diff --git a/.cursor/skills/deco-site-memory-debugging/SKILL.md b/.cursor/skills/deco-site-memory-debugging/SKILL.md index 0915d481..656c687c 100644 --- a/.cursor/skills/deco-site-memory-debugging/SKILL.md +++ b/.cursor/skills/deco-site-memory-debugging/SKILL.md @@ -74,7 +74,7 @@ process slowly accumulating garbage. 1. **`exceededMemory` / `exceededCpu` outcomes**, captured 100% by the tail worker (`deco-otel-tail`) and landing in ClickHouse `otel_logs` with - `Attributes['_source'] = 'tail-worker'`. See `docs/observability.md`, + `Attributes['_source'] = 'tail-worker'`. See `notes/observability.md`, "Error capture — three-channel model". ```sql @@ -91,7 +91,7 @@ process slowly accumulating garbage. ``` Requires the site to have adopted the tail worker per - `docs/tail-worker-recipe.md` (check `tail_consumers` in the site's + `notes/tail-worker-recipe.md` (check `tail_consumers` in the site's `wrangler.jsonc` first). If it hasn't, this query returns nothing — either onboard the recipe or fall back to the Cloudflare dashboard's own per-Worker Metrics panel, which shows exceeded-limit invocation diff --git a/.cursor/skills/deco-site-scaling-tuning/SKILL.md b/.cursor/skills/deco-site-scaling-tuning/SKILL.md index 7bdaae67..4bb4682f 100644 --- a/.cursor/skills/deco-site-scaling-tuning/SKILL.md +++ b/.cursor/skills/deco-site-scaling-tuning/SKILL.md @@ -21,15 +21,15 @@ description: LEGACY. Discover optimal autoscaling parameters for a Deno/Fresh De > stack that plays the role this skill's methodology (find the CPU/ > concurrency inflection point, pick a scaling target) was built for — the > closest current concern, per-request CPU/memory limit exhaustion, is a -> capacity/correctness problem handled via `docs/observability.md`'s +> capacity/correctness problem handled via `notes/observability.md`'s > tail-worker error-capture path (`exceededCpu`/`exceededMemory` outcomes), > not a scaling-parameter tuning problem. > > If you're looking at a current `@decocms/tanstack` or `@decocms/nextjs` > site and traffic/latency/cost seems off, this skill's `kubectl`/Prometheus > commands will simply fail (no cluster, no namespace, no Knative CRDs) — -> that is expected. Use `docs/observability.md` and -> `docs/tail-worker-recipe.md` instead. Only use this skill if you are +> that is expected. Use `notes/observability.md` and +> `notes/tail-worker-recipe.md` instead. Only use this skill if you are > specifically debugging one of the remaining Deno/Kubernetes-hosted Deco > sites outside this package split. diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..8398499c --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,105 @@ +name: Docs site + +# Builds the Deco Blocks docs site (docs/: TanStack Start + MDX, prerendered to static HTML, +# with a Pagefind search index). docs/ is a standalone Bun package with its own bun.lock, so +# every step runs in docs/. +# +# - Pull requests that touch docs/: check, build, then upload docs/dist/client as the workflow +# artifact "docs-site". It's built for the root path; to view it, unzip it and serve the +# folder with any static server that maps /x to x.html (or copy it into docs/dist/client and +# run `bun run preview`). Nothing is deployed. +# - Pushes to main that touch docs/, and manual runs on main: check, build for the /blocks/ +# base path GitHub Pages serves this repository under, then deploy docs/dist/client to +# GitHub Pages. Only decocms/blocks's main branch deploys (a manual run on another branch, +# or a fork, only builds). Needs Pages enabled with "GitHub Actions" as its source +# (Settings > Pages); until then the deploy job fails, and nothing else depends on it. + +on: + pull_request: + paths: + - "docs/**" + - ".github/workflows/pages.yml" + push: + branches: [main] + paths: + - "docs/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + +# One deploy at a time, never cancelled midway; a newer push to a PR +# cancels that PR's older preview build. +concurrency: + group: ${{ github.event_name == 'pull_request' && format('docs-site-preview-{0}', github.ref) || 'docs-site-pages' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: docs + env: + # Pages serves the repository at https://.github.io/blocks/; previews build for /. + BASE_PATH: ${{ github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && github.repository == 'decocms/blocks' && '/blocks/' || '/' }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + # `vite build` (and its prerender) runs under Node: the vite bin is `#!/usr/bin/env node`, and + # TanStack Start needs Node >= 22.12 (docs/package.json "engines"). Bun installs and runs + # the scripts; pin Node so the build doesn't depend on whatever the runner has. + - uses: actions/setup-node@v4 + with: + node-version: 22 + + # Same Bun as the rest of the repo (package.json "packageManager"). + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.5 + + - name: Install + run: bun install --frozen-lockfile + + - name: Check (types, content, Roadmap data) + run: bun run check + + # vite build (prerenders every page), then 404.html, the link check and the Pagefind index. + - name: Build docs/dist/client + run: bun run build + + - name: Upload the preview (pull requests) + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: docs-site + path: docs/dist/client + if-no-files-found: error + retention-days: 14 + + - name: Upload the Pages artifact + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && github.repository == 'decocms/blocks' + uses: actions/upload-pages-artifact@v5 + with: + path: docs/dist/client + + deploy: + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && github.repository == 'decocms/blocks' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + # The build hardcodes the /blocks/ base path, so nothing uses this step's outputs; it + # checks Pages is set up before deploying. + - uses: actions/configure-pages@v6 + + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/CLAUDE.md b/CLAUDE.md index b9924797..ad8e6a7d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,7 @@ Keys are **per deployment id** (commit sha), never a single mutable pointer — `decoVitePlugin` additionally stubs `blocks.gen` out of the server bundle so the KV copy is the isolate's only one, which is the fix for the decofile being resident three times (bundled graph + KV graph + escaped JSON string ≈ 27MB for a 9.2MB decofile, against a 128MB cap with no GC knob). **In that mode the bundled snapshot is not a fallback** — `ensureBlocksHydrated` 5xxs rather than serve an empty site the edge would cache. So it is gated on the deploy pipeline declaring it seeds `decofile:` before activation (`DECO_SEEDED_DEPLOY`, default `fastDeploy: "auto"`); CF Workers Builds and a manual `wrangler deploy` never declare it and keep the bundled snapshot. -This is deliberately **not** available in `@decocms/nextjs` — edge KV + Cloudflare Workers caching is a `tanstack`-specific concern, not something `next`'s Node/RSC target needs or should carry. Read path: `packages/blocks/src/cms/blockSource.ts`, `packages/blocks-admin/src/admin/decofile.ts` (`setFastDeployKVGetter` — dependency injection so `admin` doesn't need a hard KV dependency), `packages/tanstack/src/setupFastDeploy.ts`. Full guide + cross-repo contracts: [`docs/fast-deploy.md`](./docs/fast-deploy.md). +This is deliberately **not** available in `@decocms/nextjs` — edge KV + Cloudflare Workers caching is a `tanstack`-specific concern, not something `next`'s Node/RSC target needs or should carry. Read path: `packages/blocks/src/cms/blockSource.ts`, `packages/blocks-admin/src/admin/decofile.ts` (`setFastDeployKVGetter` — dependency injection so `admin` doesn't need a hard KV dependency), `packages/tanstack/src/setupFastDeploy.ts`. Full guide + cross-repo contracts: [`notes/fast-deploy.md`](./notes/fast-deploy.md). ## Admin Protocol diff --git a/FRAMEWORK_TODO.md b/FRAMEWORK_TODO.md index f18fa990..e9f6343b 100644 --- a/FRAMEWORK_TODO.md +++ b/FRAMEWORK_TODO.md @@ -22,7 +22,7 @@ Issues and feature gaps discovered during real site migration work. Trimmed duri ### `useScript(fn)` hydration mismatch warning — still present - `useScript` calls `fn.toString()`, which produces different output in SSR vs. client builds (minification, variable renaming). The `[useScript] Using fn.toString() for "..."` warning still fires in real dev sessions (confirmed live in production storefront dev logs during the Next.js/split-package migration work). -- **Ideal**: ship `inlineScript(str)` accepting a plain string constant, or make `useScript` stable across builds. See also `docs/next-steps-tanstack-native.md`'s proposal #2, which covers the same gap in more detail — don't build both independently. +- **Ideal**: ship `inlineScript(str)` accepting a plain string constant, or make `useScript` stable across builds. See also `notes/next-steps-tanstack-native.md`'s proposal #2, which covers the same gap in more detail — don't build both independently. ### Route files are still boilerplate - `__root.tsx`, `index.tsx`, `$.tsx`, `deco/meta.ts`, `deco/invoke.$.ts`, `deco/render.ts` are scaffolded identically per site by the migration script. @@ -74,7 +74,7 @@ Issues and feature gaps discovered during real site migration work. Trimmed duri ## Fast Deploy (KV-first content) — cross-repo follow-ups -Framework + CI scripts for fast-deploy landed in this repo (see [`docs/fast-deploy.md`](./docs/fast-deploy.md)). Remaining work lives in **other** repos: +Framework + CI scripts for fast-deploy landed in this repo (see [`notes/fast-deploy.md`](./notes/fast-deploy.md)). Remaining work lives in **other** repos: - **admin.deco.cx (Studio)**: publish a delta envelope to `/.decofile` + call `/_cache/purge`; gate on a per-site `fast_deploy_enabled` capability; dispatch the deco-sync-bot commit off the critical path. - **Site CI**: provision a KV namespace + `DECO_KV` binding; add a `sync-content-to-kv.yml` workflow; gate `deploy.yml` to code-only changes. diff --git a/MIGRATION_TOOLING_PLAN.md b/MIGRATION_TOOLING_PLAN.md index cf91ae7a..cf6c2cec 100644 --- a/MIGRATION_TOOLING_PLAN.md +++ b/MIGRATION_TOOLING_PLAN.md @@ -126,12 +126,12 @@ this plan. | 2026-05-22 | **D-10 — Server-side log normalization at the ingest worker, cost-neutral** | CF Destinations wraps every `console.log(JSON.stringify(...))` line into an OTLP LogRecord with the JSON body in `body.stringValue`. Querying by structured fields requires `JSONExtract` everywhere — slow, query-fragile, and tied to whatever the producer happens to embed. **Two design choices considered:** (a) migrate all framework `logger.{info,warn,debug}` calls to direct-POST native OTLP, ditching the CF Destinations sampled path. Substantially more code volume in production direct-POST traffic; bypasses the head sampling that keeps fleet cost bounded. (b) lift the JSON-in-body into native OTLP `LogAttributes` server-side at the `deco-otel-ingest` worker. Same wire volume, same cost. **Decision: option (b).** The ingest worker's `logsToRows` now detects JSON-shaped `body.stringValue`, lifts `level`/`msg`/`trace_id`/`span_id` plus arbitrary keys into native OTLP attributes, reduces `Body` to the human-readable `msg`, and falls back unchanged for non-JSON strings (third-party `console.log`). Dashboards drop `JSONExtractString(Body, 'level') = 'error'` in favor of `SeverityText = 'ERROR'`. **Files:** `stats-lake/ingestion/otel-ingest/src/index.ts`. Phase 4 of the observability refinement plan. | | 2026-05-22 | **D-11 — Outcome metrics layer becomes the truth source for "did we serve users today?"** | Earlier metric labels (`method`, `path`, `status`) couldn't answer "5xx rate per route per site" without joining metrics to tail-worker logs. The path label was raw-URL (unbounded cardinality risk); status was opaque (no class bucketing); no cache decision / cache layer; no commerce histogram in the framework (only apps-start sites that bumped to a recent version had it). **Decision:** expand the canonical label set for `http_requests_total` / `http_request_duration_ms` / `http_request_errors_total` to `{ method, route_pattern, status, status_class, outcome?, cache_decision?, cache_layer?, region?, …extra }`. `route_pattern` is the TanStack closed-set pattern (`/_products/$slug/p`); fallback is the normalized path. `status_class` is `2xx`/.../`5xx`/`unknown`. Cache labels lift the existing `X-Cache` / `X-Cache-Profile` headers up to the metric so dashboards answer cache-hit rate per route from the counter alone. Move `commerce_request_duration_ms` declaration into `@decocms/start` so every site emits it as soon as the framework is bumped, regardless of apps-start version (apps register operation strings only). Labels: `{ provider, operation, status_class?, cached? }`. **Files:** `src/middleware/observability.ts` (`statusClassFor`, `RequestMetricLabels`, `CacheLayer`, `recordCommerceMetric`, expanded `recordCacheMetric` signature). Phase 2 of the observability refinement plan. | | 2026-05-22 | **D-12 — Direct-POST OTLP trace exporter for framework `deco.*` spans** | Empirical verification (May 2026) confirmed the framework's 10+ `withTracing` calls produced zero rows in `otel_traces`. Root cause: the bridge tracer in `instrumentWorker` delegates to `trace.getTracer(...)` on the `@opentelemetry/api` global. With no `TracerProvider` registered (the common case — CF Workers only auto-installs a provider when `observability.traces.destinations` is set), every framework span is silently discarded. **Decision:** introduce `otelHttpTracer.ts` — a direct-POST OTLP/HTTP trace exporter that mirrors the existing meter + error-log adapters. Same transport: per-isolate buffer, ctx.waitUntil flush, FNV-1a hash sampling at `headSamplingRate` (default 0.01 matches CF Destinations recommendation). Consistent per-trace decision so child spans are kept iff their root is kept. Honors inbound W3C `traceparent` — if the remote parent arrived sampled, every span in that trace is exported regardless of the rate. Wired alongside the existing `@opentelemetry/api` bridge via `configureTracerStack` — CF auto-spans still flow to the CF dashboard, framework spans direct-POST to ClickHouse. Default-on `injectTraceContext` inside `createInstrumentedFetch` was already in place. **Files:** `src/sdk/otelHttpTracer.ts`, `src/sdk/otel.ts` (`configureTracerStack`), `src/sdk/workerEntry.ts` (traceparent parsing). Phase 3 of the observability refinement plan. | -| 2026-05-22 | **D-13 — Per-site Grafana dashboards + alert rules are auto-provisioned from `dim_sites`** | Hand-built dashboards drift the moment a new site lands: a fleet of 100 sites can't be maintained by a human curator. **Decision:** the canonical observability provisioning lives in [`stats-lake/observability/`](../../../stats-lake/observability/) — a single dashboard template + a single alert-rule template, parameterized by `{{site}}`/`{{team}}`/`{{datasource_uid}}` and rendered once per site by `scripts/provision-dashboards.ts` (reads `dim_sites` joined to `dim_teams`, writes to `dashboards/dist//.json` + `alerts/dist//.yaml`). Alerts use **anomaly bands, not thresholds** — current 5-min mean vs 24h rolling mean ± 3σ, fires after 10 minutes outside the band. Same rule set runs on every site, but the baseline is per-site so a noisy storefront doesn't false-positive against a quiet one. Every alert carries a `runbook_url` annotation pointing at [`deco-start/docs/runbooks/`](../docs/runbooks/) — the runbook is part of the alert, not a separate artifact. **Decision points open** (Phase 5 of the refinement plan): alerting venue (Grafana → email, Linear MCP tickets, both, or none for v1). **Files:** `stats-lake/observability/{dashboards,alerts,scripts,README.md}` + `deco-start/docs/runbooks/`. | +| 2026-05-22 | **D-13 — Per-site Grafana dashboards + alert rules are auto-provisioned from `dim_sites`** | Hand-built dashboards drift the moment a new site lands: a fleet of 100 sites can't be maintained by a human curator. **Decision:** the canonical observability provisioning lives in [`stats-lake/observability/`](../../../stats-lake/observability/) — a single dashboard template + a single alert-rule template, parameterized by `{{site}}`/`{{team}}`/`{{datasource_uid}}` and rendered once per site by `scripts/provision-dashboards.ts` (reads `dim_sites` joined to `dim_teams`, writes to `dashboards/dist//.json` + `alerts/dist//.yaml`). Alerts use **anomaly bands, not thresholds** — current 5-min mean vs 24h rolling mean ± 3σ, fires after 10 minutes outside the band. Same rule set runs on every site, but the baseline is per-site so a noisy storefront doesn't false-positive against a quiet one. Every alert carries a `runbook_url` annotation pointing at [`deco-start/docs/runbooks/`](../notes/runbooks/) — the runbook is part of the alert, not a separate artifact. **Decision points open** (Phase 5 of the refinement plan): alerting venue (Grafana → email, Linear MCP tickets, both, or none for v1). **Files:** `stats-lake/observability/{dashboards,alerts,scripts,README.md}` + `deco-start/docs/runbooks/`. | | 2026-05-22 | **D-17 — Alerting venue: none for v1; ship dashboards-only** | The action layer (Phase 5) generates anomaly-band alert rule templates per site, but every alert needs a pager and we don't have an on-call rotation. **Decision:** ship dashboards-only for v1. The alert templates in `stats-lake/observability/alerts/templates/site-rules.yaml` stay versioned so they evolve with the dashboards, but `provision-dashboards.ts` only templates them into `alerts/dist/` when `--with-alerts` is passed, and no Grafana → email / Linear MCP / PagerDuty receiver is wired up. Rejected alternatives: (a) Grafana → email — emails get muted within a week without a triage owner; (b) Linear MCP ticket-per-fire — creates noise during active incidents and you can't triage a ticket while firefighting; (c) both — overkill before we know which storefronts will be noisiest. **Revisit when:** an on-call rotation exists, OR a specific incident class earns dedicated paging (e.g., billing-critical sites that need 24/7 coverage). **Files:** `stats-lake/observability/scripts/provision-dashboards.ts` (`--with-alerts` flag), `stats-lake/observability/README.md`. Phase 5 of the observability refinement plan. | | 2026-05-22 | **D-16 — `deco-audit-observability` is warn-by-default; promote to block once the fleet is clean** | The audit (D-14) detects drift in `tail_consumers`, `version_metadata`, `DECO_METRICS`, and `DECO_OTEL_*_ENDPOINT` vars across every storefront wrangler.jsonc. Pre-merge blocking on day one would fail PRs that have nothing to do with observability — storefronts are upgraded over weeks, not all at once. Advisory comments alone get ignored. **Decision:** `--mode warn` (default) annotates findings via `::warning::` GitHub Actions lines but always exits 0, so observability drift surfaces in CI without blocking ship. `--mode block` exits 1 on any `error`-severity finding for use once the fleet has been pulled current. Storefronts opt into `block` per-repo by wiring `--mode block` in their workflow when they've cleared their findings. Includes `--github` flag to emit native annotations. **Files:** `scripts/audit-observability-config.ts` (parseArgs `--mode` / `--github`, main exit policy), test coverage in the matching `.test.ts` (6 new CLI smoke tests via tsx subprocess). Phase 6 of the observability refinement plan. | | 2026-05-22 | **D-15 — OTel Collector swap is a documented target state, not a committed milestone** | The current `deco-otel-ingest` Worker is a hand-rolled OTLP/HTTP parser + ClickHouse inserter. It works at ~14M POSTs/month, but each new OTLP protocol revision, each new receiver (gRPC OTLP, Prometheus remote-write), and each new sink would cost us code to write and maintain — code the upstream OTel Collector + `clickhouseexporter` ship as a maintained product. **Decision:** mark the Collector swap as the eventual target state, **with no committed timeline**, and capture the explicit revisit-triggers so we know when to act rather than relying on "we should think about this sometime." The decision is cheap because the ClickHouse schema is **already** the canonical `clickhouseexporter` shape — that was a deliberate design choice in `clickhouse/schema/otel/` so the ingest path stays swappable. Migration when triggered is config-only at the data layer: stand up a Collector in the same CF account, configure `otlphttp`/`otlpgrpc` receivers + `clickhouse/v1` exporter + `transform` processors for the existing PII redaction and JSON-body lift (1:1 from current Worker logic), DNS-cutover the `DECO_OTEL_*_ENDPOINT` vars. **Revisit triggers:** OTLP 2.0 ships, we need gRPC OTLP, we need a non-ClickHouse sink, ingest volume exceeds 100M POSTs/mo, or a hand-rolled parser develops a defect we can't fix quickly. **Files:** [`stats-lake/ingestion/otel-ingest/COLLECTOR_TARGET.md`](../../../stats-lake/ingestion/otel-ingest/COLLECTOR_TARGET.md) holds the full migration runbook + rollback story. Phase 7 of the observability refinement plan; explicitly **optional**. | | 2026-05-22 | **D-14 — `deco-audit-observability` covers fleet bindings, not just the `observability` block** | The existing audit only checked the `observability` block (sampling rates, persist, destinations). Phase 1+2+3 made several other wrangler keys load-bearing: `tail_consumers` must list `deco-otel-tail` (Phase 1 enrichment is a no-op without it), `version_metadata` must bind `CF_VERSION_METADATA` (no `service.version` without it = no deploy correlation), `analytics_engine_datasets` must bind `DECO_METRICS` (no AE meter), `vars.DECO_OTEL_{METRICS,TRACES,LOGS}_ENDPOINT` must resolve (direct-POST channels silently no-op otherwise). **Decision:** expand the audit with six new rules under a sibling function `auditFleetBindings` and a composing `auditWranglerConfig`. Severity tuned to the impact: tail consumer + version_metadata are `error` (operational coverage gap); the rest are `warn` (degraded mode, not total failure). Drift is detected today; the matching `--fix` codemod and CI gate hardness (block / warn / advisory) are Phase 6 decision points still open. **Files:** `scripts/audit-observability-config.ts` (`auditFleetBindings`, `auditWranglerConfig`). | -| 2026-05-19 | **D-8 — Cloudflare Tail Worker (Strategy B) is the canonical 100% error capture mechanism** | At fleet scale (100 sites, 2.5B req/month) head sampling forces a tradeoff: 1% sampling makes the `head_sampling_rate * 5B-event-cap` math work, but 99% of error traces and 99% of error-correlated logs get dropped at the CF Destinations head. The framework already covers framework-emitted errors via the in-Worker direct-POST channel (`DECO_OTEL_LOGS_ENDPOINT`) — that's 100% of `logger.error(...)` regardless of `head_sampling_rate`. But three structural gaps remain that *no* in-Worker code can close from inside its own request handler: (a) uncaught throws (the worker isolate is already unwinding when the throw bubbles out of `instrumentWorker`), (b) `exceededCpu` / `exceededMemory` outcomes (the runtime kills the producer before any in-Worker code can run), (c) raw `console.error(...)` from third-party SDKs that bypass the framework logger. **Decision:** introduce [`deco-otel-tail`](https://github.com/decocms/stats-lake/tree/main/ingestion/otel-tail) — a Cloudflare Tail Worker in `stats-lake/ingestion/otel-tail/`. CF invokes it on every execution of any producer worker that lists it under `tail_consumers` (`wrangler.jsonc`). The handler filters TraceItems down to the interesting subset (`outcome !== "ok" \|\| exceptions.length > 0 \|\| logs.some(l => l.level === "error")`), translates each to OTLP LogRecords (one per exception, one per `error`-level log line, plus a synthetic LogRecord for non-ok outcomes that didn't surface either), and forwards them to `deco-otel-ingest` via an in-account service binding (no public hop). Rows land in `otel_logs` with `Attributes['_source'] = 'tail-worker'` so dashboards can split tail-captured errors from direct-POST + CF-Destinations errors. **Rejected alternatives:** (1) **Codemod + lint to enforce `logger.error` calls** — structural coverage gap; can't catch uncaught throws or 1101s by definition, and a lint can't enforce calls inside third-party code. (2) **Logpush + ingest pipeline** — bypassed because Logpush isn't OTLP-shaped and the pricing curve loses to tail-worker at our scale. (3) **CF dashboard log retention only** — no fan-out to ClickHouse, no fleet-wide query surface. (4) **DO-buffered tail-on-error** — ~$8K/mo at fleet scale per the cost model in `docs/observability.md`. **Coverage matrix lives in [`docs/observability.md`](./docs/observability.md) → "Error capture — three-channel model".** Producer-side wiring is one line per `wrangler.jsonc`: `tail_consumers: [{ service: "deco-otel-tail" }]`. **Operational dependency:** the tail worker MUST be deployed to the same Cloudflare account as `deco-otel-ingest` (currently `c95fc4cec7fc52453228d9db170c372c`) so the `[[services]]` binding resolves. If `deco-otel-ingest` ever moves accounts, the service binding collapses to a public HTTPS POST and the model needs revisiting. **Agent behaviour:** when designing error capture for new Worker-deployed code, default to Strategy B for the long tail; don't reach for codemod/lint enforcement unless there's a specific code-quality concern beyond capture. | +| 2026-05-19 | **D-8 — Cloudflare Tail Worker (Strategy B) is the canonical 100% error capture mechanism** | At fleet scale (100 sites, 2.5B req/month) head sampling forces a tradeoff: 1% sampling makes the `head_sampling_rate * 5B-event-cap` math work, but 99% of error traces and 99% of error-correlated logs get dropped at the CF Destinations head. The framework already covers framework-emitted errors via the in-Worker direct-POST channel (`DECO_OTEL_LOGS_ENDPOINT`) — that's 100% of `logger.error(...)` regardless of `head_sampling_rate`. But three structural gaps remain that *no* in-Worker code can close from inside its own request handler: (a) uncaught throws (the worker isolate is already unwinding when the throw bubbles out of `instrumentWorker`), (b) `exceededCpu` / `exceededMemory` outcomes (the runtime kills the producer before any in-Worker code can run), (c) raw `console.error(...)` from third-party SDKs that bypass the framework logger. **Decision:** introduce [`deco-otel-tail`](https://github.com/decocms/stats-lake/tree/main/ingestion/otel-tail) — a Cloudflare Tail Worker in `stats-lake/ingestion/otel-tail/`. CF invokes it on every execution of any producer worker that lists it under `tail_consumers` (`wrangler.jsonc`). The handler filters TraceItems down to the interesting subset (`outcome !== "ok" \|\| exceptions.length > 0 \|\| logs.some(l => l.level === "error")`), translates each to OTLP LogRecords (one per exception, one per `error`-level log line, plus a synthetic LogRecord for non-ok outcomes that didn't surface either), and forwards them to `deco-otel-ingest` via an in-account service binding (no public hop). Rows land in `otel_logs` with `Attributes['_source'] = 'tail-worker'` so dashboards can split tail-captured errors from direct-POST + CF-Destinations errors. **Rejected alternatives:** (1) **Codemod + lint to enforce `logger.error` calls** — structural coverage gap; can't catch uncaught throws or 1101s by definition, and a lint can't enforce calls inside third-party code. (2) **Logpush + ingest pipeline** — bypassed because Logpush isn't OTLP-shaped and the pricing curve loses to tail-worker at our scale. (3) **CF dashboard log retention only** — no fan-out to ClickHouse, no fleet-wide query surface. (4) **DO-buffered tail-on-error** — ~$8K/mo at fleet scale per the cost model in `notes/observability.md`. **Coverage matrix lives in [`notes/observability.md`](./notes/observability.md) → "Error capture — three-channel model".** Producer-side wiring is one line per `wrangler.jsonc`: `tail_consumers: [{ service: "deco-otel-tail" }]`. **Operational dependency:** the tail worker MUST be deployed to the same Cloudflare account as `deco-otel-ingest` (currently `c95fc4cec7fc52453228d9db170c372c`) so the `[[services]]` binding resolves. If `deco-otel-ingest` ever moves accounts, the service binding collapses to a public HTTPS POST and the model needs revisiting. **Agent behaviour:** when designing error capture for new Worker-deployed code, default to Strategy B for the long tail; don't reach for codemod/lint enforcement unless there's a specific code-quality concern beyond capture. | The full text of the constitutional rule (loaded into every agent session for this repo) lives at diff --git a/README.md b/README.md index aeac3716..7b800a3c 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ createAdminSetup({ meta: () => Promise.resolve({}), css: "" }); setupTanstackFastDeploy(); ``` -Add `decoVitePlugin()` to Vite and mount `cmsRouteConfig()` in the catch-all route. See the working [`tanstack-smoke`](./examples/tanstack-smoke) application and the [fast deploy guide](./docs/fast-deploy.md) for production wiring. +Add `decoVitePlugin()` to Vite and mount `cmsRouteConfig()` in the catch-all route. See the working [`tanstack-smoke`](./examples/tanstack-smoke) application and the [fast deploy guide](./notes/fast-deploy.md) for production wiring. ### Next.js App Router @@ -250,14 +250,14 @@ New contributors are always welcome—start with an [open issue](https://github. | Topic | Guide | | --- | --- | -| Fast deploy and KV-backed content | [`docs/fast-deploy.md`](./docs/fast-deploy.md) | -| Observability | [`docs/observability.md`](./docs/observability.md) | -| Troubleshooting | [`docs/troubleshooting.md`](./docs/troubleshooting.md) | -| Operations runbooks | [`docs/runbooks`](./docs/runbooks) | -| Deco filesystem contract | [`docs/deco-fs-contract.md`](./docs/deco-fs-contract.md) | -| Hydration and SSR migration | [`docs/hydration-and-ssr-migration.md`](./docs/hydration-and-ssr-migration.md) | -| Known gaps | [`docs/known-gaps.md`](./docs/known-gaps.md) | -| Storefront implementation skills | [`docs/skills`](./docs/skills) | +| Fast deploy and KV-backed content | [`notes/fast-deploy.md`](./notes/fast-deploy.md) | +| Observability | [`notes/observability.md`](./notes/observability.md) | +| Troubleshooting | [`notes/troubleshooting.md`](./notes/troubleshooting.md) | +| Operations runbooks | [`notes/runbooks`](./notes/runbooks) | +| Deco filesystem contract | [`notes/deco-fs-contract.md`](./notes/deco-fs-contract.md) | +| Hydration and SSR migration | [`notes/hydration-and-ssr-migration.md`](./notes/hydration-and-ssr-migration.md) | +| Known gaps | [`notes/known-gaps.md`](./notes/known-gaps.md) | +| Storefront implementation skills | [`notes/skills`](./notes/skills) | ## License diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..7829c8fa --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.output/ +.tanstack/ +.vinxi/ +.vite/ +__pycache__/ +# The route tree is generated by TanStack Router on dev/build, but it's committed so +# `bun run check` (tsc) works on a fresh clone. The repo root ignores *.gen.ts. +!src/routeTree.gen.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..df4478e1 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,274 @@ +# Docs site architecture + +The Deco Blocks docs site is a static site built with **TanStack Start** (React 19, TanStack +Router, Vite), **Tailwind CSS v4**, **MDX** for content, **Shiki** for code highlighting and +**Pagefind** for search. `bun run build` prerenders every route to plain HTML that GitHub Pages +can serve; in the browser the same pages hydrate into a client-side app. + +This folder is a standalone Bun package: its own `package.json` and `bun.lock`, not one of the +monorepo's workspaces, so the root lockfile never changes because of it. + +**Prerequisites: Bun 1.3.5 and Node 22.12 or newer.** Bun installs packages and runs the scripts, +but `vite build` and its prerender run under Node (the `vite` bin is `#!/usr/bin/env node`, and +TanStack Start requires Node >= 22.12, see `engines` in `package.json`). Don't run the build with +`bun --bun vite build`: the prerender crashes under Bun's runtime. CI pins both. + +## Commands + +| Command | What it does | +|---|---| +| `bun install` | Install (in `docs/`). | +| `bun run dev` | Dev server with HMR on http://localhost:3000 (server-rendered, like production). | +| `bun run build` | `vite build` (client + server bundles, then prerender of every page into `dist/client/`), then `scripts/postbuild.ts`: `404.html`, link check, Pagefind index. | +| `bun run preview` | Serves `dist/client/` the way GitHub Pages does (http://localhost:4173): `/x` → `x.html`, `/x/` → `x/index.html`, a directory without its slash redirects (301). | +| `bun run check` | `tsc --noEmit`, then `scripts/check-content.ts` (frontmatter, h1 = title, links between pages and to headings, no local paths), then `scripts/check-roadmap.ts` (the Roadmap data). | + +Environment: + +- `BASE_PATH=/blocks/` builds for a sub-path (GitHub Pages serves this repo at `/blocks/`). + Default `/`. Used by `vite.config.ts` (Vite `base`, from which TanStack Start derives the + router basepath) and by the post-build scripts and preview server. Use the same value for + `build` and `preview`. +- `DOCS_LINKS=warn` makes broken links a warning instead of a build failure. +- `PORT=…` for `preview`. + +**Deploy `dist/client/`.** (`dist/server/` is the SSR bundle used during the build; nothing serves +it in production.) Opening the HTML files straight from disk (`file://`) shows the content but +not the styles or scripts, since asset URLs are root-relative; use `bun run preview`. + +## Layout + +``` +docs/ +├── content/ MDX pages, one folder per docs version +│ ├── v7/*.mdx the current release (/v7/…); index.mdx is /v7/ +│ └── next/*.mdx the next major (served at /next/; /next/ is its home) +├── components/ +│ ├── mdx/ components MDX pages use (API: components/mdx/README.md) +│ ├── ui/ Icon/Mark (the icon set), Brand (wordmark, symbol) +│ ├── home/ the two home pages: index.tsx default export (see "Home") +│ ├── roadmap/ the Roadmap pages, from data/roadmap.json (see "Roadmap") +│ ├── widgets/ interactive MDX widgets: named exports of index.tsx +│ └── search/ the ⌘K dialog: index.tsx default export (see "Search") +├── data/roadmap.json the Roadmap's data +├── src/ the app (TanStack Start's srcDirectory) +│ ├── router.tsx createRouter: basepath, scroll restoration, hydrate hook +│ ├── routes/ file-based routes (routeTree.gen.ts is generated, and committed) +│ │ ├── __root.tsx , head (fonts, CSS, theme script), header, global UI +│ │ ├── index.tsx / Home (the current release, v7) +│ │ ├── roadmap/ /roadmap/ Roadmap overview (index.tsx), /roadmap/
($section.tsx) +│ │ └── $version/ +│ │ ├── index.tsx /next/ (the next major's home), /v7/ (its index.mdx) +│ │ └── $slug.tsx /next/quickstart … a doc page +│ ├── layout/ Header, Sidebar, DocsShell (+ LandingShell), Rail, DocPage, NotFound, … +│ ├── lib/ content (manifest + page loading), nav models, chrome, theme, ui, versions +│ └── styles/ app.css (entry) → theme.css, tokens.css, base.css, prose.css, components/ +├── build/ build-time code (Node): manifest, rehype plugin, Shiki theme, slugify +├── scripts/ check-content, check-roadmap, postbuild, preview, site-files (the URL → file lookup) +├── assets/ brand SVGs, cobogó pattern (imported with ?raw) +├── public/ copied as-is (favicon.svg) +└── vite.config.ts +``` + +## Content and the manifest + +Each `content//.mdx` is one page at `//`; `index.mdx` is the +version's index (`//`). Frontmatter: + +| Key | Required | Meaning | +|---|---|---| +| `title` | yes | Plain text of the page's `# h1` (`check` verifies they match). ``, pager, search. | +| `group` | yes | Sidebar group. Groups appear in the order of their first page; a group's pages must be adjacent in `order`. | +| `order` | yes | Number; position in the version's reading order, unique per version and kind. | +| `nav` | no | Sidebar label (the old `data-nav`). Defaults to `title`. | +| `kind` | no | `docs` (default) or `internals`: which tab the page belongs to (Docs / Under the hood). | +| `eyebrow` | no | The small uppercase label above the h1. Defaults to `group`. | +| `description` | no | `<meta name="description">`. | + +Unknown keys are an error. `build/manifest.ts` reads only the frontmatter (YAML) of every file and +builds the **manifest**: per version, the pages in reading order (all `docs` pages by `order`, then +all `internals` pages by `order`) and the sidebar groups. It's served to the app as the virtual +module `virtual:content-manifest` (invalidated in dev when content files change), and +`vite.config.ts` uses it for the list of pages to prerender. + +From the manifest (`src/lib/nav.ts`): + +- **Sidebar**: the groups of the current page's kind in its version. +- **Breadcrumb**: `Docs` (or `Under the hood`) › group (unless it repeats) › nav label. +- **Pager**: previous/next in reading order; the next major's last page leads to the Roadmap. +- **Tabs**: Docs → the version's first `docs` page; Under the hood → its first `internals` page + (falling back to the default version's). + +The page body is compiled by `@mdx-js/rollup` with `remark-gfm`, `remark-frontmatter`, +`remark-mdx-frontmatter` and `build/rehype-docs.ts`, which at build time: gives h1–h3 ids (the +old site's slug rule, de-duplicated per page; JSX `<h2 id="…">` pins one), exports +`headings` (h2/h3, for the rail), inserts `<TocInline />` after the h1 and lede, highlights fenced +code with Shiki and parses the fence meta (`title="…"`), and adds the inline-code classes the old +`app.js` added at runtime. So the prerendered HTML is already complete; nothing is rewritten in +the browser. + +Each page is its own JS chunk (`import.meta.glob('/content/*/*.mdx')`, lazy). The route loader +awaits the chunk before rendering (server render and client navigation), and the router's +`hydrate` hook loads the current page's chunk before hydration, so a page never suspends and +always hydrates against identical markup. + +## Versions + +`src/lib/versions.ts` lists them in select order (`v7`: "v7 (current)", then `next`: "Next major"), +each with its optional `home` path (`/` for v7, `/next/` for the next major), and the default (`v7`, +used by the tabs and the not-found page outside a version). The header's version `<select>` (in the +drawer below 900px) shows on doc pages and on both homes: on a home it goes to the other version's +home (`/` ↔ `/next/`); on a doc page to the same slug in the other version if it exists, else to that +version's index. The Home tab goes to the current version's home, "Get started" to its Quickstart. A +version index without `index.mdx` (and without a home there) renders the version's first page (kept +out of search). Adding a version: an entry in `VERSIONS` and a `content/<id>/` folder. + +The Roadmap (`/roadmap/…`) is version-less (its chrome shows the next major). + +## Layouts and "chrome" + +The root layout needs to know, before rendering, whether the page is the full-bleed **landing** +(Home: floating header over the dark hero band, no sidebar on desktop) or the three-column +**docs** layout, and which header tab is current. A route declares this as +`staticData: { chrome: { layout, tab, version? } }`, or returns `{ chrome }` from its loader when +it depends on the URL (doc pages do). `useChrome()` (`src/lib/chrome.ts`) reads the deepest +match; the header, sidebar and search read it and style themselves with utilities. + +- `DocsShell` (`src/layout/DocsShell.tsx`): sidebar · main (breadcrumb, page tools, the article, + pager, footer) · rail. Props: `nav`, `crumbs`, `pager`, `rail`, `children`. Models in + `src/lib/nav.ts` (`NavGroup`, `Crumb`, `PagerLink`, `RailItem`). +- `LandingShell`: sidebar as mobile drawer only, `main`, then an optional `footer`. +- The rail's scroll-spy, the mobile drawer (modal, focus-trapped, closes on + navigation/Escape/resize), copy link, print, back to top, theme toggle and toast are all in + `src/layout/`; the inline outline (below 1200px) is `components/mdx/TocInline.tsx`. + +## Styles + +Styling is Tailwind v4 utilities on the components. `src/styles/app.css` is the entry: Tailwind +with its preflight, in layers `theme < base < prose < components < utilities`. The few CSS files +hold only what utilities can't express, plus named classes for markup repeated many times per +page (see `components/docs.css` below). + +- `theme.css`: the Tailwind theme. Colours are `@theme inline` aliases of the tokens + (`bg-surface` compiles to `background-color: var(--surface)`), and Tailwind's default palette is + removed, so only the site's colours exist. Fonts (`font-sans`, `font-mono`), type sizes named by + their px value (`text-13`, `text-12.5`, fluid `text-display`/`text-hero`/`text-section`), + tracking (`tracking-ui`, `tracking-label`, …), radii (`rounded-box` 14px, `rounded-dialog`), + shadows (`shadow-sm/md/lg/win/float/lift`), easings (`ease-out-quart`, `ease-out-expo`; bare + `transition-*` defaults to .25s ease), the site's breakpoints (`2xs` 360, `xs` 480, `sm` 560, + `home-sm` 640, `md` 768, `hdr-sm` 860, `nav` 900, `hdr` 980, `home` 1000, `lg` 1024, `home-lg` + 1100, `home-xl` 1140, `rail` 1200, `xl` 1280, `wide` 1600; a width used once stays arbitrary, + `max-[430px]:`), containers (`max-w-article`, `max-w-landing`, `max-w-shell`, …; `@min-rm:` and + `@min-rm-sm:` for the Roadmap's container queries), `h-header`/`top-header`/`scroll-mt-header`, animations + (`animate-enter`, `animate-pop`, …). Variants: `dark:` (data-theme, else the OS), `js:`/`no-js:`, + `nav-open:` (mobile drawer open). Shared utilities: `pill-on`, `eyebrow-label`, + `scrollbar-thin`, `scrollbar-none`. +- `tokens.css`: the raw colour tokens (`--forest`, `--lime`, warm neutrals, `--syn-*` syntax + colours, `--gx-*` Roadmap statuses, …), each written once as `light-dark(<light>, <dark>)`. The + theme is the root's `color-scheme`: `light dark` by default (follows the OS), `light`/`dark` + under `data-theme`, `light` in print (plus a few print-only values). +- `base.css`: element defaults on top of preflight (body, selection, focus ring, inline code, + `pre`, `kbd`, the 16px `.icon`, reduced motion, print page setup). +- `prose.css` (layer `prose`): the article look for what MDX writes as bare HTML inside + `<article class="doc-section">` (h1–h3, the lede, p, lists with "–" markers and numbered hairline + rows, links, strong/em, table cells), which can't carry classes. Rules are scoped under + `.doc-section` / `.doc-page`, wrap their element selectors in `:where()` and skip `.not-prose` + subtrees, so `not-prose` opts a block out (widgets, Roadmap blocks, the MDX components' own + markup). What lets any class override them is the layer, not specificity: `prose` sits below + `components` and `utilities`. +- `components/docs.css` (layer `components`): named classes, written with `@apply`, for markup + that repeats tens to hundreds of times per page, where inline class strings made the + prerendered HTML much heavier: the code panel (`code-head`, `code-lang`, `copy-button`, + `code-pre` with its scroll-fade masks), `heading-anchor`, sidebar `nav-link`, the outline's + `toc-link` (rail and inline), the search rows (`search-hit*`), and the Roadmap's + `feature-chip`, `feature-row`, `status-dot`, `gx-vp` pill and `todo-heading`. A variant (the + current link, a size, a state) stays a utility on the element and always wins. + `scripts/postbuild.ts` fails the build if any page's HTML goes over 48KB gzipped. +- `components/home.css` (layer `components`): the Studio mock's range-slider vendor + pseudo-elements (`.home-range`), which need one rule per vendor selector, and the publishing + timeline's dashed connectors (`.tl-linked`, pseudo-elements that flip direction below 1000px). + +The docs shell (`#shell`, `DocsShell.tsx`) is full width: the sidebar is pinned to the left edge, +the rail to the right, and the middle column takes the rest. Inside it every child of `main` +(breadcrumb row, article, pager, footer) shares one centred reading column, `max-w-article` +(720px), widening to `max-w-article-wide` (800px) from 1600px; code, tables and callouts keep the +text's edges. Above 1920px the shell and the docs header row cap at `max-w-shell` and centre, and +the sidebar's background runs to the window edge. The landing keeps its own 1200px container. + +Theme: an inline script (`ScriptOnce` in `<head>`) applies `?theme=dark|light` or the saved choice +(`localStorage['deco-blocks-docs-theme']`) as `data-theme` before first paint. No attribute +means "follow the OS". + +## Search + +`scripts/postbuild.ts` runs Pagefind's Node API over the prerendered HTML: only elements with +`data-pagefind-body` are indexed (doc articles have it; Home and the Roadmap opt in by adding it), +and each page is indexed under its route (`/next/quickstart`). The bundle lands in +`dist/client/pagefind/`; in the browser, `import(`${import.meta.env.BASE_URL}pagefind/pagefind.js`)` +(with `/* @vite-ignore */`) loads it, and results come back with the base path prepended. Headings +have ids, so sub-results link to `#anchors`. It only exists after a build (not in `dev`). + +The dialog is `components/search/index.tsx` (default export), mounted by `src/layout/GlobalUi.tsx` +if present. The header ⌘K button, the drawer's search button and the shortcuts (⌘K, Ctrl+K, `/`) +dispatch the `docs:search-open` window event (`SEARCH_OPEN_EVENT` / `openSearch()` in +`src/lib/ui.ts`); the dialog listens for it. + +## Home, Roadmap, widgets + +The routes pick these up with `import.meta.glob`, so each folder can be built independently: + +- **Home**: `components/home/index.tsx` default export `Home({ version })` renders a version's + whole landing: `/` renders v7's (`components/home/v7/`), `/next/` the next major's (the rest of + `components/home/`). Both sit in `HomeFrame` (`Frame.tsx`): `<LandingShell nav={sidebarFor(version, + 'docs')} footer={<SiteFooter columns={…}/>}>`, so the drawer and the footer columns follow the + version (`NEXT_COLUMNS` in `Footer.tsx`, `V7_COLUMNS` in `v7/columns.ts`). The v7 home reuses the + next major's pieces (hero band, journey carousel, cards, status window, publishing timeline, + `StepperShell`). The `$version/index` loader must not reference the home module (loaders stay in + the entry chunk; only the component is split). Styled with utilities (shared pieces in + `components/home/ui.tsx`); the + cobogó defs are `assets/cobogo-defs.svg` (imported `?raw`), brand marks in + `components/ui/Brand.tsx`, icons in `components/ui/Icon.tsx`. +- **Roadmap**: one page per section of the old Roadmap (it showed one section at a time): + `/roadmap/` is the overview, `/roadmap/blockers`, `/roadmap/api`, … the rest + (`components/roadmap/sections.ts` maps sections and ids to URLs; the routes are + `src/routes/roadmap/`). `model.ts` checks data/roadmap.json and derives everything shown; + `SectionViews.tsx` renders a section (router-free markup, so `scripts/check-roadmap.ts` can render + and check it); `RoadmapPage.tsx` puts it in `<DocsShell>` and handles client-side links, the + feature filter and deep links to filtered-out rows. Route `head()`s import only `sections.ts` + (pure TS, which also holds the section labels for document titles): anything that imports the + data from a `head()` lands in the entry chunk every page loads, and `scripts/postbuild.ts` fails + the build if Roadmap data shows up there. It's styled with utilities in `SectionViews.tsx` + (statuses map to the `--gx-*` tokens there); it has no stylesheet of its own. The route's chrome is + `{ layout: 'docs', tab: 'roadmap' }`. Links from content may use `/roadmap#<id>`: `MdxLink` + resolves it to the page holding the id. +- **Widgets**: capitalized named exports of `components/widgets/index.tsx` become MDX components + (`<Walkthrough />`). + +Shared helpers for all of them: `toast`, `announce`, `copyText`, `openSearch`, `closeMenu` +(`src/lib/ui.ts`); `CopyButton` (`components/mdx/CodeBlock.tsx`). + +## Prerendering and the base path + +`vite.config.ts` gives TanStack Start's prerenderer an explicit page list: `/`, the Roadmap pages, each +version index and every manifest page (no crawling). Each becomes `dist/client/<path>.html` +(`next/quickstart.html`, `roadmap/api.html`; indexes `v7/index.html`, `roadmap/index.html`), which GitHub Pages +serves at the extension-less URL without a redirect. `404.html` is rendered after the build by the +built server handler for an unmatched URL, so it hydrates cleanly as the router's not-found state. + +Links: write `to="/next/quickstart"` (TanStack `<Link>`) or `[x](/next/quickstart)` in MDX; the +router adds the base path. Static files from `public/` need `import.meta.env.BASE_URL`. + +## Checks + +- `bun run check`: types, frontmatter, h1 vs title, inter-page links and heading anchors in MDX, + no local filesystem paths in content; then `scripts/check-roadmap.ts` (also the first step of + `bun run build`): the Roadmap data, its rendered pages (ids, counts, wording, every link) and + the docs' links into it. +- `bun run build`: fails if a page fails to render, or (post-build) on any internal link in the + rendered HTML whose page or `#fragment` doesn't exist, Roadmap anchors included. + +## Public-content policy + +This folder is published (public repository and public site). Only commit what anyone may read: +no credentials, no private repository names or code, no customer names, no local paths, no live +security issues. See README.md. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..9a8b4b86 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,131 @@ +# Deco Blocks docs site + +The source of the Deco Blocks documentation site: **Home** (`/`), the **Docs** and **Under the +hood** pages of each docs version (`/next/…` for the next major, `/v7/…` for the current +release), and the **Roadmap** (`/roadmap/…`, the to-do list that gets the next major to a +release), plus ⌘K search, a light/dark theme toggle and a printable layout. + +It's a static site: **TanStack Start** (React 19, TanStack Router, Vite) prerenders every page to +HTML, **MDX** holds the content, **Shiki** highlights code at build time, **Tailwind CSS v4** +carries the design tokens and **Pagefind** builds the search index. How it fits together, and the +conventions for pages and components, are in [`ARCHITECTURE.md`](./ARCHITECTURE.md). + +## Build and preview + +`docs/` is a standalone Bun package (its own `package.json` and `bun.lock`, not a workspace of +the monorepo). It needs Bun 1.3.5 and Node 22.12 or newer: Bun installs and runs the scripts, +and `vite build` runs under Node. From `docs/`: + +```sh +bun install +bun run dev # dev server on http://localhost:3000 +bun run check # tsc, content checks, Roadmap data checks +bun run build # prerender into dist/client/, 404.html, link check, search index +bun run preview # serve dist/client/ the way GitHub Pages does, on http://localhost:4173 +``` + +- `BASE_PATH=/blocks/` builds for a sub-path (GitHub Pages serves this repository at `/blocks/`); + the default is `/`. Use the same value for `build` and `preview`. +- `DOCS_LINKS=warn` reports broken internal links without failing the build. +- The output to deploy is `dist/client/` (`dist/` is gitignored). Search only works on a build, + not in `dev`. Open the build through `bun run preview`, not from disk: asset URLs are + root-relative. + +CI (`.github/workflows/pages.yml`) checks and builds the site on every pull request that touches +`docs/` and uploads `dist/client/` as the `docs-site` workflow artifact (built for `/`; serve it +with any static server that maps `/x` to `x.html`). Pushes to `main` that touch `docs/`, and +manual runs of the workflow on `main`, build it for `/blocks/` and deploy it to GitHub Pages once +Pages is enabled with "GitHub Actions" as its source (Settings › Pages); until then the deploy job +fails and nothing else depends on it. + +## Layout + +| Path | What it is | +|---|---| +| `content/<version>/*.mdx` | The doc pages, one folder per docs version. A file is served at `/<version>/<file name>`; frontmatter sets its title, sidebar group and order. | +| `data/roadmap.json` | Everything the Roadmap shows. See below. | +| `components/home/` | The Home page. | +| `components/roadmap/` | The Roadmap pages, rendered from `data/roadmap.json`. | +| `components/mdx/` | Components MDX pages can use (callouts, flows, code blocks, …); API in its `README.md`. | +| `components/widgets/`, `components/search/` | The interactive widgets used in MDX (the resolution walkthrough) and the ⌘K search dialog. | +| `src/` | The app: routes, layout (header, sidebar, "on this page" rail, pager), styles and tokens. | +| `build/` | Build-time plugins: the content manifest, heading ids and outline, code highlighting. | +| `scripts/` | `check-content.ts`, `check-roadmap.ts`, `postbuild.ts` (404 page, link check, search index), `preview.ts`. | +| `assets/brand/*.svg`, `assets/cobogo-defs.svg`, `public/favicon.svg` | Logo, symbol, favicon and background patterns. The logo, symbol and favicon are deco's brand files, the same as in the public [decocms/studio](https://github.com/decocms/studio) repository; the cobogó patterns are the ones on [decocms.com](https://decocms.com). | + +The only external requests the site makes are the Fontshare and Google Fonts stylesheets and the +font files they load. Keep it that way: no analytics, no third-party scripts, no remote images. + +## What can go here + +This directory is public twice over: the repository is public, and the site is published on +GitHub Pages. Commit only what's fine for anyone to read: no credentials or tokens, no private +repository names, paths or code excerpts, no customer or client names, no local paths, and nothing +copied from internal reports that hasn't been rewritten for a public audience. A private site is +named by its platform ("the FastStore storefront"), with no detail that identifies its codebase or +owner. Security weaknesses in code that is deployed or released today are reported privately, +never described here; the Roadmap keeps only the design requirement for the unreleased API. When +in doubt, describe the problem in terms of the framework, not of a particular private site. + +## Editing the Roadmap + +`data/roadmap.json` holds the Roadmap's content; `components/roadmap/` holds its structure and +the framing sentences around the data (`model.ts` checks and derives, `SectionViews.tsx` renders, +`RoadmapPage.tsx` puts a section in the docs shell, `sections.ts` maps sections and ids to URLs, +styled with Tailwind utilities on the components). Run `bun scripts/check-roadmap.ts` (part of `bun run check` and +`bun run build`) after an edit: it fails with a message naming the item when an id, link or count +doesn't hold together. + +Each section is its own page: `/roadmap/` (the overview), then `/roadmap/<section id without +"roadmap-">` (`/roadmap/blockers`, `/roadmap/api`, …). Every other id is a fragment of its +section's page (`/roadmap/api#roadmap-api--add-a-request-scope`; feature rows +`/roadmap/features#roadmap-f-<id>`). From the docs, `[x](/roadmap#<id>)` is enough: the link is +resolved to the right page, and the check fails if the id doesn't exist. + +Top-level keys: + +- `statuses` — the five feature statuses in rank order (`to-build`, `to-finish`, `site-code`, + `done`, `goes-away`) with their label, the words used in counts (`word`, and `word_one` for a + count of one), the overview tile's definition and the legend's definition. +- `sections` — the twelve sections in page order: `id`, sidebar name (`nav`), `eyebrow`, `title`. +- `overview` — the overview's `intro`, `readiness_lead` and its callouts: `fix_docs`, and an + optional `fix_now` (a live bug to fix whether or not a site migrates). The intro and readiness + line are checked against the data: the intro must link each site's section by its `name`, and + the counts the readiness line states must match the features' statuses. +- `blockers` — the ten release blockers, ranked: `today`, `plan`, `features`, and + `delivered_by` (work-item ids; each work item links back with "Part of blocker"), plus an + optional `delivered_note` (inline HTML shown after the Delivered-by links). +- `studio_new`, `studio_legacy` — Studio support items: `today`, `features`, `delivered_by` + (work-item or site-step ids; each must share at least one feature with the item). +- `work_items` — `group` (`api`, `cli`, `studio`, `docs`), `plan`, `features`, `docs` (ids of doc + sections the item concerns) and `pinned` (only the two docs items that correct wrong statements; + they come first). Within a group the page sorts by feature count; ties keep the file's order. +- `sites` — the three sites in page order, each with an `id` (what features' `sites` list), the + `name` the page shows, a `short` name (row tags and the site filter), `section`, `description`, + `headline` and its `steps` in execution order (`kind`: `now`, `pre`, `blocker`, `work`, + `content` or `fix`; `no_action: true` draws a note without a box). +- `categories` — the feature categories in page order: `id`, `title`, `description`. +- `features` — keyed by feature id: `name`, `category`, `status`, `effort` (`L`, `M`, `S`), + `sites`, `summary`, `docs` (at least one doc-section id), and the row's tags: `confidence` + (`high` or `medium`), `first_rated` and `rated_before_review` (a status id or `null`) and + `unconfirmed_sub_claim`. + +Conventions: + +- Every to-do has an explicit `id`, which is its anchor: `<section id>--<slug>` (for example + `roadmap-api--add-a-request-scope`). Keep ids stable when a title changes, since links point at + them; references (`delivered_by`, `{{item:…}}`) use ids. +- Titles, `today`, `plan`, `text`, `headline`, `description` and the `overview` strings are HTML. + Titles allow only text, entities and `<code>`; work-item `plan`s are block HTML (`<p>`, `<ul>`), + the other fields inline HTML. +- `{{item:ID}}` inside any HTML field becomes a link to that to-do, named by its current title. +- Feature `name`s and category `description`s are plain text. Feature `summary`s are plain text + with two bits of markup: `` `code` `` and `[label](#section-id)` links. +- Don't store totals: every count on the page (per status, per site, per section, open effort, + the "How this list was made" numbers) is computed from the items. +- Links in HTML fields keep the old single page's form, `href="#id"`, and are rewritten when the + page renders: `#roadmap-…` to the Roadmap page holding that id, anything else to the next + major's docs (`#studio-compatibility` → `/next/studio-compatibility`, `#releases-and-deployment--publishing` → `/next/releases-and-deployment#publishing`). + Docs ids (`docs` lists, summary links) are page file names in `content/next/`; the check fails on + one without a page (`DOCS_LINKS=warn` only warns), and the post-build link check on a missing + `#fragment`. diff --git a/docs/assets/brand/favicon.svg b/docs/assets/brand/favicon.svg new file mode 100644 index 00000000..99d2d099 --- /dev/null +++ b/docs/assets/brand/favicon.svg @@ -0,0 +1,5 @@ +<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="200" height="200" rx="40" fill="#D0EC1A"/> +<path d="M79.8643 161.651C64.1138 161.651 51.606 155.629 44.194 144.974C36.3187 133.856 35.3922 118.106 41.4145 101.429C49.753 79.6558 69.6728 66.2216 93.7618 66.2216H94.2251C94.2251 65.7583 94.2251 65.2951 94.2251 64.3685C93.7618 56.4933 98.8576 49.5445 106.27 47.2283L128.042 38.8898C130.359 37.9633 132.675 37.5 134.991 37.5C141.94 37.5 148.425 41.206 151.668 47.6915L160.47 66.2216C163.249 71.7806 163.249 78.7293 160.007 83.8251C156.764 88.9208 151.668 91.7003 146.109 92.1636C144.719 94.9431 143.793 97.7226 142.403 100.039C139.624 106.524 136.844 113.01 133.601 119.959C121.557 144.974 108.123 161.651 79.8643 161.651Z" fill="#07401A"/> +<path d="M79.8641 145.438C97.4676 145.438 107.196 137.562 118.777 113.01C125.263 99.5758 130.358 86.1415 136.381 73.1705L143.793 75.4867C145.646 75.95 147.035 75.0235 146.109 73.1705L136.844 55.1037C136.381 53.714 134.528 53.714 133.601 54.1772L111.365 62.5157C109.512 62.979 109.512 64.832 111.365 65.2952L117.851 67.6115C112.292 79.656 105.806 98.186 100.247 109.767C94.2249 122.738 91.4454 131.54 80.7906 131.54C70.1359 131.54 68.7461 123.665 73.3786 112.084C78.4744 98.6493 86.8129 94.9433 96.0779 97.7228C98.8574 94.0168 100.71 88.4578 101.637 83.362C98.8574 82.4355 95.6146 82.4355 92.8351 82.4355C77.5479 82.4355 62.2606 90.3108 55.7751 106.988C48.8263 128.761 56.2383 145.438 79.8641 145.438Z" fill="#D0EC1A"/> +</svg> diff --git a/docs/assets/brand/symbol-on-dark.svg b/docs/assets/brand/symbol-on-dark.svg new file mode 100644 index 00000000..0e3379e8 --- /dev/null +++ b/docs/assets/brand/symbol-on-dark.svg @@ -0,0 +1,4 @@ +<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M43.381 127.131C27.2525 127.131 14.4445 120.964 6.85463 110.054C-1.20964 98.6687 -2.15838 82.5402 4.00842 65.4629C12.5471 43.1676 32.9449 29.4109 57.6121 29.4109H58.0865C58.0865 28.9365 58.0865 28.4621 58.0865 27.5134C57.6121 19.4491 62.8301 12.3336 70.42 9.96175L92.7154 1.42311C95.0872 0.474369 97.4591 0 99.8309 0C106.946 0 113.588 3.79495 116.908 10.4361L125.921 29.4109C128.767 35.1033 128.767 42.2188 125.447 47.4369C122.126 52.6549 116.908 55.5012 111.216 55.9755C109.793 58.8217 108.844 61.6679 107.421 64.0398C104.575 70.681 101.728 77.3221 98.4078 84.4376C86.0742 110.054 72.3175 127.131 43.381 127.131Z" fill="#D0EC1A"/> +<path d="M43.3804 110.528C61.4064 110.528 71.3682 102.464 83.2274 77.3223C89.8685 63.5656 95.0866 49.8089 101.253 36.5265L108.843 38.8984C110.741 39.3727 112.164 38.424 111.215 36.5265L101.728 18.0261C101.253 16.603 99.3559 16.603 98.4072 17.0774L75.6375 25.616C73.74 26.0904 73.74 27.9879 75.6375 28.4623L82.2786 30.8341C76.5862 43.1677 69.9451 62.1425 64.2526 74.0017C58.0858 87.284 55.2396 96.297 44.3291 96.297C33.4187 96.297 31.9955 88.2327 36.7392 76.3735C41.9573 62.6168 50.4959 58.8219 59.9833 61.6681C62.8295 57.8731 64.727 52.1807 65.6757 46.9626C62.8295 46.0139 59.5089 46.0139 56.6627 46.0139C41.0086 46.0139 25.3544 54.0782 18.7132 71.1555C11.5977 93.4508 19.1876 110.528 43.3804 110.528Z" fill="#07401A"/> +</svg> diff --git a/docs/assets/brand/symbol-on-light.svg b/docs/assets/brand/symbol-on-light.svg new file mode 100644 index 00000000..89a4fc25 --- /dev/null +++ b/docs/assets/brand/symbol-on-light.svg @@ -0,0 +1,4 @@ +<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M33.8914 99.321C21.291 99.321 11.2848 94.5032 5.35518 85.9794C-0.945031 77.0849 -1.68623 64.4845 3.13158 51.1429C9.80239 33.7247 25.7382 22.9772 45.0094 22.9772H45.38C45.38 22.6066 45.38 22.236 45.38 21.4948C45.0094 15.1946 49.0861 9.63562 55.0157 7.78261L72.4339 1.1118C74.2869 0.370601 76.1399 0 77.9929 0C83.5519 0 88.7403 2.96481 91.3345 8.15321L98.3759 22.9772C100.6 27.4245 100.6 32.9835 98.0053 37.0601C95.4111 41.1367 91.3345 43.3603 86.8873 43.7309C85.7755 45.9545 85.0343 48.1781 83.9225 50.0311C81.6989 55.2195 79.4753 60.4079 76.8811 65.9669C67.2455 85.9794 56.4981 99.321 33.8914 99.321Z" fill="#07401A"/> +<path d="M33.8913 86.3501C47.9742 86.3501 55.7568 80.0499 65.0218 60.4081C70.2102 49.6606 74.2868 38.9132 79.1046 28.5364L85.0342 30.3894C86.5166 30.76 87.6284 30.0188 86.8872 28.5364L79.4752 14.083C79.1046 12.9712 77.6222 12.9712 76.881 13.3418L59.0922 20.0126C57.6098 20.3832 57.6098 21.8656 59.0922 22.2362L64.2806 24.0892C59.8334 33.7248 54.645 48.5488 50.1978 57.8138C45.38 68.1907 43.1563 75.2321 34.6325 75.2321C26.1087 75.2321 24.9969 68.9319 28.7029 59.6668C32.7795 48.9194 39.4503 45.9546 46.8624 48.1782C49.086 45.2134 50.5684 40.7662 51.3096 36.6896C49.086 35.9484 46.4918 35.9484 44.2682 35.9484C32.0383 35.9484 19.8085 42.2486 14.6201 55.5902C9.06109 73.0085 14.9907 86.3501 33.8913 86.3501Z" fill="#D0EC1A"/> +</svg> diff --git a/docs/assets/brand/wordmark-on-dark.svg b/docs/assets/brand/wordmark-on-dark.svg new file mode 100644 index 00000000..27002906 --- /dev/null +++ b/docs/assets/brand/wordmark-on-dark.svg @@ -0,0 +1,16 @@ +<svg + width="68" + height="28" + viewBox="0 0 68 28" + fill="none" + xmlns="http://www.w3.org/2000/svg" +> + <path + d="M41.5098 28C38.295 28 36.2209 26.963 34.7691 25.8222C34.4579 26.0296 34.2505 26.237 33.8357 26.4444C31.0357 27.8963 27.9246 28 26.7839 28C22.2209 28 19.8357 26.0296 18.695 24.3704C18.5913 24.2667 18.4876 24.0593 18.3839 23.9556C16.3098 26.4444 13.6135 28 9.56905 28C6.04313 28 3.13942 26.6518 1.48016 24.2667C-0.282799 21.6741 -0.490207 18.1481 0.961645 14.4148C2.93202 9.43704 7.39127 6.42963 12.8876 6.42963C12.9913 6.42963 12.9913 6.42963 13.095 6.42963C13.095 6.32593 13.095 6.11852 13.095 6.01481C12.9913 4.25185 14.132 2.6963 15.7913 2.17778L20.6653 0.311111C21.1839 0.103704 21.7024 0 22.2209 0C23.7765 0 25.2283 0.82963 25.9542 2.28148L28.0283 6.53333C28.6505 6.42963 29.2728 6.42963 29.895 6.42963C32.9024 6.42963 35.2876 7.46667 36.8431 9.33333C39.2283 7.46667 42.2357 6.42963 45.5542 6.42963C47.3172 6.42963 48.9765 6.74074 50.2209 7.36296C50.6357 7.57037 51.0505 7.77778 51.3616 8.08889C53.2283 7.05185 55.4061 6.42963 57.7913 6.42963C61.2135 6.42963 64.1172 7.77778 65.7765 10.163C67.5394 12.6519 67.8505 16.0741 66.7098 19.4963C64.8431 24.6815 60.0728 28 54.4728 28C52.0876 28 49.9098 27.3778 48.2505 26.1333C47.9394 26.4444 47.5246 26.7556 47.1098 26.8593C45.5542 27.5852 43.5839 27.8963 41.6135 28H41.5098Z" + fill="#D0EC1A" + /> + <path + d="M55.0951 21.1556C52.8136 21.1556 52.5025 18.9778 53.1247 16.8C53.6432 15.037 54.9913 13.2741 56.9617 13.2741C59.3469 13.2741 59.5543 15.6593 58.8284 17.7333C58.4136 19.4963 57.0654 21.1556 55.0951 21.1556ZM54.4728 24.3704C58.4136 24.3704 61.8358 22.1926 63.2876 18.2519C64.7395 14.1037 63.0802 10.0593 57.7913 10.0593C53.5395 10.0593 50.221 12.7556 48.9765 16.2815C47.6284 20.2222 49.0802 24.3704 54.4728 24.3704ZM41.5099 24.3704C43.0654 24.3704 44.621 24.0593 45.7617 23.5408C46.1765 22.5037 46.1765 21.4667 45.8654 20.4296C45.1395 20.7408 43.9988 21.0519 42.9617 21.0519C39.9543 21.0519 39.7469 18.8741 40.3691 17.0074C41.0951 15.037 42.858 13.3778 45.658 13.3778C46.3839 13.3778 47.1099 13.4815 47.5247 13.7926C48.2506 12.7556 48.7691 11.7185 48.8728 10.6815C48.2506 10.3704 47.1099 10.163 45.658 10.163C40.9914 10.163 37.3617 12.8593 36.1173 16.5926C34.7691 20.0148 35.7025 24.3704 41.5099 24.3704ZM25.5395 15.8667C26.3691 14.1037 27.6136 13.0667 29.1691 13.0667C30.621 13.0667 30.8284 13.8963 30.621 14.5185C30.3099 15.3482 29.1691 15.8667 25.5395 15.8667ZM26.8876 24.3704C28.4432 24.3704 30.5173 24.0593 32.2802 23.2296C32.5913 22.2963 32.5913 21.2593 32.1765 20.2222C31.0358 20.7407 29.4802 21.1556 28.0284 21.1556C25.9543 21.1556 24.8136 20.4296 24.8136 18.7704C30.5173 18.8741 33.5247 17.837 34.5617 15.3482C35.4951 12.7556 33.8358 10.163 29.8951 10.163C25.6432 10.163 22.5321 13.1704 21.2876 16.4889C20.1469 19.9111 20.8728 24.3704 26.8876 24.3704ZM9.67283 24.3704C13.6136 24.3704 15.8951 22.6074 18.4876 17.0074C19.9395 14 21.0802 10.9926 22.5321 7.98519L24.1913 8.50371C24.6062 8.60742 24.9173 8.40001 24.7099 7.98519L22.7395 3.83705C22.4284 3.62964 22.1173 3.62964 21.9099 3.62964L16.9321 5.49631C16.5173 5.60001 16.5173 6.01482 16.9321 6.11853L18.4876 6.74075C17.2432 9.54075 15.7913 13.6889 14.5469 16.2815C13.1988 19.1852 12.4728 21.2593 10.1913 21.2593C7.90986 21.2593 7.49505 19.4963 8.42838 16.9037C9.56912 13.8963 11.4358 13.0667 13.6136 13.6889C14.2358 12.8593 14.6506 11.6148 14.858 10.4741C14.2358 10.2667 13.5099 10.2667 12.8876 10.2667C9.36172 10.2667 5.93949 12.0296 4.48764 15.8667C2.62098 20.5333 4.38394 24.3704 9.67283 24.3704Z" + fill="#07401A" + /> +</svg> diff --git a/docs/assets/brand/wordmark-on-light.svg b/docs/assets/brand/wordmark-on-light.svg new file mode 100644 index 00000000..e96387ab --- /dev/null +++ b/docs/assets/brand/wordmark-on-light.svg @@ -0,0 +1,16 @@ +<svg + width="68" + height="28" + viewBox="0 0 68 28" + fill="none" + xmlns="http://www.w3.org/2000/svg" +> + <path + d="M41.5098 28C38.295 28 36.2209 26.963 34.7691 25.8222C34.4579 26.0296 34.2505 26.237 33.8357 26.4444C31.0357 27.8963 27.9246 28 26.7839 28C22.2209 28 19.8357 26.0296 18.695 24.3704C18.5913 24.2667 18.4876 24.0593 18.3839 23.9556C16.3098 26.4444 13.6135 28 9.56905 28C6.04313 28 3.13942 26.6518 1.48016 24.2667C-0.282799 21.6741 -0.490207 18.1481 0.961645 14.4148C2.93202 9.43704 7.39127 6.42963 12.8876 6.42963C12.9913 6.42963 12.9913 6.42963 13.095 6.42963C13.095 6.32593 13.095 6.11852 13.095 6.01481C12.9913 4.25185 14.132 2.6963 15.7913 2.17778L20.6653 0.311111C21.1839 0.103704 21.7024 0 22.2209 0C23.7765 0 25.2283 0.82963 25.9542 2.28148L28.0283 6.53333C28.6505 6.42963 29.2728 6.42963 29.895 6.42963C32.9024 6.42963 35.2876 7.46667 36.8431 9.33333C39.2283 7.46667 42.2357 6.42963 45.5542 6.42963C47.3172 6.42963 48.9765 6.74074 50.2209 7.36296C50.6357 7.57037 51.0505 7.77778 51.3616 8.08889C53.2283 7.05185 55.4061 6.42963 57.7913 6.42963C61.2135 6.42963 64.1172 7.77778 65.7765 10.163C67.5394 12.6519 67.8505 16.0741 66.7098 19.4963C64.8431 24.6815 60.0728 28 54.4728 28C52.0876 28 49.9098 27.3778 48.2505 26.1333C47.9394 26.4444 47.5246 26.7556 47.1098 26.8593C45.5542 27.5852 43.5839 27.8963 41.6135 28H41.5098Z" + fill="#07401A" + /> + <path + d="M55.0951 21.1556C52.8136 21.1556 52.5025 18.9778 53.1247 16.8C53.6432 15.037 54.9913 13.2741 56.9617 13.2741C59.3469 13.2741 59.5543 15.6593 58.8284 17.7333C58.4136 19.4963 57.0654 21.1556 55.0951 21.1556ZM54.4728 24.3704C58.4136 24.3704 61.8358 22.1926 63.2876 18.2519C64.7395 14.1037 63.0802 10.0593 57.7913 10.0593C53.5395 10.0593 50.221 12.7556 48.9765 16.2815C47.6284 20.2222 49.0802 24.3704 54.4728 24.3704ZM41.5099 24.3704C43.0654 24.3704 44.621 24.0593 45.7617 23.5408C46.1765 22.5037 46.1765 21.4667 45.8654 20.4296C45.1395 20.7408 43.9988 21.0519 42.9617 21.0519C39.9543 21.0519 39.7469 18.8741 40.3691 17.0074C41.0951 15.037 42.858 13.3778 45.658 13.3778C46.3839 13.3778 47.1099 13.4815 47.5247 13.7926C48.2506 12.7556 48.7691 11.7185 48.8728 10.6815C48.2506 10.3704 47.1099 10.163 45.658 10.163C40.9914 10.163 37.3617 12.8593 36.1173 16.5926C34.7691 20.0148 35.7025 24.3704 41.5099 24.3704ZM25.5395 15.8667C26.3691 14.1037 27.6136 13.0667 29.1691 13.0667C30.621 13.0667 30.8284 13.8963 30.621 14.5185C30.3099 15.3482 29.1691 15.8667 25.5395 15.8667ZM26.8876 24.3704C28.4432 24.3704 30.5173 24.0593 32.2802 23.2296C32.5913 22.2963 32.5913 21.2593 32.1765 20.2222C31.0358 20.7407 29.4802 21.1556 28.0284 21.1556C25.9543 21.1556 24.8136 20.4296 24.8136 18.7704C30.5173 18.8741 33.5247 17.837 34.5617 15.3482C35.4951 12.7556 33.8358 10.163 29.8951 10.163C25.6432 10.163 22.5321 13.1704 21.2876 16.4889C20.1469 19.9111 20.8728 24.3704 26.8876 24.3704ZM9.67283 24.3704C13.6136 24.3704 15.8951 22.6074 18.4876 17.0074C19.9395 14 21.0802 10.9926 22.5321 7.98519L24.1913 8.50371C24.6062 8.60742 24.9173 8.40001 24.7099 7.98519L22.7395 3.83705C22.4284 3.62964 22.1173 3.62964 21.9099 3.62964L16.9321 5.49631C16.5173 5.60001 16.5173 6.01482 16.9321 6.11853L18.4876 6.74075C17.2432 9.54075 15.7913 13.6889 14.5469 16.2815C13.1988 19.1852 12.4728 21.2593 10.1913 21.2593C7.90986 21.2593 7.49505 19.4963 8.42838 16.9037C9.56912 13.8963 11.4358 13.0667 13.6136 13.6889C14.2358 12.8593 14.6506 11.6148 14.858 10.4741C14.2358 10.2667 13.5099 10.2667 12.8876 10.2667C9.36172 10.2667 5.93949 12.0296 4.48764 15.8667C2.62098 20.5333 4.38394 24.3704 9.67283 24.3704Z" + fill="#D0EC1A" + /> +</svg> diff --git a/docs/assets/cobogo-defs.svg b/docs/assets/cobogo-defs.svg new file mode 100644 index 00000000..c3e2dda5 --- /dev/null +++ b/docs/assets/cobogo-defs.svg @@ -0,0 +1 @@ +<svg width="0" height="0" aria-hidden="true" focusable="false"><defs><path id="cb-arabela" d="M119.984 119.569H0V0H119.984V119.569ZM13.493 106.283H49.5215C46.769 87.6533 32.1231 73.0074 13.493 70.2549V106.283ZM106.491 70.2549C87.8609 73.0074 73.215 87.6533 70.4624 106.283H106.491V70.2549ZM61.3279 25.5475C61.0688 24.5987 58.9152 24.5987 58.6561 25.5475C54.2884 41.5382 41.7457 54.0808 25.7551 58.4485C24.8063 58.7076 24.8063 60.8612 25.7551 61.1203C41.7457 65.488 54.2884 78.0306 58.6561 94.0213C58.9152 94.97 61.0688 94.97 61.3279 94.0213C65.6956 78.0306 78.2382 65.488 94.2289 61.1203C95.1776 60.8612 95.1776 58.7076 94.2289 58.4485C78.2382 54.0808 65.6956 41.5382 61.3279 25.5475ZM13.493 13.2854V49.3135C32.1231 46.561 46.769 31.9155 49.5215 13.2854H13.493ZM70.4624 13.2854C73.215 31.9155 87.8609 46.561 106.491 49.3135V13.2854H70.4624Z"/><path id="cb-tacoChines" d="M119.984 119.569H0V0H119.984V119.569ZM13.2854 95.489V106.283H32.1756V95.489H13.2854ZM37.7804 95.489V106.283H56.8782V95.489H37.7804ZM62.6906 62.6906V106.283H73.485V62.6906H62.6906ZM79.2974 62.6906V106.283H90.0918V62.6906H79.2974ZM95.489 87.1856V106.283H106.283V87.1856H95.489ZM13.2854 79.2974V90.0918H56.8782V79.2974H13.2854ZM95.489 62.6906V81.5808H106.283V62.6906H95.489ZM13.2854 62.6906V73.485H56.8782V62.6906H13.2854ZM13.2854 37.7804V56.8782H24.0798V37.7804H13.2854ZM29.477 13.2854V56.8782H40.2714V13.2854H29.477ZM46.0838 13.2854V56.8782H56.8782V13.2854H46.0838ZM62.6906 46.499V56.8782H106.283V46.499H62.6906ZM62.6906 29.8922V40.2714H106.283V29.8922H62.6906ZM13.2854 13.2854V32.1756H24.0798V13.2854H13.2854ZM62.6906 13.2854V24.0798H81.5808V13.2854H62.6906ZM87.1856 13.2854V24.0798H106.283V13.2854H87.1856Z"/><path id="cb-flor" d="M119.984 119.569H0V0H119.984V119.569ZM97.701 83.5682C91.8687 77.7359 84.1987 75.1332 77.3557 75.9756C76.5754 76.0717 75.9638 76.6837 75.8677 77.464C75.0254 84.3068 77.6278 91.9758 83.46 97.8081C89.2925 103.641 96.9622 106.243 103.805 105.401C104.586 105.305 105.197 104.693 105.293 103.913C106.135 97.07 103.533 89.4004 97.701 83.5682ZM42.3732 75.7072C35.5307 74.8654 27.8618 77.468 22.0299 83.2998C16.1979 89.1319 13.5955 96.8012 14.4377 103.644C14.5337 104.424 15.1456 105.036 15.926 105.132C22.7686 105.974 30.4374 103.372 36.2693 97.5397C42.1015 91.7075 44.7039 84.0383 43.8616 77.1956C43.7655 76.4151 43.1536 75.8032 42.3732 75.7072ZM61.0692 64.9991C60.4495 64.5153 59.5843 64.5153 58.9646 64.9991C53.5306 69.2421 49.9481 76.5051 49.948 84.7529C49.948 93.0008 53.5305 100.264 58.9646 104.507C59.5843 104.991 60.4495 104.991 61.0692 104.507C66.5033 100.264 70.0862 93.0008 70.0862 84.7529C70.0861 76.5052 66.5032 69.2422 61.0692 64.9991ZM35.4013 49.9298C27.1534 49.9299 19.8901 53.5122 15.6471 58.9463C15.1632 59.566 15.1633 60.4313 15.6471 61.051C19.89 66.4853 27.1532 70.0683 35.4013 70.0683C43.6494 70.0683 50.9129 66.4853 55.1559 61.051C55.6396 60.4313 55.6396 59.5664 55.1559 58.9468C50.9129 53.5125 43.6493 49.9298 35.4013 49.9298ZM84.7513 49.9298C76.5034 49.9298 69.2401 53.5122 64.9971 58.9463C64.5133 59.566 64.5133 60.4313 64.9971 61.051C69.2401 66.4853 76.5033 70.0683 84.7513 70.0683C92.9994 70.0683 100.263 66.4853 104.506 61.051C104.99 60.4313 104.99 59.5664 104.506 58.9468C100.263 53.5125 92.9993 49.9298 84.7513 49.9298ZM61.0692 15.6483C60.4495 15.1645 59.5843 15.1644 58.9646 15.6483C53.5305 19.8913 49.9481 27.1546 49.948 35.4025C49.9481 43.6505 53.5304 50.9141 58.9646 55.1571C59.5843 55.641 60.4495 55.6409 61.0692 55.1571C66.5033 50.9141 70.0862 43.6504 70.0862 35.4025C70.0862 27.1547 66.5033 19.8914 61.0692 15.6483ZM103.912 14.6242C97.0695 13.7825 89.4008 16.3854 83.5691 22.2172C77.737 28.0494 75.1345 35.7186 75.9768 42.5613C76.0729 43.3417 76.6848 43.9533 77.4652 44.0493C84.3077 44.8911 91.9762 42.2885 97.8081 36.4567C103.64 30.6244 106.243 22.9554 105.4 16.1125C105.304 15.3321 104.692 14.7202 103.912 14.6242ZM36.6172 22.0295C30.7853 16.1977 23.1164 13.595 16.2739 14.4369C15.4936 14.5329 14.8816 15.1449 14.7855 15.9252C13.9432 22.7681 16.546 30.4375 22.3782 36.2697C28.2102 42.1017 35.8793 44.704 42.7219 43.862C43.5022 43.7659 44.1138 43.1543 44.2099 42.374C45.0521 35.5312 42.4494 27.8617 36.6172 22.0295Z"/><path id="cb-quatroPontas" d="M119.984 119.569H0V0H119.984V119.569ZM19.5365 106.283H55.4251V100.239C47.4792 99.3931 40.2168 96.2599 34.309 91.51L19.5365 106.283ZM85.0278 91.6941C79.2659 96.2584 72.2394 99.2955 64.5588 100.193V106.283H99.6171L85.0278 91.6941ZM100.217 64.3513C99.3387 72.2158 96.2183 79.4027 91.51 85.2589L106.699 100.447V64.3513H100.217ZM13.2854 64.3513V99.6171L27.8743 85.0274C23.2691 79.2137 20.2185 72.1126 19.3516 64.3513H13.2854ZM77.916 85.6096C66.7496 79.4604 53.121 79.5459 42.0233 85.8667C47.0826 89.3185 53.1976 91.3373 59.7844 91.3373C66.5329 91.3373 72.786 89.218 77.916 85.6096ZM34.0597 41.5088C30.3897 46.6655 28.2315 52.9729 28.2315 59.7844C28.2315 66.5957 30.3898 72.9029 34.0597 78.0595C40.5432 66.7582 40.5434 52.8101 34.0597 41.5088ZM85.9676 42.1717C79.9791 53.1301 79.9792 66.4383 85.9676 77.3967C89.3579 72.3665 91.3373 66.3067 91.3373 59.7844C91.3373 53.262 89.358 47.202 85.9676 42.1717ZM74.6843 45.2997C70.1347 46.8118 65.3995 47.5914 60.6585 47.6387C60.3526 47.6417 60.0466 47.6417 59.7406 47.6387C54.9996 47.5914 50.2644 46.8118 45.7149 45.2997C48.8366 54.6923 48.8365 64.8761 45.7149 74.2687C55.1075 71.147 65.2916 71.147 74.6843 74.2687C71.5626 64.8761 71.5625 54.6923 74.6843 45.2997ZM13.2854 55.2175H19.3516C20.2185 47.456 23.2688 40.3543 27.8743 34.5406L13.2854 19.9516V55.2175ZM91.51 34.309C96.2186 40.1653 99.3387 47.3527 100.217 55.2175H106.699V19.1213L91.51 34.309ZM59.7844 28.2315C53.1977 28.2315 47.0826 30.2499 42.0233 33.7017C53.1211 40.0226 66.7495 40.1081 77.916 33.9587C72.7861 30.3505 66.5328 28.2315 59.7844 28.2315ZM19.1213 12.8703L34.3095 28.0584C40.2172 23.3086 47.4794 20.1753 55.4251 19.3289V12.8703H19.1213ZM64.5588 12.8703V19.3751C72.2392 20.2729 79.2656 23.3102 85.0274 27.8743L100.032 12.8703H64.5588Z"/><g id="cb-field"><use href="#cb-arabela" fill="#FFFFFF" opacity="0.05" transform="translate(0 0)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(120 0) rotate(180 60 60)"/><use href="#cb-tacoChines" fill="#FFFFFF" opacity="0.05" transform="translate(240 0)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(360 0) rotate(90 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(480 0) rotate(180 60 60)"/><use href="#cb-tacoChines" fill="#FFFFFF" opacity="0.05" transform="translate(600 0) rotate(90 60 60)"/><use href="#cb-tacoChines" fill="#FFFFFF" opacity="0.05" transform="translate(720 0) rotate(270 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(840 0)"/><use href="#cb-arabela" fill="#FFFFFF" opacity="0.05" transform="translate(0 120) rotate(90 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(120 120)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(240 120) rotate(180 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(360 120) rotate(180 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(480 120)"/><use href="#cb-tacoChines" fill="#D0EC1A" opacity="0.1" transform="translate(600 120) rotate(90 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(720 120) rotate(90 60 60)"/><use href="#cb-tacoChines" fill="#FFFFFF" opacity="0.05" transform="translate(840 120) rotate(270 60 60)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(0 240) rotate(90 60 60)"/><use href="#cb-arabela" fill="#FFFFFF" opacity="0.05" transform="translate(120 240)"/><use href="#cb-tacoChines" fill="#FFFFFF" opacity="0.05" transform="translate(240 240)"/><use href="#cb-tacoChines" fill="#FFFFFF" opacity="0.05" transform="translate(360 240)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(480 240) rotate(180 60 60)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(600 240) rotate(270 60 60)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(720 240) rotate(180 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(840 240) rotate(270 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(0 360) rotate(270 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(120 360)"/><use href="#cb-tacoChines" fill="#FFFFFF" opacity="0.05" transform="translate(240 360) rotate(180 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(360 360) rotate(270 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(480 360) rotate(90 60 60)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(600 360) rotate(180 60 60)"/><use href="#cb-arabela" fill="#D0EC1A" opacity="0.1" transform="translate(720 360)"/><use href="#cb-quatroPontas" fill="#D0EC1A" opacity="0.1" transform="translate(840 360) rotate(180 60 60)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(0 480) rotate(180 60 60)"/><use href="#cb-tacoChines" fill="#FFFFFF" opacity="0.05" transform="translate(120 480) rotate(270 60 60)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(240 480) rotate(180 60 60)"/><use href="#cb-arabela" fill="#FFFFFF" opacity="0.05" transform="translate(360 480) rotate(270 60 60)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(480 480)"/><use href="#cb-arabela" fill="#FFFFFF" opacity="0.05" transform="translate(600 480) rotate(270 60 60)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(720 480) rotate(180 60 60)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(840 480) rotate(180 60 60)"/><use href="#cb-tacoChines" fill="#FFFFFF" opacity="0.05" transform="translate(0 600)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(120 600) rotate(270 60 60)"/><use href="#cb-flor" fill="#FFFFFF" opacity="0.05" transform="translate(240 600) rotate(90 60 60)"/><use href="#cb-arabela" fill="#FFFFFF" opacity="0.05" transform="translate(360 600) rotate(90 60 60)"/><use href="#cb-tacoChines" fill="#D0EC1A" opacity="0.1" transform="translate(480 600)"/><use href="#cb-arabela" fill="#FFFFFF" opacity="0.05" transform="translate(600 600)"/><use href="#cb-quatroPontas" fill="#FFFFFF" opacity="0.05" transform="translate(720 600)"/><use href="#cb-tacoChines" fill="#FFFFFF" opacity="0.05" transform="translate(840 600) rotate(90 60 60)"/></g><pattern id="cb-lg" width="960" height="720" patternUnits="userSpaceOnUse" patternTransform="scale(1.5)"><use href="#cb-field"/></pattern><pattern id="cb-sm" width="960" height="720" patternUnits="userSpaceOnUse" patternTransform="scale(.75)"><use href="#cb-field"/></pattern></defs></svg> diff --git a/docs/build/manifest.ts b/docs/build/manifest.ts new file mode 100644 index 00000000..1c3b737e --- /dev/null +++ b/docs/build/manifest.ts @@ -0,0 +1,197 @@ +/** + * The content manifest: every MDX page under content/<version>/, described by its frontmatter. + * + * Read at build time (Node/Bun) by: + * - the `virtual:content-manifest` Vite module (contentManifestPlugin below), which the app + * uses for the sidebar, breadcrumb, pager, version select and tabs; + * - vite.config.ts, which turns it into the list of pages to prerender; + * - scripts/check-content.ts. + * + * Only the frontmatter is parsed here (YAML), never the MDX body, so this stays cheap. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import { parse as parseYaml } from 'yaml' +import type { Plugin, ViteDevServer } from 'vite' +import { VERSIONS } from '../src/lib/versions.ts' + +export type PageKind = 'docs' | 'internals' + +/** What a page's frontmatter may contain. See ARCHITECTURE.md › Frontmatter. */ +export interface PageFrontmatter { + /** Plain text of the page's `# Title` (the h1). Used for <title>, search and the pager. */ + title: string + /** Sidebar label (the old `data-nav`). Defaults to `title`. */ + nav?: string + /** Sidebar group, e.g. "Getting started". Groups appear in the order of their first page. */ + group: string + /** `docs` (the Docs tab) or `internals` (the Under the hood tab). Defaults to `docs`. */ + kind?: PageKind + /** Position in the version's reading order (sidebar + pager). Unique per version and kind. */ + order: number + /** The small uppercase label above the title. Defaults to `group`. */ + eyebrow?: string + /** <meta name="description">. Optional. */ + description?: string +} + +export interface ManifestPage { + version: string + /** File name without `.mdx`; `''` for `index.mdx` (the version's index page). */ + slug: string + /** Router path without the base path: `/next/quickstart`, `/v7/`. */ + path: string + /** Path relative to the site root, e.g. `content/next/quickstart.mdx`. */ + file: string + title: string + nav: string + group: string + kind: PageKind + order: number + eyebrow: string + description: string | null +} + +export interface ManifestGroup { + title: string + kind: PageKind + pages: string[] // slugs, in order +} + +export interface VersionManifest { + id: string + /** Pages in reading order: all `docs` pages by `order`, then all `internals` pages by `order`. */ + pages: ManifestPage[] + groups: ManifestGroup[] +} + +export interface Manifest { + versions: Record<string, VersionManifest> +} + +const KINDS: PageKind[] = ['docs', 'internals'] + +export function readFrontmatter(source: string, file: string): Record<string, unknown> { + const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(source) + if (!m) throw new Error(`${file}: missing YAML frontmatter (--- … ---) at the top of the file`) + const data = parseYaml(m[1]) + if (!data || typeof data !== 'object') throw new Error(`${file}: frontmatter is not a YAML mapping`) + return data as Record<string, unknown> +} + +function str(fm: Record<string, unknown>, key: string, file: string, required: boolean): string | undefined { + const v = fm[key] + if (v === undefined || v === null) { + if (required) throw new Error(`${file}: frontmatter \`${key}\` is required`) + return undefined + } + if (typeof v !== 'string' || !v.trim()) throw new Error(`${file}: frontmatter \`${key}\` must be a non-empty string`) + return v.trim() +} + +const ALLOWED_KEYS = new Set(['title', 'nav', 'group', 'kind', 'order', 'eyebrow', 'description']) + +export function loadManifest(siteRoot: string): Manifest { + const contentDir = path.join(siteRoot, 'content') + const versions: Record<string, VersionManifest> = {} + for (const v of VERSIONS) { + const dir = path.join(contentDir, v.id) + let files: string[] = [] + try { + if (statSync(dir).isDirectory()) files = readdirSync(dir).filter((f) => f.endsWith('.mdx')) + } catch { + files = [] + } + const pages: ManifestPage[] = files.map((f) => { + const rel = `content/${v.id}/${f}` + const fm = readFrontmatter(readFileSync(path.join(dir, f), 'utf8'), rel) + for (const k of Object.keys(fm)) if (!ALLOWED_KEYS.has(k)) throw new Error(`${rel}: unknown frontmatter key \`${k}\``) + const slug = f === 'index.mdx' ? '' : f.slice(0, -4) + if (slug && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) throw new Error(`${rel}: file names must be kebab-case`) + const title = str(fm, 'title', rel, true)! + const group = str(fm, 'group', rel, true)! + const kind = (str(fm, 'kind', rel, false) ?? 'docs') as PageKind + if (!KINDS.includes(kind)) throw new Error(`${rel}: \`kind\` must be one of ${KINDS.join(', ')}`) + const order = fm.order + if (typeof order !== 'number' || !Number.isFinite(order)) throw new Error(`${rel}: frontmatter \`order\` must be a number`) + return { + version: v.id, + slug, + path: `/${v.id}/${slug}`, + file: rel, + title, + nav: str(fm, 'nav', rel, false) ?? title, + group, + kind, + order, + eyebrow: str(fm, 'eyebrow', rel, false) ?? group, + description: str(fm, 'description', rel, false) ?? null, + } + }) + pages.sort((a, b) => KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind) || a.order - b.order) + const groups: ManifestGroup[] = [] + const seenOrder = new Map<string, string>() + for (const p of pages) { + const key = `${p.kind}:${p.order}` + if (seenOrder.has(key)) throw new Error(`${p.file}: \`order: ${p.order}\` is also used by ${seenOrder.get(key)}`) + seenOrder.set(key, p.file) + const last = groups[groups.length - 1] + if (last && last.title === p.group && last.kind === p.kind) last.pages.push(p.slug) + else { + if (groups.some((g) => g.title === p.group && g.kind === p.kind)) + throw new Error(`${p.file}: group "${p.group}" is split by another group; give its pages adjacent \`order\` values`) + groups.push({ title: p.group, kind: p.kind, pages: [p.slug] }) + } + } + versions[v.id] = { id: v.id, pages, groups } + } + return { versions } +} + +/** Every route path that has a page: `/next/quickstart`, `/v7/`, … (no base path). */ +export function manifestPaths(manifest: Manifest): string[] { + return Object.values(manifest.versions).flatMap((v) => v.pages.map((p) => p.path)) +} + +const VIRTUAL_ID = 'virtual:content-manifest' +const RESOLVED_ID = `\0${VIRTUAL_ID}` + +/** Serves `virtual:content-manifest` and reloads the page when content files are added/removed/edited. */ +export function contentManifestPlugin(siteRoot: string): Plugin { + const contentDir = path.join(siteRoot, 'content') + return { + name: 'deco-docs:content-manifest', + resolveId(id) { + if (id === VIRTUAL_ID) return RESOLVED_ID + }, + load(id) { + if (id !== RESOLVED_ID) return + const manifest = loadManifest(siteRoot) + for (const v of Object.values(manifest.versions)) for (const p of v.pages) this.addWatchFile(path.join(siteRoot, p.file)) + return `export default ${JSON.stringify(manifest)};` + }, + configureServer(server: ViteDevServer) { + const onChange = (file: string) => { + if (!file.startsWith(contentDir) || !file.endsWith('.mdx')) return + for (const env of Object.values(server.environments)) { + const mod = env.moduleGraph.getModuleById(RESOLVED_ID) + if (mod) env.moduleGraph.invalidateModule(mod) + } + server.ws.send({ type: 'full-reload' }) + } + server.watcher.add(contentDir) + server.watcher.on('add', onChange) + server.watcher.on('unlink', onChange) + server.watcher.on('change', (file) => { + // Body edits hot-reload through MDX; only frontmatter changes need the manifest rebuilt, + // but telling them apart costs more than just invalidating. + if (file.startsWith(contentDir) && file.endsWith('.mdx')) { + for (const env of Object.values(server.environments)) { + const mod = env.moduleGraph.getModuleById(RESOLVED_ID) + if (mod) env.moduleGraph.invalidateModule(mod) + } + } + }) + }, + } +} diff --git a/docs/build/rehype-docs.ts b/docs/build/rehype-docs.ts new file mode 100644 index 00000000..4777c9fe --- /dev/null +++ b/docs/build/rehype-docs.ts @@ -0,0 +1,202 @@ +/** + * The site's rehype plugin for MDX pages. Runs at build time (and in dev), so the prerendered HTML + * already has everything the old site added in the browser: + * + * 1. Heading ids (h1–h3) with the old slug algorithm, de-duplicated per page; an explicit `id` + * (written as JSX, `<h2 id="x">`) is kept. h2/h3 also get `aria-label` = their text, so the + * injected permalink isn't read as part of the name. + * 2. `export const headings = [{ id, depth, text, html }]` (h2 and h3) for the "On this page" rail. + * 3. A `<TocInline />` element after the h1 and its lede paragraph (the collapsible outline shown + * below 1200px), rendered by components/mdx/TocInline.tsx. + * 4. Code: every fenced block is highlighted with Shiki (CSS-variable theme), and the fence meta + * is parsed: ```ts title="cms.ts". The <pre> gets data-* props that components/mdx/CodeBlock + * turns into the panel chrome (file header, language badge, copy button). + * 5. Inline code: `is-short` (≤ 24 chars, never wraps) and, inside tables, `can-wrap` when the + * code has spaces (signatures may wrap on phones), as the old app.js did. + */ +import type { Element, ElementContent, Root, RootContent, Text } from 'hast' +import { toString } from 'hast-util-to-string' +import { toHtml } from 'hast-util-to-html' +import { visit, SKIP } from 'unist-util-visit' +import { valueToEstree } from 'estree-util-value-to-estree' +import { createHighlighter, type Highlighter, type ShikiTransformer } from 'shiki' +import { createSlugger } from './slugify.ts' +import { decoTheme } from './shiki-theme.ts' + +export const LANGS = ['typescript', 'tsx', 'javascript', 'jsx', 'json', 'jsonc', 'bash', 'shellscript', 'yaml', 'html', 'css', 'diff'] as const +const ALIASES: Record<string, string> = { ts: 'typescript', mts: 'typescript', js: 'javascript', sh: 'bash', shell: 'bash', zsh: 'bash', yml: 'yaml', plaintext: 'text', txt: 'text' } + +/** + * YAML: a key that is also a YAML 1.1 boolean (`on:` in a GitHub Actions workflow) is scoped as a + * boolean by the grammar, which can't see the colon. Color it as the key it is. + */ +const yamlBooleanKeys: ShikiTransformer = { + name: 'deco:yaml-boolean-keys', + tokens(lines) { + if (this.options.lang !== 'yaml') return + for (const line of lines) + line.forEach((tok, i) => { + if (/^(?:on|off|yes|no|y|n|true|false)$/i.test(tok.content) && line[i + 1]?.content.startsWith(':')) tok.color = 'var(--syn-property)' + }) + }, +} + +let highlighter: Promise<Highlighter> | undefined +const getHighlighter = () => (highlighter ??= createHighlighter({ themes: [decoTheme], langs: [...LANGS] })) + +export interface DocHeading { + id: string + depth: 2 | 3 + /** Plain text. */ + text: string + /** Inner HTML (keeps <code>), for the rail and the inline outline. */ + html: string +} + +/** Parses `title="cms.ts" foo bar=baz` into { title: 'cms.ts', foo: true, bar: 'baz' }. */ +export function parseMeta(meta: string | undefined): Record<string, string | true> { + const out: Record<string, string | true> = {} + if (!meta) return out + const re = /([\w-]+)(?:=(?:"([^"]*)"|'([^']*)'|(\S+)))?/g + let m: RegExpExecArray | null + while ((m = re.exec(meta))) out[m[1]] = m[2] ?? m[3] ?? m[4] ?? true + return out +} + +const isEl = (n: unknown, tag?: string): n is Element => + !!n && (n as Element).type === 'element' && (!tag || (n as Element).tagName === tag) + +function classList(el: Element): string[] { + const c = el.properties?.className as unknown + return Array.isArray(c) ? c.map(String) : typeof c === 'string' ? c.split(/\s+/) : [] +} + +function jsxFlow(name: string): RootContent { + return { type: 'mdxJsxFlowElement', name, attributes: [], children: [] } as unknown as RootContent +} + +export default function rehypeDocs() { + return async (tree: Root) => { + const slug = createSlugger() + const headings: DocHeading[] = [] + + // 0. Headings written as JSX (`<h2 id="publishing">Publishing</h2>`, to pin an id) become plain + // elements, so they get the same treatment (and the h2/h3 component overrides) as `## …`. + visit(tree, (node, index, parent) => { + const jsx = node as unknown as { type: string; name?: string; attributes?: { type: string; name: string; value: unknown }[]; children: ElementContent[] } + if (jsx.type !== 'mdxJsxFlowElement' || !jsx.name || !/^h[1-3]$/.test(jsx.name) || !parent || typeof index !== 'number') return + const properties: Record<string, string> = {} + for (const a of jsx.attributes ?? []) { + if (a.type !== 'mdxJsxAttribute' || typeof a.value !== 'string') return // dynamic props: leave as JSX + properties[a.name === 'class' ? 'className' : a.name] = a.value + } + // Flow JSX wraps its text in a paragraph; a heading holds phrasing content. + const kids = jsx.children.length === 1 && isEl(jsx.children[0], 'p') ? (jsx.children[0] as Element).children : jsx.children + ;(parent.children as RootContent[])[index] = { type: 'element', tagName: jsx.name, properties, children: kids } as Element + }) + + // 1–2. Headings (explicit ids first, so generated ones never take them). + visit(tree, 'element', (el) => { + if (/^h[1-3]$/.test(el.tagName) && typeof el.properties?.id === 'string') slug('', el.properties.id) + }) + visit(tree, 'element', (el) => { + if (!/^h[1-3]$/.test(el.tagName)) return + const text = toString(el).replace(/\s+/g, ' ').trim() + const explicit = typeof el.properties?.id === 'string' ? el.properties.id : undefined + const id = explicit ?? slug(text) + el.properties = { ...el.properties, id } + if (el.tagName !== 'h1') { + el.properties.ariaLabel = text + headings.push({ id, depth: el.tagName === 'h2' ? 2 : 3, text, html: toHtml(el.children as ElementContent[]) }) + } + }) + + // 3. <TocInline /> after the h1 (and its lede paragraph, if one follows). + const top = tree.children + const h1 = top.findIndex((n) => isEl(n, 'h1')) + if (h1 >= 0) { + let at = h1 + 1 + while (at < top.length && top[at].type === 'text' && !(top[at] as Text).value.trim()) at++ + const insertAt = isEl(top[at], 'p') ? at + 1 : h1 + 1 + top.splice(insertAt, 0, jsxFlow('TocInline')) + } + + // 4. Code blocks. + const hl = await getHighlighter() + const loaded = new Set(hl.getLoadedLanguages()) + visit(tree, 'element', (pre, index, parent) => { + if (pre.tagName !== 'pre') return + const code = pre.children.find((c): c is Element => isEl(c, 'code')) + if (!code) return + const raw = toString(code).replace(/\n$/, '') + const fromClass = classList(code).find((c) => c.startsWith('language-'))?.slice('language-'.length) + let lang = fromClass ? (ALIASES[fromClass] ?? fromClass) : 'text' + const meta = parseMeta((code.data as { meta?: string } | undefined)?.meta) + const title = typeof meta.title === 'string' ? meta.title : undefined + // A TypeScript block that contains JSX is TSX (the old site's rule). + if (lang === 'typescript' && /<\/[A-Za-z][\w.]*>|<[A-Z][\w.]*(\s[^<>]*)?\/>|<>|<\/>/.test(raw)) lang = 'tsx' + let children: ElementContent[] = [{ type: 'text', value: raw }] + if (lang !== 'text' && loaded.has(lang)) { + const out = hl.codeToHast(raw, { lang, theme: decoTheme.name!, transformers: [yamlBooleanKeys] }) + const hPre = out.children.find((c): c is Element => isEl(c, 'pre')) + const hCode = hPre?.children.find((c): c is Element => isEl(c, 'code')) + if (hCode) children = hCode.children + } else if (lang !== 'text') { + throw new Error(`Code block language "${lang}" isn't loaded; add it to LANGS in build/rehype-docs.ts`) + } + const props: Record<string, string | boolean> = { dataLang: lang } + if (title) props.dataTitle = title + const oneline = !raw.includes('\n') + if (!title && lang === 'bash' && oneline && /^(npm|npx|pnpm|yarn|bun|bunx)\b/.test(raw)) props.dataCmd = true + if (!title && lang === 'bash' && /^https?:\/\//.test(raw)) props.dataUrl = true + if (raw.includes('│')) props.dataDiagram = true + const newPre: Element = { + type: 'element', + tagName: 'pre', + properties: props, + children: [{ type: 'element', tagName: 'code', properties: { className: [`language-${lang}`] }, children }], + } + if (parent && typeof index === 'number') parent.children[index] = newPre + return SKIP + }) + + // 5. Inline code classes. + visit(tree, 'element', (el, _i, parent) => { + if (el.tagName === 'pre') return SKIP + if (el.tagName !== 'code' || isEl(parent, 'pre')) return + const text = toString(el).trim() + const cls = classList(el) + if (text.length <= 24) cls.push('is-short') + el.properties = { ...el.properties, className: cls.length ? cls : undefined } + }) + visit(tree, 'element', (cell) => { + if (cell.tagName !== 'td' && cell.tagName !== 'th') return + visit(cell, 'element', (c) => { + if (c.tagName === 'code' && /\s/.test(toString(c).trim())) c.properties = { ...c.properties, className: [...classList(c), 'can-wrap'] } + }) + }) + + // export const headings = [...] + tree.children.unshift({ + type: 'mdxjsEsm', + value: '', + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ExportNamedDeclaration', + specifiers: [], + declaration: { + type: 'VariableDeclaration', + kind: 'const', + declarations: [{ type: 'VariableDeclarator', id: { type: 'Identifier', name: 'headings' }, init: valueToEstree(headings) }], + }, + }, + ], + }, + }, + } as unknown as RootContent) + } +} diff --git a/docs/build/shiki-theme.ts b/docs/build/shiki-theme.ts new file mode 100644 index 00000000..d9ca8e41 --- /dev/null +++ b/docs/build/shiki-theme.ts @@ -0,0 +1,80 @@ +/** + * One Shiki theme whose colors are CSS variables (`--syn-*`, defined in src/styles/tokens.css for + * light, dark and print). Highlighting happens once, at build time, and the page switches palettes + * with the theme toggle like everything else. The categories mirror the old Prism setup: + * comment, keyword, string, type, function, number, property, punctuation, operator, tag. + */ +import type { ThemeRegistrationRaw } from 'shiki' + +const v = (name: string) => `var(--syn-${name})` + +export const decoTheme: ThemeRegistrationRaw = { + name: 'deco-css-vars', + type: 'light', + colors: { + 'editor.foreground': 'var(--code-fg)', + 'editor.background': 'transparent', + }, + settings: [ + { settings: { foreground: 'var(--code-fg)', background: 'transparent' } }, + { scope: ['comment', 'punctuation.definition.comment', 'string.quoted.docstring'], settings: { foreground: v('comment'), fontStyle: 'italic' } }, + // JSDoc tags inside comments (`@title`) stay plain comment text, as with Prism. + { scope: ['comment.block.documentation storage.type.class', 'comment storage.type', 'comment.block.documentation entity.name.type', 'comment.block.documentation variable'], settings: { foreground: v('comment'), fontStyle: 'italic' } }, + { + scope: [ + 'keyword', + 'storage.type', + 'storage.modifier', + 'keyword.control', + 'keyword.operator.new', + 'keyword.operator.expression', + 'keyword.operator.typeof', + 'keyword.operator.satisfies', + 'keyword.operator.as', + 'variable.language.this', + 'constant.language.null', + 'constant.language.undefined', + ], + settings: { foreground: v('keyword') }, + }, + { scope: ['keyword.operator', 'storage.type.function.arrow', 'keyword.operator.type.annotation'], settings: { foreground: v('operator') } }, + { scope: ['keyword.operator.expression', 'keyword.operator.new', 'keyword.operator.ternary.ts'], settings: { foreground: v('keyword') } }, + { scope: ['string', 'string.quoted', 'string.template', 'punctuation.definition.string', 'string.unquoted.plain.out.yaml'], settings: { foreground: v('string') } }, + { scope: ['constant.numeric', 'constant.language.boolean', 'constant.language.json', 'constant.language'], settings: { foreground: v('number') } }, + { scope: ['string.regexp', 'constant.other.symbol'], settings: { foreground: v('number') } }, + { + scope: [ + 'entity.name.type', + 'entity.name.class', + 'support.type.primitive', + 'support.type.builtin', + 'support.class', + 'entity.other.inherited-class', + 'entity.name.namespace', + 'support.class.component', + 'variable.other.constant.object', + ], + settings: { foreground: v('type') }, + }, + { scope: ['entity.name.function', 'support.function', 'meta.function-call entity.name.function', 'entity.name.command', 'support.function.builtin.shell'], settings: { foreground: v('function') } }, + { + scope: [ + 'support.type.property-name', + 'support.type.property-name.json', + 'meta.object-literal.key', + 'variable.other.property', + 'variable.other.object.property', + 'entity.other.attribute-name', + 'entity.name.tag.yaml', + ], + settings: { foreground: v('property') }, + }, + { scope: ['punctuation', 'meta.brace', 'punctuation.separator', 'punctuation.terminator', 'punctuation.accessor', 'meta.delimiter'], settings: { foreground: v('punctuation') } }, + { scope: ['entity.name.tag', 'punctuation.definition.tag'], settings: { foreground: v('tag') } }, + { scope: ['punctuation.definition.tag'], settings: { foreground: v('punctuation') } }, + { scope: ['support.class.component.tsx', 'entity.name.tag.tsx support.class.component'], settings: { foreground: v('type') } }, + // Shell arguments (`install`, `@decocms/blocks`, `-D`) are plain text, as with Prism. + { scope: ['variable.parameter.shell', 'constant.other.option', 'string.unquoted.argument.shell', 'string.unquoted.argument'], settings: { foreground: 'var(--code-fg)' } }, + { scope: ['punctuation.definition.template-expression', 'punctuation.section.embedded'], settings: { foreground: v('keyword') } }, + ], +} diff --git a/docs/build/slugify.ts b/docs/build/slugify.ts new file mode 100644 index 00000000..8dc0ac54 --- /dev/null +++ b/docs/build/slugify.ts @@ -0,0 +1,28 @@ +/** + * Heading ids. Same algorithm as the old single-page site (its page script), so an old anchor such as + * `#releases-and-deployment--publishing` maps to `/next/releases-and-deployment#publishing` by dropping the + * `<section id>--` prefix. + */ +export function slugify(text: string): string { + return ( + text + .toLowerCase() + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'section' + ) +} + +/** Returns a slugger that de-duplicates within one page: `a`, `a-2`, `a-3`… */ +export function createSlugger() { + const seen = new Set<string>() + return (text: string, explicit?: string) => { + const base = explicit || slugify(text) + let id = base + let n = 2 + while (seen.has(id)) id = `${base}-${n++}` + seen.add(id) + return id + } +} diff --git a/docs/bun.lock b/docs/bun.lock new file mode 100644 index 00000000..7ba00b9d --- /dev/null +++ b/docs/bun.lock @@ -0,0 +1,780 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "deco-blocks-docs", + "dependencies": { + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", + "react": "^19.3.0", + "react-dom": "^19.3.0", + }, + "devDependencies": { + "@mdx-js/rollup": "^3.1.1", + "@tailwindcss/vite": "^4.3.3", + "@types/bun": "^1.4.2", + "@types/hast": "^3.0.5", + "@types/mdx": "^2.0.14", + "@types/node": "^26.6.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "estree-util-value-to-estree": "^3.5.0", + "hast-util-to-html": "^9.0.5", + "hast-util-to-string": "^3.0.1", + "pagefind": "^1.5.2", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", + "remark-mdx-frontmatter": "^6.0.0", + "shiki": "^4.5.0", + "tailwindcss": "^4.3.3", + "typescript": "^5.9.3", + "unist-util-visit": "^5.1.0", + "vite": "^8.3.2", + "yaml": "^2.9.1", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], + + "@mdx-js/rollup": ["@mdx-js/rollup@3.1.1", "", { "dependencies": { "@mdx-js/mdx": "^3.0.0", "@rollup/pluginutils": "^5.0.0", "source-map": "^0.7.0", "vfile": "^6.0.0" }, "peerDependencies": { "rollup": ">=2" } }, "sha512-v8satFmBB+DqDzYohnm1u2JOvxx6Hl3pUvqzJvfs2Zk/ngZ1aRUhsWpXvwPkNeGN9c2NCm/38H29ZqXQUjf8dw=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@oozcitak/dom": ["@oozcitak/dom@2.0.2", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/url": "^3.0.0", "@oozcitak/util": "^10.0.0" } }, "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w=="], + + "@oozcitak/infra": ["@oozcitak/infra@2.0.2", "", { "dependencies": { "@oozcitak/util": "^10.0.0" } }, "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA=="], + + "@oozcitak/url": ["@oozcitak/url@3.0.0", "", { "dependencies": { "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0" } }, "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ=="], + + "@oozcitak/util": ["@oozcitak/util@10.0.0", "", {}, "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA=="], + + "@oxc-project/types": ["@oxc-project/types@0.152.0", "", {}, "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw=="], + + "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.5.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ=="], + + "@pagefind/darwin-x64": ["@pagefind/darwin-x64@1.5.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw=="], + + "@pagefind/freebsd-x64": ["@pagefind/freebsd-x64@1.5.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA=="], + + "@pagefind/linux-arm64": ["@pagefind/linux-arm64@1.5.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw=="], + + "@pagefind/linux-x64": ["@pagefind/linux-x64@1.5.2", "", { "os": "linux", "cpu": "x64" }, "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA=="], + + "@pagefind/windows-arm64": ["@pagefind/windows-arm64@1.5.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g=="], + + "@pagefind/windows-x64": ["@pagefind/windows-x64@1.5.2", "", { "os": "win32", "cpu": "x64" }, "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.12", "", { "os": "android", "cpu": "arm" }, "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.12", "", { "os": "android", "cpu": "arm64" }, "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.12", "", { "os": "linux", "cpu": "arm" }, "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.12", "", { "os": "linux", "cpu": "x64" }, "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.12", "", { "os": "win32", "cpu": "x64" }, "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.63.6", "", { "os": "android", "cpu": "arm" }, "sha512-G6xF9OVRWsbHadMfoPNUDwW/Jt70QYG+ZDUvtYCFxjf3SPhqVAO0u9ShXZ7Cf5silVoRWyN4bDjCEKnJkXkw0w=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.63.6", "", { "os": "android", "cpu": "arm64" }, "sha512-Us/kTH5e2anr1CQvO8MEq4eeCVcIXI3Ag7OFxDI30n2OW7Q7xWuxo8ItbYq28PG2Sm1NWmZzG9+SqVEaj25SFA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.63.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fwaSNrSHp9PJuEh4bKtd0mYC+G3FeVDjSTmsSiSlIYu7TFCpxrfB6lk7Ao8cIbswFyklPB1ZDUV8LkLBM15VWw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.63.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-QR2tx26gCeGh4eAGnUasfnJuRc+d7Vc0Jo1/fYtKGuiKqCuhbVpbNEvqgYGBwGDZGyS04jj+zdHi9W8uvb1Sdw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.63.6", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-7E1wRJEW8r7WlqmCy58ugndWHrzoEV/pRt2x0D9el7Inra62+Cw00lYW9XhW6iUCtFjhNw49McsSCIzJ/nG8uw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.63.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-13KJeF+vDswMzEHmMIouCVcEQdov3CzAA3ZhM8QFmBJV/FZ/VMZI49UHDvPXTK+wokk86kYPmQ9Qb3xGGJbZoQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.63.6", "", { "os": "linux", "cpu": "arm" }, "sha512-hV+W1r8HM84PER4If94QuzrrD0DNk4CxobNhVUiz9ZiiWHDzJss4Jra2AYGH1a7GOTzjS1sIyZ35yYEhw/aq+g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.63.6", "", { "os": "linux", "cpu": "arm" }, "sha512-5dWs/GENZufRph8PEfl0TrNH/R3DHJA+VxF/dqlfV2oUZfxRtN3qmFXg2DZj8DYM8d+Vd3dgZtuVmoBE/nb2YQ=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.63.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-inQYLPVIUvYk+s7zpxQldqwrajUNs+t4TJjPGzsdPTvsT8gsQXVcW/sQFolHZ3YSWVk0nMMwU7TtRi0Ow+QMuw=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.63.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-sH+0MV1HmDC1q7+bQnlN9ywKBmHqkA2EdD/hHYrrz654ybc+vNC1FRNyzuYKicMQVtZZ4X2jz0osJYmsOtnj4g=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.63.6", "", { "os": "linux", "cpu": "none" }, "sha512-mCECvRr6HGekdBc6dvHzUWubqEti4JyOqlNePrOzl4Wimk26iW5Zy9rld2mQbC9id1NYjfF8nHLFq5fr3CuOKQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.63.6", "", { "os": "linux", "cpu": "none" }, "sha512-bb9rdoCPwM4DEsbOP3CJgFnvhSJ3XKCUYyUhxnlSs/XGT0DknpOYgLCPbK8VUzwl+cZGzFQm0KbBYRaSGZC2YQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.63.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aIJRoGCHev45JSu7JMzVrMzu1NX3nQrXx1Vb/6y4AblxW7JpCMpk4e8C6lHzvSElxi3UEcMdFJldsuWQjV1ddA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.63.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-LvjnulezHjiaM5Ga9MPCDLTVddjvcrJhZc6vEz9fxeFRGPJsPAX9HuTuPTUQ3l8d+nYpZB/xWzUpuKJlLQm3VQ=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.63.6", "", { "os": "linux", "cpu": "none" }, "sha512-YmhSBeYZwJ937s8tKpYAhms48N+tj7e+oFkbkVVxfMRdAiynK2AxqRapJXB4utyXxc/iYTkMfPRQ+7ZP0IO78w=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.63.6", "", { "os": "linux", "cpu": "none" }, "sha512-9+YukhzqTvJvzfDXJJzkURK9TDY01nofNH3pyHGuucGC4xPvBVtIfCAqJjkjnmBEGJYNXB8sBc2EDt7xDgGenw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.63.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-9G5AtEkpN/A89BILHHEL9aYRYTuFjwa8PUQqoEg8SDrwOZBKpwYqNlQCBNI5QPwGrf7qFlb6LNFilo8FCSA3Wg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.63.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Ezx2E5D6Siz805j41x91JiGbzuCm8i2U98U37SQ7ytY737+wsJq+P9nPL6UmqDww491zO7MTivYOTrtmTTeCSw=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.63.6", "", { "os": "linux", "cpu": "x64" }, "sha512-8EZ1Q3PB7yUjvR4MbFAnCBF1wF0XRe5QQqbtlJXP7ZX5C8iYlSQJugUhj9oS0gbUP4jp9qGhaIgFnV9Nvod86Q=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.63.6", "", { "os": "openbsd", "cpu": "x64" }, "sha512-3JI27TALItfZ/qaxXKdbRXfV6WepUoUa3IN07FE+1xxoRdl3JIyrp5PiDF0vOHsPgEiOFkF8s3B3BRymQkdwGA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.63.6", "", { "os": "none", "cpu": "arm64" }, "sha512-BNOGeNFNRZDT7Hfc7b9JbTZFXkpAXeXl/CZWtxdd5CNfBaor/0RkJjg+q+E2gwVIvGqqp/NclThKFGrhuScVhw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.63.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-D3eEyLSIim5WF5fHbwnx+ryk0delN7fJyBrDTO3wVCCNBZ/cA8aCGwLS3SiuK+vRI4E7dqikAlStGuR20dBMuw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.63.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-xwbs9g8zLbHzj1poG1uh27xtubsu27Gk2nuN6kvvGDybjiTfwMksQAnF+phlvI5oeXkJWWDc6kg1t3XpgUYvrg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.63.6", "", { "os": "win32", "cpu": "x64" }, "sha512-Tr6rhRNnFthvXr45zWVZQ5P8jZ5UwZnXNc7Qnu80cP/VVYtSioOPmvv7wP6UMgRfkBCJr2Pm60ua0YBDSJjgnw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.6", "", { "os": "win32", "cpu": "x64" }, "sha512-0RAxO1pS/6lY1mSoD2Apm+6aPyfUlm+OWKgncIdj/eVN9I+pDY3lKgD7xsUNFei1u7MZQETGOo6m8POXMDp53A=="], + + "@shikijs/core": ["@shikijs/core@4.5.0", "", { "dependencies": { "@shikijs/primitive": "4.5.0", "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5", "hast-util-to-html": "^9.0.5" } }, "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw=="], + + "@shikijs/langs": ["@shikijs/langs@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0" } }, "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA=="], + + "@shikijs/primitive": ["@shikijs/primitive@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg=="], + + "@shikijs/themes": ["@shikijs/themes@4.5.0", "", { "dependencies": { "@shikijs/types": "4.5.0" } }, "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q=="], + + "@shikijs/types": ["@shikijs/types@4.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], + + "@tanstack/history": ["@tanstack/history@1.162.4", "", {}, "sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ=="], + + "@tanstack/react-router": ["@tanstack/react-router@1.170.41", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/react-store": "^0.11.2", "@tanstack/router-core": "1.171.34", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w=="], + + "@tanstack/react-start": ["@tanstack/react-start@1.168.60", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/react-start-client": "1.168.39", "@tanstack/react-start-rsc": "0.1.59", "@tanstack/react-start-server": "1.167.46", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-plugin-core": "1.171.49", "@tanstack/start-server-core": "1.169.39", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-WMOM0Xlh2lPbVlbjYM6uOxOpiaWRPvODm/dwkLsGKq5tjyHOHZmKckJgwlrZl7OQUqFlOZHYj8bKUTgoeg8cUQ=="], + + "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.39", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/start-client-core": "1.170.34" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Uby0uo37JlQgUKHU0snDwH9nYiEeAjm9+7pUOFgycU/UszEILC2SO2DSCoNnCMuHKj9z+4URKzBYIP2exT9+wg=="], + + "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.59", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/router-utils": "1.162.3", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.49", "@tanstack/start-storage-context": "1.167.36", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-M2Lqxkk5C1zQncQs5paqX+/OLdOht51pg6D84YfLMrNsjhU/nzb6wd92MIja0mi5i23+L+fxLLjQYR2DObjBlw=="], + + "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.46", "", { "dependencies": { "@tanstack/react-router": "1.170.41", "@tanstack/router-core": "1.171.34", "@tanstack/start-server-core": "1.169.39" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-K7z8xBOEdp8u5CZCxWrbS0tFtk5XZGMdhssLeZlRZKlLDbX7BKLdMtJ7th5sR6QVt9MTUAg3LcoqvJMPSFF+lg=="], + + "@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], + + "@tanstack/router-core": ["@tanstack/router-core@1.171.34", "", { "dependencies": { "@tanstack/history": "1.162.4", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw=="], + + "@tanstack/router-generator": ["@tanstack/router-generator@1.167.40", "", { "dependencies": { "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.34", "@tanstack/router-utils": "1.162.3", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.9.6", "zod": "^4.5.4" } }, "sha512-WkEueqEvsBBudzO0fx1nWfOmRNW8OAvBoVABTGls9bAWzjdC+KDGHvjCNrvn88ofs1F6gqL8aWnzKCbxW3dCyQ=="], + + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.42", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.34", "@tanstack/router-generator": "1.167.40", "@tanstack/router-utils": "1.162.3", "chokidar": "^5.0.0", "unplugin": "^3.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.41", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-vAR5WXJh/ErseEWQftDhsJcr2c++ucRQ6FbqN3ygMO44+ZxxRfW3sIpZmddHROVSmBk8jc6MrwjTPNjVhd62Vw=="], + + "@tanstack/router-utils": ["@tanstack/router-utils@1.162.3", "", { "dependencies": { "@babel/generator": "^7.29.8", "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "ansis": "^4.3.1", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.4", "pathe": "^2.0.3", "tinyglobby": "^0.2.17" } }, "sha512-Icb0xGuG1+54IV0WMLRcc3ErTx2HeJWyPG661FkQ8UT6guoBq1FJjFRqlG/JS0xi4mFFkBhvwO7tbLa32f3yxA=="], + + "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.34", "", { "dependencies": { "@tanstack/router-core": "1.171.34", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.36", "seroval": "^1.6.7" } }, "sha512-LfwZSbau2MLLZ/uPAoO+NC9fK3J66c0wLhnNQqIa+Goqw7bXPjkHwlsjY7evlUz1AnyhhQ1Qab95v+VmP0TZ9A=="], + + "@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.162.0", "", {}, "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ=="], + + "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.49", "", { "dependencies": { "@babel/code-frame": "7.29.7", "@babel/core": "^7.29.7", "@babel/types": "^7.29.8", "@jridgewell/remapping": "^2.3.5", "@tanstack/router-core": "1.171.34", "@tanstack/router-generator": "1.167.40", "@tanstack/router-plugin": "1.168.42", "@tanstack/router-utils": "1.162.3", "@tanstack/start-server-core": "1.169.39", "exsolve": "^1.1.1", "lightningcss": "^1.33.0", "pathe": "^2.0.3", "picomatch": "^4.0.7", "seroval": "^1.6.7", "source-map": "^0.7.6", "srvx": "^0.11.22", "tinyglobby": "^0.2.17", "ufo": "^1.6.4", "vitefu": "^1.1.3", "xmlbuilder2": "^4.0.3", "zod": "^4.5.4" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-HPOb7LnMc71k38cd5uvMybPPUU/Q/56KvSqQzSWz7cps1ZDFXaxUM6EFaFHLCdgTWqGexNGDdRE+sUVPVBM4Xg=="], + + "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.39", "", { "dependencies": { "@tanstack/history": "1.162.4", "@tanstack/router-core": "1.171.34", "@tanstack/start-client-core": "1.170.34", "@tanstack/start-storage-context": "1.167.36", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.6.7" } }, "sha512-pqqkxhXY46nuFSYt6TsMGyABiplUzHFHndKAlRRGC0fWDrJCH1koWxVMoxySa51ZETV79f/z73b5UZ1jfIkXwQ=="], + + "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.36", "", { "dependencies": { "@tanstack/router-core": "1.171.34" } }, "sha512-x+xCXK2GtFzY6NbXqAg8YpU6cwAjDfMeYSNzxfGzntpjbwBRHHrXeC/kYUnBBQCV7wK4kJ8vBBQGP0OyYTasUg=="], + + "@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="], + + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], + + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@26.6.3", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.1", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw=="], + + "acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ansis": ["ansis@4.4.0", "", {}, "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.27", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ=="], + + "browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="], + + "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001814", "", {}, "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.443", "", {}, "sha512-TDJG36L9A3CWWwZ97HKaE+Iz1sW80pNp6sAU0owzCIV3Zc9eVyRqZK8Vz6NKuMIU8WjP0pIkoqCa0VRf/yFD0Q=="], + + "enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="], + + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], + + "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], + + "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "estree-util-scope": ["estree-util-scope@1.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA=="], + + "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], + + "estree-util-value-to-estree": ["estree-util-value-to-estree@3.5.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ=="], + + "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "exsolve": ["exsolve@1.1.1", "", {}, "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetchdts": ["fetchdts@0.1.7", "", {}, "sha512-YoZjBdafyLIop9lSxXVI33oLD5kN31q4Td+CasofLLYeLXRFeOsuOw0Uo+XNRi9PZlbfdlN2GmRtm4tCEQ9/KA=="], + + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "h3-v2": ["h3@2.0.1-rc.20", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.13" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg=="], + + "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "isbot": ["isbot@5.2.2", "", {}, "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-character": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "micromark": ["micromark@4.0.3", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.4", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.1.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.3" } }, "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w=="], + + "micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], + + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], + + "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], + + "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], + + "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="], + + "micromark-factory-space": ["micromark-factory-space@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-edit-map": ["micromark-util-edit-map@1.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "node-releases": ["node-releases@2.0.57", "", {}, "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + + "pagefind": ["pagefind@1.5.2", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.5.2", "@pagefind/darwin-x64": "1.5.2", "@pagefind/freebsd-x64": "1.5.2", "@pagefind/linux-arm64": "1.5.2", "@pagefind/linux-x64": "1.5.2", "@pagefind/windows-arm64": "1.5.2", "@pagefind/windows-x64": "1.5.2" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "prettier": ["prettier@3.9.9", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg=="], + + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + + "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="], + + "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="], + + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], + + "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], + + "recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="], + + "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + + "remark-frontmatter": ["remark-frontmatter@5.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-frontmatter": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0", "unified": "^11.0.0" } }, "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], + + "remark-mdx-frontmatter": ["remark-mdx-frontmatter@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "estree-util-value-to-estree": "^3.0.0", "smol-toml": "^1.0.0", "unified": "^11.0.0", "unist-util-mdx-define": "^1.0.0", "yaml": "^2.0.0" } }, "sha512-Rz7oaxWwaKpWn/c+iqWOq5wQWPt8TdPyfUIZWBCGqwSqNZ7X8heHkPviy7/UHUXjtd8QTnEV6LXb+CKn9A+ZFA=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "rolldown": ["rolldown@1.2.12", "", { "dependencies": { "@oxc-project/types": "=0.152.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.12", "@rolldown/binding-android-arm64": "1.2.12", "@rolldown/binding-darwin-arm64": "1.2.12", "@rolldown/binding-darwin-x64": "1.2.12", "@rolldown/binding-freebsd-x64": "1.2.12", "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", "@rolldown/binding-linux-arm64-gnu": "1.2.12", "@rolldown/binding-linux-arm64-musl": "1.2.12", "@rolldown/binding-linux-ppc64-gnu": "1.2.12", "@rolldown/binding-linux-s390x-gnu": "1.2.12", "@rolldown/binding-linux-x64-gnu": "1.2.12", "@rolldown/binding-linux-x64-musl": "1.2.12", "@rolldown/binding-openharmony-arm64": "1.2.12", "@rolldown/binding-win32-arm64-msvc": "1.2.12", "@rolldown/binding-win32-x64-msvc": "1.2.12" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ=="], + + "rollup": ["rollup@4.63.6", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.6", "@rollup/rollup-android-arm64": "4.63.6", "@rollup/rollup-darwin-arm64": "4.63.6", "@rollup/rollup-darwin-x64": "4.63.6", "@rollup/rollup-freebsd-arm64": "4.63.6", "@rollup/rollup-freebsd-x64": "4.63.6", "@rollup/rollup-linux-arm-gnueabihf": "4.63.6", "@rollup/rollup-linux-arm-musleabihf": "4.63.6", "@rollup/rollup-linux-arm64-gnu": "4.63.6", "@rollup/rollup-linux-arm64-musl": "4.63.6", "@rollup/rollup-linux-loong64-gnu": "4.63.6", "@rollup/rollup-linux-loong64-musl": "4.63.6", "@rollup/rollup-linux-ppc64-gnu": "4.63.6", "@rollup/rollup-linux-ppc64-musl": "4.63.6", "@rollup/rollup-linux-riscv64-gnu": "4.63.6", "@rollup/rollup-linux-riscv64-musl": "4.63.6", "@rollup/rollup-linux-s390x-gnu": "4.63.6", "@rollup/rollup-linux-x64-gnu": "4.63.6", "@rollup/rollup-linux-x64-musl": "4.63.6", "@rollup/rollup-openbsd-x64": "4.63.6", "@rollup/rollup-openharmony-arm64": "4.63.6", "@rollup/rollup-win32-arm64-msvc": "4.63.6", "@rollup/rollup-win32-ia32-msvc": "4.63.6", "@rollup/rollup-win32-x64-gnu": "4.63.6", "@rollup/rollup-win32-x64-msvc": "4.63.6", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w4+GKyTBqshj84F1GF3s2zxAPFZPEmNkCbQtqilsYS7GsksmX6VoV7UggfZg8sUHCCOkBe7UtQp6fZwbH311FQ=="], + + "rou3": ["rou3@0.8.1", "", {}, "sha512-ePa+XGk00/3HuCqrEnK3LxJW7I0SdNg6EFzKUJG73hMAdDcOUC/i/aSz7LSDwLrGr33kal/rqOGydzwl6U7zBA=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "seroval": ["seroval@1.6.8", "", {}, "sha512-HlSgSAkTk4EqHcje1ptJjfZi1YDv5KbhVJ/d3P7T/nAXua2VmDu+AKDX5VTdFfZf48nDkWB2TKYt0DrCSa+3wg=="], + + "seroval-plugins": ["seroval-plugins@1.6.8", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-N7mWAMydj89EnYTHtRpS3LBrAz3J9O6oVrqpuXuvlAHfAUG8WEKqtbAd7SBiJNY5NfCAFhSdyAh7wI4Shjqy4g=="], + + "shiki": ["shiki@4.5.0", "", { "dependencies": { "@shikijs/core": "4.5.0", "@shikijs/engine-javascript": "4.5.0", "@shikijs/engine-oniguruma": "4.5.0", "@shikijs/langs": "4.5.0", "@shikijs/themes": "4.5.0", "@shikijs/types": "4.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.5" } }, "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ=="], + + "smol-toml": ["smol-toml@1.9.0", "", {}, "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-mdx-define": ["unist-util-mdx-define@1.1.2", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-9ncH7i7TN5Xn7/tzX5bE3rXgz1X/u877gYVAUB3mLeTKYJmQHmqKTDBi6BTGXV7AeolBCI9ErcVsOt2qryoD0g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "unplugin": ["unplugin@3.4.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rsbuild/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rsbuild/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w=="], + + "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@8.3.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.11", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + + "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + + "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + } +} diff --git a/docs/components/home/Footer.tsx b/docs/components/home/Footer.tsx new file mode 100644 index 00000000..4e340ba3 --- /dev/null +++ b/docs/components/home/Footer.tsx @@ -0,0 +1,121 @@ +import { MdxLink } from '~/components/mdx/MdxLink' +import { WordmarkLime } from '~/components/ui/Brand' +import { btnOutline, btnPrimary, container, cta, d, Dim, H2, reveal } from './ui' + +const noteLink = 'text-inherit underline decoration-1 underline-offset-2 rounded-[2px] transition-colors hover:text-fg' + +export function FinalCta() { + return ( + <div className="py-28 bg-bg text-center max-sm:py-20" role="group" aria-labelledby="final-title"> + <div className={`${container} flex flex-col items-center`}> + <H2 id="final-title" className={`max-w-[620px] mx-auto ${reveal}`}> + Start with one function. <Dim>Ship it from Git.</Dim> + </H2> + <div className={`${cta} justify-center ${reveal}`} style={d('120ms')}> + <MdxLink className={btnPrimary} href="/next/quickstart"> + Read the quickstart → + </MdxLink> + <MdxLink className={btnOutline} href="/next/how-it-works"> + How it works + </MdxLink> + </div> + <p className={`mt-4 mx-auto max-w-[560px] text-13 leading-5 text-quiet ${reveal}`} style={d('120ms')}> + The quickstart runs in plain Node, no framework. Or read the{' '} + <MdxLink className={noteLink} href="/next/api-reference"> + API reference + </MdxLink> + , or{' '} + <MdxLink className={noteLink} href="/next/internals"> + what's under the hood + </MdxLink> + . + </p> + </div> + </div> + ) +} + +export interface FooterColumn { + title: string + /** [href, label] */ + links: [string, string][] +} + +/** The next major's footer columns (its home is /next/). The current release's are in v7/columns.ts. */ +export const NEXT_COLUMNS: FooterColumn[] = [ + { + title: 'Docs', + links: [ + ['/next/how-it-works', 'How it works'], + ['/next/quickstart', 'Quickstart'], + ['/next/blocks', 'Blocks'], + ['/next/releases-and-drafts', 'Releases & drafts'], + ['/next/api-reference', 'API reference'], + ], + }, + { + title: 'Guides', + links: [ + ['/next/nextjs', 'Next.js App Router'], + ['/next/tanstack-start-descriptors', 'TanStack Start'], + ['/next/routing', 'Pages & routing'], + ['/next/releases-and-deployment', 'Deployment'], + ['/next/telemetry', 'Telemetry'], + ['/next/troubleshooting', 'Troubleshooting'], + ], + }, + { + title: 'Under the hood', + links: [ + ['/next/internals', 'Overview'], + ['/next/how-resolution-works', 'How resolution works'], + ['/next/hosted-releases-internals', 'Hosted releases internals'], + ['/next/router-internals', 'Router internals'], + ['/next/content-protocol', 'Content protocol'], + ['/next/design-decisions', 'Design decisions'], + ], + }, + { + title: 'Project', + links: [ + ['https://github.com/decocms/blocks', 'GitHub'], + ['/next/internals#contributing', 'Contributing'], + ['/next/renames-and-migrations', 'Migrating from v7'], + ['/roadmap', 'Roadmap'], + ], + }, +] + +/** + * The landing's site footer, with the columns of the home's version. `site-footer` is the hook + * Menu.tsx uses to make it inert under the open drawer. + */ +export function SiteFooter({ columns }: { columns: FooterColumn[] }) { + return ( + <footer className="site-footer p-2 bg-footer-bg max-sm:p-1.5 [&_:where(:focus-visible)]:outline-lime"> + <div className="relative overflow-hidden pt-32 rounded-2xl bg-footer-panel text-[#E7E5E4] max-sm:pt-16"> + <div className={container}> + <div className="grid grid-cols-4 gap-10 max-md:grid-cols-2 max-md:gap-x-6 max-md:gap-y-9"> + {columns.map((col) => ( + <nav aria-label={col.title} key={col.title}> + <p className="mb-2 text-18 leading-[1.625] font-medium text-lime">{col.title}</p> + {col.links.map(([href, label]) => ( + <MdxLink + className="block w-fit py-2 text-16 leading-6 text-[#E7E5E4] no-underline rounded-[3px] transition-colors duration-300 hover:text-[rgba(208,236,26,.85)]" + href={href} + key={href} + > + {label} + </MdxLink> + ))} + </nav> + ))} + </div> + </div> + <div className="max-w-[1296px] mt-14 mx-auto px-10 aspect-[1296/250] overflow-hidden max-sm:px-4 max-sm:mt-10 [&_.wm]:w-full [&_.wm]:h-auto" aria-hidden="true"> + <WordmarkLime /> + </div> + </div> + </footer> + ) +} diff --git a/docs/components/home/Frame.tsx b/docs/components/home/Frame.tsx new file mode 100644 index 00000000..f0e5444b --- /dev/null +++ b/docs/components/home/Frame.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from 'react' +import cobogoDefs from '~/assets/cobogo-defs.svg?raw' +import { LandingShell } from '~/src/layout/DocsShell' +import { sidebarFor } from '~/src/lib/nav' +import { SiteFooter, type FooterColumn } from './Footer' +import { useReveal } from './useReveal' + +const COBOGO_DEFS = cobogoDefs.trim() + +/** The landing frame both homes share: drawer nav and footer of `version`, the cobogó pattern defs, the indexed section. */ +export function HomeFrame({ version, columns, children }: { version: string; columns: FooterColumn[]; children: ReactNode }) { + const ref = useReveal<HTMLElement>() + return ( + <LandingShell nav={sidebarFor(version, 'docs')} footer={<SiteFooter columns={columns} />}> + {/* The cobogó <pattern>s (#cb-lg, #cb-sm) that the hero and stability bands fill with. */} + <span + className="contents [&>svg]:absolute [&>svg]:size-0 [&>svg]:overflow-hidden [&>svg]:pointer-events-none" + dangerouslySetInnerHTML={{ __html: COBOGO_DEFS }} + /> + <section id="home" aria-labelledby="home-title" data-pagefind-body="" ref={ref}> + {children} + </section> + </LandingShell> + ) +} + diff --git a/docs/components/home/Hero.tsx b/docs/components/home/Hero.tsx new file mode 100644 index 00000000..97bcf92a --- /dev/null +++ b/docs/components/home/Hero.tsx @@ -0,0 +1,100 @@ +import { useState } from 'react' +import { Icon } from '~/components/ui/Icon' +import { MdxLink } from '~/components/mdx/MdxLink' +import { copyText, toast } from '~/src/lib/ui' +import { Journey } from './Journey' +import { btnPrimary, btnWhite, container, cta, d, enter } from './ui' + +/** + * The cobogó pattern behind a band (the <pattern>s #cb-lg/#cb-sm are defined once in index.tsx), + * faded out towards the middle by a mask. `className` places it (top/height) and may swap the mask. + */ +export function Cobogo({ className = '' }: { className?: string }) { + return ( + <svg className={`absolute inset-x-0 -z-1 w-full pointer-events-none ${className}`} aria-hidden="true" focusable="false"> + <rect className="max-md:hidden" width="100%" height="100%" fill="url(#cb-lg)" /> + <rect className="hidden max-md:inline" width="100%" height="100%" fill="url(#cb-sm)" /> + </svg> + ) +} + +/** The default cobogó mask: pattern at both edges, clear in the middle. */ +export const cobogoEdges = + '[mask-image:linear-gradient(to_right,#000_0%,#000_17%,transparent_39%,transparent_61%,#000_83%,#000_100%)]' + +const INSTALL = 'npm install @decocms/blocks' + +/** The hero's "$ command" pill: copies `command`. A long one truncates on phones (the full text stays in data-copy and the label). */ +export function InstallButton({ command = INSTALL }: { command?: string }) { + const [copied, setCopied] = useState(false) + return ( + <button + className="group h-12 inline-flex items-center gap-3 pl-5 pr-2.5 border-0 rounded-full bg-[rgba(255,255,255,.2)] text-white font-mono text-13.5 leading-5 max-w-full whitespace-nowrap transition-[background-color,scale] hover:bg-[rgba(255,255,255,.3)] active:scale-[.98] max-sm:w-full max-sm:justify-between max-sm:text-13" + type="button" + data-copy={command} + aria-label={`Copy install command: ${command}`} + onClick={async () => { + const ok = await copyText(command) + toast(ok ? `Copied: ${command}` : 'Select the text to copy it.') + if (ok) { + setCopied(true) + setTimeout(() => setCopied(false), 1600) + } + }} + > + <span className="text-brand" aria-hidden="true"> + $ + </span> + <span className="min-w-0 overflow-hidden text-ellipsis max-sm:flex-1 max-sm:text-left">{command}</span> + <span + className="size-[30px] grid place-items-center rounded-full text-[rgba(255,255,255,.72)] group-hover:text-white group-hover:bg-[rgba(255,255,255,.1)]" + aria-hidden="true" + > + <Icon name="copy" className={copied ? 'hidden' : 'size-3.5'} /> + <Icon name="check" className={copied ? 'size-3.5 block text-brand' : 'hidden'} /> + </span> + </button> + ) +} + +/** The hero band: forest gradient under the floating header, room for it on top. */ +export const heroBand = + 'relative isolate overflow-hidden text-band-fg pt-[calc(var(--header-h)+112px)] pb-22 max-sm:pt-[calc(var(--header-h)+56px)] max-sm:pb-14 [background-image:radial-gradient(800px_600px_at_72%_20%,rgba(30,110,55,.45),transparent_60%),linear-gradient(162deg,#1A6030_0%,#0F4A22_48%,#082E14_100%)] [&_:where(:focus-visible)]:outline-brand' + +/** The hero's h1 (white, fluid hero size). */ +export const heroTitle = 'm-0 max-w-[800px] text-white text-hero leading-[1.14] font-normal tracking-display text-balance focus:outline-none' + +export function Hero() { + return ( + <div className={heroBand}> + <Cobogo className={`top-[120px] h-[calc(100%-120px)] max-nav:top-[88px] max-nav:h-[calc(100%-88px)] ${cobogoEdges}`} /> + <div className={container}> + <h1 + id="home-title" + className={`${heroTitle} ${enter}`} + style={d('60ms')} + > + Headless for developers. + <br /> Editable for humans. + <br /> <span className="text-[rgba(255,255,255,.52)]">Native for AI.</span> + </h1> + <p className={`mt-5 max-w-[560px] text-15 leading-[1.6] text-band-muted ${enter}`} style={d('120ms')}> + Deco CMS is the open-source, AI-native headless CMS. Developers write functions, editors change how they're called in the site + editor, AI agents edit the same JSON files, and every change lands in Git, with telemetry and analytics built in. + </p> + <div className={`${cta} ${enter}`} style={d('300ms')}> + <MdxLink className={btnPrimary} href="/next/quickstart"> + Start building + </MdxLink> + <a className={btnWhite} href="#home-how"> + See how it works + </a> + <InstallButton /> + </div> + </div> + <div className={container}> + <Journey /> + </div> + </div> + ) +} diff --git a/docs/components/home/Journey.tsx b/docs/components/home/Journey.tsx new file mode 100644 index 00000000..28af371b --- /dev/null +++ b/docs/components/home/Journey.tsx @@ -0,0 +1,500 @@ +/** + * The hero's product window: the four-pane "Experiments" journey (Type it → Edit it → Commit it → + * Resolve it). Dragging a site editor slider rewrites the JSON diff, the commit status and the odds in + * the last pane (#exp-odds); Save "commits" the change. Below 768px the panes become a + * scroll-snap carousel driven by (and driving) the step pills under the window. + */ +import { Fragment, useCallback, useEffect, useRef, useState, type CSSProperties } from 'react' +import { Icon } from '~/components/ui/Icon' +import { BrandSymbol } from '~/components/ui/Brand' +import { MdxLink } from '~/components/mdx/MdxLink' +import { prefersReducedMotion, toast } from '~/src/lib/ui' +import { Ck, Cm, F, K, L, N, P, S, T } from './tokens' +import { Dots, enter, mono, symbol, textLink, textLinkIcon, win, winBarDark, winTitle } from './ui' + +type Key = 'newCheckout' | 'stickyHeader' | 'freeShippingBanner' +type Values = Record<Key, number> + +const KEYS: Key[] = ['newCheckout', 'stickyHeader', 'freeShippingBanner'] +const FIELDS: Record<Key, string> = { + newCheckout: 'New checkout flow', + stickyHeader: 'Sticky header', + freeShippingBanner: 'Free shipping banner', +} +const LABELS: Record<Key, string> = { + newCheckout: 'new checkout', + stickyHeader: 'sticky header', + freeShippingBanner: 'free shipping banner', +} +/** What's committed on main when the page loads, and what the site editor form shows. */ +const INITIAL_SAVED: Values = { newCheckout: 10, stickyHeader: 50, freeShippingBanner: 0 } +const INITIAL_VALUES: Values = { newCheckout: 25, stickyHeader: 50, freeShippingBanner: 0 } + +function commitMessage(saved: Values, values: Values) { + const changed = KEYS.filter((k) => values[k] !== saved[k]) + if (changed.length === 1) { + const k = changed[0] + return `${values[k] > saved[k] ? 'ramp ' : 'lower '}${LABELS[k]} to ${values[k]}%` + } + return changed.length ? `update ${changed.length} experiments` : '' +} + +export const Arrow = () => ( + <span + className="absolute z-2 top-1/2 -right-[18px] size-[26px] -mt-[13px] grid place-items-center rounded-full bg-win-panel border border-border text-eyebrow shadow-sm max-rail:hidden" + aria-hidden="true" + > + <Icon name="chevron-right" className="size-3.5" /> + </span> +) + +/* The four panes. `jp` is the hook the phone carousel queries. */ +export const pane = 'jp relative min-w-0 flex flex-col border border-hairline rounded-xl bg-win-panel max-md:snap-start' +export const head = 'flex items-center gap-2 h-[42px] px-3.5 border-b border-hairline' +export const stepNum = 'text-12 leading-4 tabular-nums text-eyebrow' +export const title = 'text-13.5 leading-5 font-medium text-fg tracking-ui whitespace-nowrap' +export const meta = 'ml-auto min-w-0 font-mono text-11 leading-4 font-normal text-muted-fg overflow-hidden text-ellipsis whitespace-nowrap' +/* The panes sit on the mock's light surface, so a scrolling pane's focus ring is the olive ring, + not the lime one the Hero gives its other descendants. */ +export const pre = 'flex-1 m-0 pb-3.5 overflow-x-auto text-10.5 leading-[18px] text-code-fg scrollbar-none focus-visible:outline-ring' +/** A numbered pane: no left padding, the line numbers' hairline drawn as a scrolling background. */ +export const preLn = `${pre} pt-3 pl-0 pr-2 [counter-reset:ln] [background:linear-gradient(to_right,transparent_21px,var(--hairline)_21px,var(--hairline)_22px,transparent_22px)_local]` +export const foot = 'flex items-center gap-2 min-h-[42px] px-3.5 py-2.5 border-t border-hairline text-11.5 leading-4 text-muted-fg' +export const footText = 'min-w-0 overflow-hidden text-ellipsis whitespace-nowrap' +export const footMono = `${mono} text-10.5 whitespace-nowrap overflow-hidden text-ellipsis` +export const footIcon = 'size-3.5 text-eyebrow' +/* Diff lines of the JSON pane. */ +export const dl = 'block -mx-3.5 pl-2 pr-3.5 whitespace-pre' +export const gut = 'inline-block w-3 select-none' +export const source = 'inline-flex items-center gap-1.5 h-6 pl-2 pr-2.5 border border-border rounded-full bg-surface text-fg whitespace-nowrap' + +const STEPS = ['Type', 'Edit', 'Commit', 'Resolve'] + +/** + * The phone carousel of a journey window (both homes' heroes): below 768px the `.jp` panes scroll + * horizontally; the step pills follow the scroll position (`step`) and drive it (`showPanel`). + */ +export function useJourneyCarousel() { + const [step, setStep] = useState(0) + const journeyRef = useRef<HTMLDivElement>(null) + const panels = () => Array.from(journeyRef.current?.querySelectorAll<HTMLElement>('.jp') ?? []) + const showPanel = useCallback((i: number) => { + const journey = journeyRef.current + const panel = panels()[i] + if (!journey || !panel) return + const pad = parseFloat(getComputedStyle(journey).paddingLeft) || 0 + journey.scrollTo({ left: panel.offsetLeft - pad, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }) + setStep(i) + }, []) + + useEffect(() => { + const journey = journeyRef.current + if (!journey) return + let tick = false + const onScroll = () => { + if (tick) return + tick = true + requestAnimationFrame(() => { + tick = false + const left = journey.scrollLeft + const pad = parseFloat(getComputedStyle(journey).paddingLeft) || 0 + let best = 0 + let dist = Infinity + panels().forEach((p, i) => { + const d = Math.abs(p.offsetLeft - pad - left) + if (d < dist) { + dist = d + best = i + } + }) + if (journey.scrollLeft + journey.clientWidth >= journey.scrollWidth - 2) best = panels().length - 1 + setStep(best) + }) + } + const onFocusIn = (event: FocusEvent) => { + if (journey.scrollWidth <= journey.clientWidth + 1) return + const list = panels() + const i = list.findIndex((p) => p.contains(event.target as Node)) + if (i < 0) return + const box = journey.getBoundingClientRect() + const r = list[i].getBoundingClientRect() + if (r.left < box.left - 1 || r.right > box.right + 1) showPanel(i) + } + journey.addEventListener('scroll', onScroll, { passive: true }) + journey.addEventListener('focusin', onFocusIn) + return () => { + journey.removeEventListener('scroll', onScroll) + journey.removeEventListener('focusin', onFocusIn) + } + }, [showPanel]) + + return { journeyRef, step, showPanel } +} + +/** The step pills under a journey window (phones only). */ +export function JourneyPills({ steps, step, onSelect }: { steps: string[]; step: number; onSelect: (i: number) => void }) { + return ( + <div + className={`hidden max-md:flex justify-center flex-wrap gap-1.5 mt-4 ${enter}`} + data-pagefind-ignore="" + style={{ '--d': '480ms' } as CSSProperties} + role="group" + aria-label="Steps in the product window" + > + {steps.map((label, i) => ( + <button + type="button" + className="group h-[30px] inline-flex items-center gap-1.5 px-3 border border-[rgba(255,255,255,.22)] rounded-full bg-transparent text-[rgba(255,255,255,.78)] text-13 leading-4 transition-[background-color,color,border-color] duration-300 aria-[current=step]:bg-[rgba(255,255,255,.2)] aria-[current=step]:border-transparent aria-[current=step]:text-white" + data-jp={i} + aria-current={i === step ? 'step' : undefined} + key={label} + onClick={() => onSelect(i)} + > + <span className="tabular-nums text-[rgba(255,255,255,.5)] group-aria-[current=step]:text-lime" aria-hidden="true">{`0${i + 1}`}</span> + {label} + </button> + ))} + </div> + ) +} + +export function Journey() { + const [saved, setSaved] = useState<Values>(INITIAL_SAVED) + const [values, setValues] = useState<Values>(INITIAL_VALUES) + const [lastCommit, setLastCommit] = useState('') + const [flashKey, setFlashKey] = useState<Key | null>(null) + const footRef = useRef<HTMLDivElement>(null) + + const changed = KEYS.filter((k) => values[k] !== saved[k]).length + const state = changed ? 'dirty' : lastCommit ? 'committed' : 'clean' + const status = changed ? `${changed}${changed === 1 ? ' change' : ' changes'} · not committed yet` : lastCommit ? 'Committed' : 'No changes' + const commitLine = changed ? commitMessage(saved, values) : lastCommit || 'up to date with main' + + const save = () => { + const message = commitMessage(saved, values) + if (!message) return + setLastCommit(message) + setSaved({ ...values }) + setFlashKey(null) + const foot = footRef.current + if (foot) { + foot.classList.remove('just') + void foot.offsetWidth + foot.classList.add('just') + } + toast('In the site editor, Save commits the file to your repository.') + } + + const { journeyRef, step, showPanel } = useJourneyCarousel() + + const prop = (k: Key, v: number, comma: string, del?: boolean) => ( + <> + {' '} + <P>"{k}"</P>: <N className={del ? 'line-through decoration-[color-mix(in_oklab,var(--del-fg)_60%,transparent)]' : undefined}>{v}</N> + {comma} + </> + ) + + return ( + <> + <div + className={`${win} mt-16 rounded-2xl bg-win-bg border-[rgba(255,255,255,.12)] shadow-[0_0_0_1px_rgba(255,255,255,.06),0_50px_120px_-40px_rgba(0,0,0,.6)] max-sm:mt-11 max-sm:rounded-box ${enter}`} + data-pagefind-ignore="" + style={{ '--d': '420ms' } as CSSProperties} + > + <div className={winBarDark}> + <Dots hidden /> + <span className={`${winTitle} max-sm:left-16 max-sm:right-4 max-sm:text-right`}>my-store — Experiments</span> + </div> + <div + className="relative grid grid-cols-4 gap-2.5 p-2.5 max-rail:grid-cols-2 max-md:grid-cols-none max-md:grid-flow-col max-md:auto-cols-[86%] max-md:overflow-x-auto max-md:snap-x max-md:snap-mandatory max-md:scroll-px-2.5 max-md:overscroll-x-contain max-md:scrollbar-none" + role="group" + aria-label="From a TypeScript type to a resolved value" + ref={journeyRef} + > + <div className={pane}> + <div className={head}> + <span className={stepNum}>01</span> + <span className={title}>Type it</span> + <span className={meta}>experiments.ts</span> + </div> + <pre className={preLn}> + <code className="block"> + <L> + <K>export</K> <K>interface</K> <T>Experiments</T> {'{'} + </L> + <L> + {' '} + <Cm> + /** <Ck>@title</Ck> New checkout flow + </Cm> + </L> + <L> + <Cm> + {' * '} + <Ck>@minimum</Ck> 0 <Ck>@maximum</Ck> 100 */ + </Cm> + </L> + <L> + {' '} + <P>newCheckout</P>: <T>number</T>; + </L> + <L> + {' '} + <Cm> + /** <Ck>@title</Ck> Sticky header … */ + </Cm> + </L> + <L> + {' '} + <P>stickyHeader</P>: <T>number</T>; + </L> + <L> + {' '} + <Cm> + /** <Ck>@title</Ck> Free shipping … */ + </Cm> + </L> + <L> + {' '} + <P>freeShippingBanner</P>: <T>number</T>; + </L> + <L>{'}'}</L> + </code> + </pre> + <div className={foot}> + <span className={footMono}>npx deco schema</span> + <span className={`${footText} text-eyebrow`} aria-hidden="true"> + → + </span> + <span className={footMono}>.deco/schema.gen.json</span> + </div> + <Arrow /> + </div> + + <div className={pane}> + <div className={head}> + <span className={stepNum}>02</span> + <span className={title}>Edit it</span> + <span className={meta}>Site editor</span> + </div> + <div className="flex-1 flex flex-col bg-st-bg text-st-fg rounded-b-[11px]"> + <div className="flex items-center justify-between gap-2 h-[42px] px-3.5 border-b border-st-border"> + <span className={`inline-flex items-center gap-2 text-13 leading-5 font-medium ${symbol}`}> + <BrandSymbol /> + Experiments + </span> + <span className="font-mono text-11 leading-4 font-normal text-st-muted px-2 py-0.5 rounded-full bg-st-field border border-st-border"> + experiments + </span> + </div> + {KEYS.map((k, i) => ( + <div className={i ? 'mt-2 pt-3 px-3.5 pb-0.5 border-t border-st-border' : 'pt-2.5 px-3.5 pb-0.5'} key={k}> + <div className="flex items-center justify-between gap-2"> + <label className="text-12 leading-4 text-st-muted" htmlFor={`exp-${k}`}> + {FIELDS[k]} + </label> + <output + className="min-w-[38px] h-[22px] inline-grid place-items-center px-2 rounded-full bg-tint font-mono text-11 leading-4 font-medium tabular-nums text-tint-fg" + id={`out-${k}`} + htmlFor={`exp-${k}`} + > + {values[k]} + </output> + </div> + <input + type="range" + className="home-range block w-full h-5 mt-1.5 bg-transparent cursor-pointer focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 focus-visible:rounded-sm" + id={`exp-${k}`} + min={0} + max={100} + value={values[k]} + data-exp={k} + style={{ '--val': `${values[k]}%` } as CSSProperties} + onChange={(e) => { + setValues((v) => ({ ...v, [k]: Number(e.target.value) })) + setFlashKey(k) + }} + /> + </div> + ))} + <div className="mt-auto flex items-center justify-between gap-2 px-3.5 py-2.5 border-t border-st-border"> + <span className="text-11 leading-4 text-st-muted">0–100, from the JSDoc tags</span> + <button + type="button" + className="h-[30px] min-w-16 px-4 border-0 rounded-full bg-brand text-brand-ink text-12.5 font-medium transition-[background-color,color,scale] focus-visible:outline-ring enabled:hover:bg-brand-hover enabled:active:scale-[.96] disabled:bg-st-field disabled:text-st-muted disabled:shadow-[inset_0_0_0_1px_var(--st-border)] disabled:cursor-default" + id="studio-save" + disabled={!changed} + onClick={save} + > + {changed ? 'Save' : 'Saved'} + </button> + </div> + </div> + <Arrow /> + </div> + + <div className={pane}> + <div className={head}> + <span className={stepNum}>03</span> + <span className={title}>Commit it</span> + <span className={meta} title=".deco/blocks/Experiments.json"> + <span className="rail:hidden max-xs:hidden">.deco/blocks/</span>Experiments.json + </span> + </div> + <div className="flex flex-wrap items-center gap-1.5 pt-2.5 px-3.5 text-11 leading-4 text-muted-fg"> + <span className={source}> + <i className="size-1.5 rounded-full bg-purple" aria-hidden="true" /> + Saved in the site editor + </span> + <span>or</span> + <span className={source}> + <i className="size-1.5 rounded-full bg-yellow" aria-hidden="true" /> + Edited by an agent + </span> + </div> + <pre className={`${pre} pt-2.5 pl-3 pr-2.5`} aria-label="Diff of .deco/blocks/Experiments.json"> + <code className="block" id="exp-json"> + <span className={dl}> + <span className={`${gut} text-faint`}> </span> + {'{'} + </span> + <span className={dl}> + <span className={`${gut} text-faint`}> </span> + {' '} + <P>"__resolveType"</P>: <S>"experiments"</S>, + </span> + {KEYS.map((k, i) => { + const comma = i < KEYS.length - 1 ? ',' : '' + if (values[k] === saved[k]) + return ( + <span className={dl} key={k}> + <span className={`${gut} text-faint`}> </span> + {prop(k, values[k], comma)} + </span> + ) + return ( + <Fragment key={k}> + <span className={`${dl} bg-del-bg`}> + <span className={`${gut} text-del-fg`}>-</span> + {prop(k, saved[k], comma, true)} + </span> + {/* Keyed by value so each change remounts the line and the flash replays. */} + <span + className={`${dl} bg-add-bg shadow-[inset_2px_0_0_var(--olive-ring)]${k === flashKey ? ' animate-flash' : ''}`} + key={`${k}-${values[k]}`} + > + <span className={`${gut} text-eyebrow`}>+</span> + {prop(k, values[k], comma)} + </span> + </Fragment> + ) + })} + <span className={dl}> + <span className={`${gut} text-faint`}> </span> + {'}'} + </span> + </code> + </pre> + {/* The commit status: data-state (dirty | committed | clean) switches its children; save() + toggles `.just` here (the className stays constant, so React never drops it). */} + <div className={`group ${foot} [&.just]:animate-commit-in`} id="exp-foot" data-state={state} aria-live="polite" ref={footRef}> + <span + className={`${footText} size-[7px] rounded-full bg-yellow shadow-[0_0_0_3px_rgba(255,193,22,.2)] flex-none group-data-[state=committed]:hidden group-data-[state=clean]:bg-faint group-data-[state=clean]:shadow-none`} + aria-hidden="true" + /> + <Icon name="check" className={`${footIcon} hidden group-data-[state=committed]:block`} /> + <span + className={`${footText} flex-none text-fg font-medium group-data-[state=committed]:absolute group-data-[state=committed]:size-px group-data-[state=committed]:[clip-path:inset(50%)]`} + id="exp-status" + > + {status} + </span> + <span + className={`${footMono} hidden group-data-[state=committed]:block group-data-[state=committed]:min-w-0 group-data-[state=committed]:text-11 group-data-[state=committed]:text-fg`} + id="exp-commit" + > + {commitLine} + </span> + </div> + <Arrow /> + </div> + + <div className={pane}> + <div className={head}> + <span className={stepNum}>04</span> + <span className={title}>Resolve it</span> + <span className={meta}>checkout.ts</span> + </div> + <pre className={preLn}> + <code className="block"> + <L> + <K>import</K> {'{ createCMS } '} + <K>from</K> + </L> + <L> + {' '} + <S>"@decocms/blocks"</S>; + </L> + <L> + <K>import</K> blocks <K>from</K> + </L> + <L> + {' '} + <S>"./.deco"</S>; + </L> + <L> + <K>import</K> content <K>from</K> + </L> + <L> + {' '} + <S>"./.deco/blocks.gen"</S>; + </L> + <L> + <K>const</K> cms = <F>createCMS</F>({'{'} + </L> + <L>{' blocks,'}</L> + <L>{' content,'}</L> + <L>{'});'}</L> + <L> + <K>const</K> [flags, error] = <K>await</K> cms + </L> + <L> + {' .'} + <F>forRelease</F>() + </L> + <L> + {' .'} + <F>resolve</F>(<S>"Experiments"</S>); + </L> + <L> + <K>if</K> (error) <K>throw</K> error; + </L> + <L> + <Cm> + // true on ~<span id="exp-odds">{values.newCheckout}</span>% of calls + </Cm> + </L> + </code> + </pre> + <div className={foot}> + <Icon name="check" className={footIcon} /> + <span className={footText}>Same result as calling experiments() directly</span> + </div> + </div> + </div> + </div> + <JourneyPills steps={STEPS} step={step} onSelect={showPanel} /> + <div className={`flex items-baseline justify-between gap-6 mt-7 max-md:block max-md:mt-5 ${enter}`} style={{ '--d': '520ms' } as CSSProperties}> + <p className="m-0 max-w-[78ch] text-14 leading-5.5 text-band-muted"> + The Quickstart, end to end. The site editor saves it, or an agent edits it: either way it's a commit, and the function never changes.{' '} + <span className="text-white font-medium">Drag a slider, then Save.</span> + </p> + <MdxLink className={`${textLink} text-brand max-md:mt-3.5`} href="/next/quickstart"> + Follow the quickstart + <Icon name="arrow-right" className={textLinkIcon} /> + </MdxLink> + </div> + </> + ) +} diff --git a/docs/components/home/Sections.tsx b/docs/components/home/Sections.tsx new file mode 100644 index 00000000..bfb3735e --- /dev/null +++ b/docs/components/home/Sections.tsx @@ -0,0 +1,589 @@ +/** + * The landing's bands under the hero: stack strip, "Every change is a commit" cards, the + * stability band, publishing timeline, and "Small on purpose". (The stepper is Stepper.tsx; the + * final CTA and footer are Footer.tsx; shared pieces are in ui.tsx.) + */ +import type { CSSProperties, ReactNode } from 'react' +import { Icon, Mark, type MarkName } from '~/components/ui/Icon' +import { MdxLink } from '~/components/mdx/MdxLink' +import { Cobogo } from './Hero' +import { + container, + d, + devLink, + devLinkIcon, + Dim, + Dots, + H2, + Kicker, + Lede, + lsec, + mono, + reveal, + section, + sectionHead, + textLink, + textLinkIcon, + win, + winBar, + winBarSmall, + winPaper, + winTitle, + winTitleSmall, +} from './ui' + +const STACK: [MarkName, string][] = [ + ['nextjs', 'Next.js'], + ['tanstack', 'TanStack Start'], + ['cloudflare', 'Cloudflare Workers'], + ['react', 'React Native'], + ['node', 'Node.js'], + ['git', 'Any Git repo'], +] + +/** A grid of cards separated by 1px hairlines (the gap shows the border colour behind them). */ +export const hairlineGrid = 'grid gap-px overflow-hidden border border-border rounded-2xl bg-border' + +export function StackStrip() { + return ( + <div> + <div className={`${container} pt-18 pb-20 max-sm:py-14`}> + <Kicker className={`text-center ${reveal}`}>Runs on your stack</Kicker> + <ul className={`${hairlineGrid} list-none mt-8 p-0 grid-cols-6 max-xl:grid-cols-3 max-md:grid-cols-2 ${reveal}`} style={d('80ms')} aria-label="Runtimes"> + {STACK.map(([mark, label]) => ( + <li + className="h-24 flex items-center justify-center gap-2.5 px-3 bg-bg text-15 font-medium tracking-snug text-stack-fg text-center transition-colors duration-300 hover:bg-stack-hover hover:text-tint-fg max-md:h-20 max-md:text-14" + key={mark} + > + <Mark name={mark} className="size-[18px] flex-none" /> + {label} + </li> + ))} + </ul> + </div> + </div> + ) +} + +/* "Every change is a commit": the three cards' small window visuals. */ +export const ciVisual = 'relative h-[212px] border-hairline rounded-xl overflow-hidden max-home-xl:row-span-3 max-sm:h-[204px]' +export const ciWin = `${win} ${ciVisual}` +export const card = 'min-w-0 pt-6 px-7 pb-9 bg-bg max-home-xl:grid max-home-xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] max-home-xl:gap-x-8 max-home-xl:items-start max-md:block max-sm:pt-4 max-sm:px-4 max-sm:pb-7' +export const cardNum = 'block mt-[30px] text-13 leading-4 tabular-nums text-eyebrow max-home-xl:mt-1 max-md:mt-[26px]' +export const cardH3 = 'mt-2.5 mb-2.5 text-22 leading-tight font-normal tracking-heading' +export const cardP = 'm-0 text-15 leading-[1.6] text-muted-fg' +export const mfRow = 'grid grid-cols-[64px_minmax(0,1fr)] items-center gap-3 py-[9px]' +export const mfLabel = 'text-11.5 leading-4 text-muted-fg' +export const mfInput = 'h-8 flex items-center px-3 border rounded-full bg-surface text-fg min-w-0 whitespace-nowrap overflow-hidden' +export const chip = 'h-[26px] inline-flex items-center px-2.5 rounded-full text-12 leading-4 font-normal' +const aeLine = 'block py-0.5 px-3 text-12 leading-5 whitespace-pre border-0 rounded-none' +const aeIcon = 'size-[13px] text-eyebrow' + +export function ContentModel() { + return ( + <div className={lsec}> + <div className={`${container} ${section}`}> + <div className={sectionHead}> + <Kicker className={reveal}>One content model</Kicker> + <H2 className={reveal} style={d('60ms')}> + Every change is a commit, <Dim>whoever makes it.</Dim> + </H2> + <Lede className={reveal} style={d('120ms')}> + Launch a landing page at <code>/summer-sale</code>, schedule a campaign banner, ramp an experiment to 25%: all from a form, all + reviewable, none of it a code change. + </Lede> + </div> + <div className={`${hairlineGrid} grid-cols-3 max-home-xl:grid-cols-1 ${reveal}`} style={d('160ms')}> + <div className={card}> + <div className={`${ciWin} bg-code-bg`} aria-hidden="true" data-pagefind-ignore=""> + <div className={winBarSmall}> + <Dots small /> + <span className={winTitleSmall}>.deco/index.ts</span> + </div> + <pre className="p-4 text-12 leading-[21px]"> + <code> + <span className="text-syn-keyword">export default</span> + {' {\n '} + <span className="text-syn-property">experiments</span> + {', '} + <span className="text-syn-comment italic">// returns A/B flags</span> + {'\n '} + <span className="text-syn-property">hero</span> + {', '} + <span className="text-syn-comment italic">// returns JSX</span> + {'\n '} + <span className="text-syn-property">seo</span> + {', '} + <span className="text-syn-comment italic">// returns page metadata</span> + {'\n} '} + <span className="text-syn-keyword">satisfies</span> <span className="text-syn-type">Blocks</span>; + </code> + </pre> + </div> + <span className={cardNum}>01</span> + <h3 className={cardH3}>Headless for developers</h3> + <p className={cardP}> + Write a function, type its inputs, and add it to your block map. Content can now call it. Deco CMS stays out of your stack: + no database and no content API to run, just JSON files in your repository and a small SDK that works with Next.js, TanStack + Start, or any JavaScript server. + </p> + </div> + <div className={card}> + <div className={`${ciWin} bg-surface`} aria-hidden="true" data-pagefind-ignore=""> + <div className={winBarSmall}> + <Dots small /> + <span className={winTitleSmall}>Site editor — Summer campaign</span> + </div> + <div className="py-1 px-3.5"> + <div className={mfRow}> + <span className={mfLabel}>Name</span> + <span className={`${mfInput} border-border text-13`}>Summer campaign</span> + </div> + <div className={`${mfRow} border-t border-hairline`}> + <span className={mfLabel}>Path</span> + <span + className={`${mfInput} ${mono} text-12.5 border-olive-ring shadow-[0_0_0_3px_color-mix(in_oklab,var(--brand)_40%,transparent)]`} + > + /summer-sale + <span className="w-px h-4 ml-0.5 bg-fg animate-blink" /> + </span> + </div> + <div className={`${mfRow} border-t border-hairline`}> + <span className={mfLabel}>Blocks</span> + <span className="flex gap-1.5 flex-wrap"> + <span className={`${chip} bg-tint font-mono text-tint-fg`}>hero</span> + <span className={`${chip} bg-tint font-mono text-tint-fg`}>product</span> + <span className={`${chip} bg-transparent border border-dashed border-border-strong text-muted-fg font-sans`}>+ Add</span> + </span> + </div> + </div> + </div> + <span className={cardNum}>02</span> + <h3 className={cardH3}>Editable for humans</h3> + <p className={cardP}> + One command turns your types into forms, and the site editor puts them in front of editors, on your machine or hosted. Developers build the + blocks once, editors use them to change pages, campaigns, and settings, and every change is a commit you can review and roll back. + </p> + </div> + <div className={card}> + <div + className={`${ciVisual} border bg-bg-warm p-4 flex flex-col justify-center gap-2.5 max-sm:py-3 max-sm:px-3.5 max-sm:gap-2`} + aria-hidden="true" + data-pagefind-ignore="" + > + <div className="self-end max-w-[88%] pt-2 px-3.5 pb-[9px] rounded-[14px_14px_4px_14px] bg-hover text-fg text-13 leading-[19px]"> + <span className="block text-10.5 leading-3.5 tracking-label uppercase text-who-fg">You</span>Ramp the new checkout to 25% + </div> + <div className="flex-none border border-hairline rounded-xl bg-surface overflow-hidden"> + <div className="flex items-center gap-2 h-8 px-3 border-b border-hairline text-12 leading-4 text-muted-fg whitespace-nowrap overflow-hidden"> + <Icon name="sparkle" className={aeIcon} /> + <span className="min-w-0 overflow-hidden text-ellipsis">Edited .deco/blocks/Experiments.json</span> + </div> + <code className={`${aeLine} bg-del-bg text-del-fg`}>- "newCheckout": 10,</code> + <code className={`${aeLine} bg-add-bg text-fg shadow-[inset_2px_0_0_var(--olive-ring)]`}>+ "newCheckout": 25,</code> + <div className="flex items-center gap-1.5 h-[30px] px-3 border-t border-hairline text-11.5 leading-4 text-muted-fg whitespace-nowrap overflow-hidden"> + <Icon name="check" className={aeIcon} /> + <span> + Valid against <b className="font-mono text-11 leading-4 font-normal text-fg">.deco/schema.gen.json</b> + </span> + </div> + </div> + </div> + <span className={cardNum}>03</span> + <h3 className={cardH3}>Native for AI</h3> + <p className={cardP}> + Content is typed JSON in Git, so coding agents read, edit, and validate it with the tools they already have. Their edits arrive + as ordinary commits your team reviews like anyone else's, and the same types that build the editor keep them honest. + </p> + </div> + </div> + </div> + </div> + ) +} + +/* The stability band's status window. */ +const pkgTile = 'min-w-0 grid content-start gap-px pt-3 px-3 pb-[13px] border rounded-xl max-home-sm:pt-2.5 max-home-sm:px-2.5 max-home-sm:pb-[11px]' +const pkgStrong = 'text-14.5 leading-5 font-medium tracking-ui max-home-sm:text-13.5' +const pkgSmall = 'text-11.5 leading-[17px] max-home-sm:leading-4' +export const stRow = 'flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 min-h-[52px] py-2.5 px-0.5' +export const stKey = 'text-14 leading-5 text-fg' +export const stPill = 'inline-flex items-center gap-[7px] h-7 pl-2.5 pr-3 rounded-full text-12.5 leading-4 font-medium whitespace-nowrap' +export const stIcon = 'size-[13px]' + +export function Stability() { + return ( + <div className={lsec}> + <div className="mx-auto w-full max-w-landing px-10 py-8 max-sm:px-2 max-sm:py-4"> + <div className="relative isolate overflow-hidden pt-22 px-18 pb-20 rounded-3xl text-band-fg [background-image:radial-gradient(700px_500px_at_85%_10%,rgba(30,110,55,.5),transparent_60%),linear-gradient(162deg,#145528_0%,#0C4420_50%,#07301A_100%)] max-xl:py-18 max-xl:px-12 max-sm:pt-14 max-sm:px-5 max-sm:pb-12 max-sm:rounded-2xl [&_:where(:focus-visible)]:outline-lime"> + <Cobogo className="top-0 h-full [mask-image:linear-gradient(to_right,transparent_0%,transparent_62%,#000_92%)]" /> + <div className="relative max-w-[900px] mb-[52px]"> + <Kicker band className={reveal}> + Built for stability + </Kicker> + <H2 band className={reveal} style={d('60ms')}> + Your site ships with its content. <Dim band className="min-home-sm:block">Nothing stands between it and your visitors.</Dim> + </H2> + <Lede band className={reveal} style={d('120ms')}> + Your pages, banners and settings are files in your own repository, and every deploy carries them alongside the code. Your site + always has the content it needs at hand, so a page never waits on a content server. + </Lede> + </div> + <div className="relative grid grid-cols-[minmax(0,1fr)_minmax(0,1.04fr)] grid-rows-[auto_1fr] gap-x-16 items-start max-xl:gap-x-12 max-nav:grid-cols-1 max-nav:grid-rows-none max-nav:gap-y-10"> + <div + className={`${win} bg-surface col-[2] row-[1/span_2] rounded-2xl border-[rgba(255,255,255,.14)] shadow-[0_0_0_1px_rgba(0,0,0,.05),0_44px_90px_-34px_rgba(0,0,0,.6)] max-nav:col-[1] max-nav:row-auto ${reveal}`} + style={d('160ms')} + role="group" + aria-labelledby="status-title" + data-pagefind-ignore="" + > + <div className={winBar}> + <Dots hidden /> + <span className={winTitle} id="status-title"> + store.example.com — status + </span> + </div> + <div className="pt-[26px] px-[22px] pb-2 max-home-sm:pt-6 max-home-sm:px-3.5 max-home-sm:pb-1.5"> + <div className="relative pt-5 px-3.5 pb-3.5 border-[1.5px] border-dashed border-border-strong rounded-box max-home-sm:px-2.5 max-home-sm:pb-3"> + <p className="absolute -top-2.5 left-3 m-0 px-2 bg-surface text-12 leading-[18px] tracking-pill uppercase text-eyebrow"> + Every deploy ships + </p> + <div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-stretch gap-2 max-home-sm:gap-1.5"> + <div className={`${pkgTile} border-border bg-bg-subtle`}> + <Icon name="code" className="size-4 mb-2 text-muted-fg" /> + <strong className={`${pkgStrong} text-fg`}>Your code</strong> + <small className={`${pkgSmall} text-muted-fg`}>Design, features</small> + </div> + <span + className="self-center size-[22px] grid place-items-center rounded-full border border-border bg-surface text-14 leading-none text-muted-fg" + aria-hidden="true" + > + + + </span> + <div className={`${pkgTile} border-[color-mix(in_oklab,var(--olive-ring)_40%,transparent)] bg-tint`}> + <Icon name="file" className="size-4 mb-2 text-tint-fg" /> + <strong className={`${pkgStrong} text-tint-fg`}>Your content</strong> + <small className={`${pkgSmall} text-[color-mix(in_oklab,var(--tint-fg)_78%,transparent)]`}>Pages, banners, settings</small> + </div> + </div> + <p className="mt-3 text-center text-12.5 leading-[18px] text-muted-fg">Packed together, tested together.</p> + </div> + <ul className="list-none mt-3.5 p-0" role="list"> + <li className={stRow}> + <span className={stKey}>Your site</span> + <span className={`${stPill} bg-pill-on-bg text-pill-on-fg shadow-[inset_0_0_0_1px_var(--pill-on-ring)]`}> + <i className="size-[7px] rounded-full flex-none bg-lime shadow-[0_0_0_3px_rgba(208,236,26,.22)]" aria-hidden="true" /> + Online + </span> + </li> + <li className={`${stRow} border-t border-hairline`}> + <span className={stKey}>Content</span> + <span className={`${stPill} bg-tint text-tint-fg`}> + <Icon name="check" strokeWidth={2.25} className={stIcon} /> + Inside this deploy + </span> + </li> + <li className={`${stRow} border-t border-hairline`}> + <span className={stKey}>Page loads</span> + <span className={`${stPill} bg-tint text-tint-fg`}> + <Icon name="check" strokeWidth={2.25} className={stIcon} /> + No extra round trip + </span> + </li> + </ul> + </div> + </div> + <ul className={`col-[1] row-[1] list-none m-0 p-0 border-b border-band-line max-nav:row-auto ${reveal}`} style={d('200ms')} role="list"> + <Point title="No content servers to run">No content database or content server to host, scale or keep online.</Point> + <Point title="Fast on every page"> + Every page is built from content your site already holds, so it loads as fast as your own code. + </Point> + <Point title="Stable by design"> + Your site depends on no other service to show its content. Every deploy carries a tested copy of it as the fallback, so what + visitors see is always a version you shipped or published. + </Point> + </ul> + <MdxLink className={`${devLink} text-lime col-[1] row-[2] max-nav:row-auto max-nav:-mt-1 ${reveal}`} href="/next/releases-and-deployment"> + <span className="font-normal text-band-muted">For developers:</span> how content{' '} + <span className="whitespace-nowrap"> + loads + <Icon name="arrow-right" className={devLinkIcon} /> + </span> + </MdxLink> + </div> + </div> + </div> + </div> + ) +} + +export function Point({ title, children }: { title: string; children: ReactNode }) { + return ( + <li className="grid grid-cols-[32px_minmax(0,1fr)] pt-[18px] pb-[19px] border-t border-band-line max-home-sm:grid-cols-[28px_minmax(0,1fr)]"> + <Icon name="check" strokeWidth={2.25} className="size-4 mt-[3px] text-lime" /> + <span className="grid gap-1"> + <strong className="text-16 leading-5.5 font-medium tracking-ui text-white">{title}</strong> + <span className="text-14.5 leading-[1.55] text-band-muted">{children}</span> + </span> + </li> + ) +} + +/* The publishing timeline: a 6-column row of step pills per lane, each pill placed in column + `--c`, linked to the previous one by a dashed connector (::before) and a chevron (::after) that + span the `--gap`. Below 1000px the lanes sit side by side and each becomes a vertical list. */ +const gap = '[--gap:28px] max-home-lg:[--gap:20px] max-home:[--gap:16px]' +export const laneGrid = `${gap} grid grid-cols-6 gap-x-(--gap) max-home:grid-cols-1 max-home:gap-x-0` +export const stepBox = + 'relative min-w-0 flex items-center justify-center border rounded-full text-14 leading-[18px] font-medium tracking-ui text-center max-home-lg:text-13.5 max-home:col-[1] max-home:w-full max-home:max-w-[240px] max-home:justify-self-center max-home-sm:text-13' +export const step = `${stepBox} col-(--c) row-[1] h-10 px-2.5 whitespace-nowrap max-home-lg:px-2 max-home:row-(--c) max-home:h-[38px] max-home:text-13.5 max-home-sm:px-1.5` +export const stepPlain = 'border-border-strong bg-surface text-fg' +export const stepSkip = 'border-dashed border-border-strong bg-transparent text-muted-fg line-through decoration-1' +/** The link from the previous pill (dashed line + chevron, in src/styles/components/home.css). */ +export const linked = 'tl-linked' +/** …across a skipped column (Edit → Commit). */ +export const linkedFar = 'tl-linked tl-far' +export const lane = 'pt-[26px] px-7 pb-5 max-home-lg:pt-6 max-home-lg:px-[22px] max-home-lg:pb-[18px] max-home:grid max-home:row-span-3 max-home:grid-rows-subgrid max-home:content-start max-home:pt-5 max-home:px-4 max-home:pb-4 max-home-sm:pt-[18px] max-home-sm:px-2.5 max-home-sm:pb-3.5' +const label = + 'flex flex-wrap items-center gap-x-1.5 gap-y-1 mb-6 text-13.5 leading-5 text-muted-fg max-home:items-start max-home:content-start max-home:mb-5 max-home:text-13 max-home-sm:text-12.5 max-home-sm:leading-[18px]' +export const notes = `${laneGrid} mt-2.5 min-h-[30px] text-12.5 leading-[18px] text-muted-fg max-home:mt-3 max-home:min-h-0` +export const bracket = + 'relative col-[4/6] pt-3 text-center before:absolute before:top-0 before:left-[18%] before:right-[18%] before:h-[7px] before:border before:border-t-0 before:border-border-strong before:rounded-b-[7px] max-home:hidden' +export const cap = 'col-[6] justify-self-center pt-3 text-center whitespace-nowrap max-home:col-[1] max-home:pt-0 max-home:whitespace-normal' + +export function LaneLabel({ title, sub }: { title: string; sub: string }) { + return ( + <p className={label}> + <b className="font-medium text-fg">{title}</b> + <span className="max-home:hidden">·</span> + <span className="max-home:basis-full">{sub}</span> + </p> + ) +} + +export function Publishing() { + return ( + <div className={lsec}> + <div className={`${container} ${section}`}> + <div className={sectionHead}> + <Kicker className={reveal}>Hosted Deco CMS · optional</Kicker> + <H2 className={reveal} style={d('60ms')}> + Publish in seconds, <Dim>not on the next deploy.</Dim> + </H2> + <Lede className={reveal} style={d('120ms')}> + The hosted Deco CMS adds the site editor on GitHub and publishing without a redeploy: each change reaches your servers within + seconds, and editors preview drafts on your real pages first. Without it, people and agents edit the files, by hand or in the site editor on + your machine, and content goes live with your next deploy. + </Lede> + </div> + <div className={`${winPaper} rounded-2xl ${reveal}`} style={d('160ms')} aria-hidden="true" data-pagefind-ignore=""> + <div className={winBar}> + <Dots /> + <span className={`${winTitle} max-home-sm:left-16 max-home-sm:right-3.5 max-home-sm:text-right`}>Publishing a change — store.example.com</span> + </div> + <div className="max-home:grid max-home:grid-cols-2 max-home:grid-rows-[auto_auto_auto]"> + <div className={lane}> + <LaneLabel title="Without it" sub="content ships with your next deploy" /> + <ol className={`${laneGrid} list-none m-0 p-0 max-home:grid-rows-[repeat(6,38px)] max-home:gap-y-(--gap) max-home:justify-items-stretch`}> + <li style={c(1)} className={`${step} ${stepPlain}`}> + Edit + </li> + <li style={c(3)} className={`${step} ${stepPlain} ${linkedFar}`}> + Commit + </li> + <li style={c(4)} className={`${step} ${stepPlain} ${linked}`}> + Build + </li> + <li style={c(5)} className={`${step} ${stepPlain} ${linked}`}> + Deploy + </li> + <li style={c(6)} className={`${step} ${stepPlain} ${linked}`}> + Live + </li> + </ol> + <div className={notes}> + <span className={bracket}>code release</span> + <span className={cap}>at your next deploy</span> + </div> + </div> + <div + className={`${lane} border-t border-hairline bg-[color-mix(in_oklab,var(--tint)_70%,var(--surface))] max-home:border-t-0 max-home:border-l`} + > + <LaneLabel title="With the hosted Deco CMS" sub="each publish is a commit" /> + <ol className={`${laneGrid} list-none m-0 p-0 max-home:grid-rows-[repeat(6,38px)] max-home:gap-y-(--gap) max-home:justify-items-stretch`}> + <li style={c(1)} className={`${step} ${stepPlain}`}> + Edit + </li> + <li style={c(2)} className={`${step} ${stepPlain} ${linked}`}> + Preview + <span className="absolute left-1/2 bottom-[calc(100%-9px)] -translate-x-1/2 inline-flex items-center gap-[5px] h-5 px-2 rounded-full bg-pill-on-bg shadow-[inset_0_0_0_1px_var(--pill-on-ring)] text-10.5 leading-3.5 font-medium tracking-normal text-pill-on-fg whitespace-nowrap max-home:hidden"> + <i className="size-[5px] rounded-full bg-lime" /> + Previewing a draft + </span> + </li> + <li style={c(3)} className={`${step} ${stepPlain} ${linked}`}> + Publish + </li> + <li style={c(4)} className={`${step} ${stepSkip} ${linked} max-home:hidden`}> + Build + </li> + <li style={c(5)} className={`${step} ${stepSkip} ${linked} max-home:hidden`}> + Deploy + </li> + {/* Below 1000px, Build and Deploy collapse into this one pill spanning their rows. */} + <li + className={`${stepBox} ${stepSkip} ${linked} hidden max-home:flex max-home:row-[4/6] max-home:self-stretch px-2.5 max-home-lg:px-2 max-home:text-13 max-home:leading-[17px] max-home:no-underline max-home:rounded-[18px] max-home:text-fg max-home-sm:px-1.5`} + > + No build · no deploy + </li> + <li style={c(6)} className={`${step} ${linked} border-transparent bg-pub-live-bg text-pub-live-fg shadow-[0_6px_18px_-8px_rgba(7,64,26,.55)]`}> + Live everywhere + </li> + </ol> + <div className={notes}> + <span className={`${bracket} before:border-dashed`}>not needed</span> + <span className={`${cap} font-medium text-tint-fg`}>in seconds</span> + </div> + </div> + </div> + </div> + <ul className={`${hairlineGrid} list-none mt-7 p-0 grid-cols-3 max-home-lg:grid-cols-2 max-home-sm:grid-cols-1 ${reveal}`} style={d('200ms')} role="list"> + <PubPoint title="Not tied to code releases"> + Content needs no build and no deploy, so editors publish when a campaign is ready, not when the next code release goes out. + </PubPoint> + <PubPoint title="Preview on the real site"> + Editors open drafts on your actual pages before anything goes live, and only people the site editor lets in can see them. + </PubPoint> + <PubPoint title="Undo any change"> + Every publish is saved in your repository's history, with who changed what and when, so going back is one step. + </PubPoint> + </ul> + <MdxLink className={`${devLink} text-link ${reveal}`} href="/next/releases-and-deployment"> + <span className="font-normal text-muted-fg">For developers:</span> publishing and{' '} + <span className="whitespace-nowrap"> + rollback + <Icon name="arrow-right" className={devLinkIcon} /> + </span> + </MdxLink> + </div> + </div> + ) +} + +export function PubPoint({ title, children }: { title: string; children: ReactNode }) { + return ( + <li className="min-w-0 flex flex-col pt-6 px-[26px] pb-[30px] bg-bg max-home-lg:last:odd:col-span-full max-home-sm:pt-[18px] max-home-sm:px-4 max-home-sm:pb-6"> + <strong className="mb-2 text-19 leading-[1.3] font-normal tracking-[-.012em] text-fg max-home-sm:text-18">{title}</strong> + <span className="text-14.5 leading-[1.6] text-muted-fg">{children}</span> + </li> + ) +} + +export const c = (n: number) => ({ '--c': n }) as CSSProperties + +const HOOD_LINKS: { href: string; title: string; text: string; read: string }[] = [ + { + href: '/next/blocks', + title: 'Nothing to export', + text: 'Content is plain, documented files you already hold: open them in any editor, search them, or generate them with a script.', + read: 'Read: How a page is built from content', + }, + { + href: '/next/how-resolution-works', + title: 'Small enough to read', + text: 'Content is plain files and features are ordinary code, so any developer or AI agent can follow how a page is made, with the tools they already use.', + read: 'Read: How resolution works', + }, + { + href: '/next/releases-and-deployment#publishing', + title: 'Publish by committing', + text: 'Every change is a commit in your repository, with its history and a one-step revert. Add the hosted Deco CMS and a prepared release reaches your servers on their next background check, without a redeploy. Restore a retained release without rebuilding.', + read: 'Read: Publishing is committing', + }, + { + href: '/next/design-decisions', + title: 'Openly documented', + text: 'How each part works, and why it was built that way, is written down in these docs, and the source is public on GitHub.', + read: 'Read: Design decisions', + }, +] + +const OWN: [string, string, 'yours' | 'opt'][] = [ + ['Content', 'Your Git repository', 'yours'], + ['Code', 'Your Git repository', 'yours'], + ['Hosting', 'Any JavaScript runtime you choose: Node, Cloudflare Workers, Deno, Bun or a React Native app', 'yours'], + ['Telemetry', 'Your OpenTelemetry collector, or the hosted Deco CMS', 'yours'], + ['Analytics', 'One Dollar Stats, your own collector, or the hosted Deco CMS', 'yours'], + ['Site editor on GitHub & publishing without a redeploy', 'Hosted Deco CMS', 'opt'], +] + +export const tag = 'h-6 inline-flex items-center px-[11px] rounded-full text-12 leading-4 font-medium whitespace-nowrap' + +export function SmallOnPurpose() { + return ( + <div className={`${lsec} bg-bg-subtle`}> + <div className={`${container} ${section} grid grid-cols-2 gap-x-20 items-start max-home-lg:gap-x-14 max-nav:grid-cols-1`}> + <div> + <Kicker className={reveal}>Yours to keep</Kicker> + <H2 className={reveal} style={d('60ms')}> + Small on purpose. <Dim>Nothing hidden.</Dim> + </H2> + <Lede className={reveal} style={d('120ms')}> + Deco CMS is a small library and a folder of files in your own repository, not a platform you move into. You can read how the + library works, swap any piece of it, and keep all of your content whatever you decide next. + </Lede> + <div className={`mt-10 ${reveal}`} style={d('160ms')}> + <p className="mb-3 text-14 leading-5 font-medium text-fg" id="own-title"> + Where your site lives + </p> + <dl className={`${hairlineGrid} m-0`} aria-labelledby="own-title"> + {OWN.map(([what, where, kind]) => ( + <div + className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-5 pt-3.5 px-[18px] pb-[15px] bg-bg max-home-sm:pt-[13px] max-home-sm:px-3.5 max-home-sm:pb-3.5 max-home-sm:gap-x-3" + key={what} + > + <dt className="col-[1] text-15 leading-5.5 font-medium tracking-ui text-fg">{what}</dt> + <dd className="col-[1] text-14 leading-5 text-muted-fg">{where}</dd> + <dd className="col-[2] row-[1/span_2]"> + {kind === 'yours' ? ( + <span className={`${tag} bg-tag-yours-bg text-lime`}>Yours</span> + ) : ( + <span className={`${tag} shadow-[inset_0_0_0_1px_var(--border-strong)] text-muted-fg`}>Optional</span> + )} + </dd> + </div> + ))} + </dl> + </div> + </div> + <div className="pt-[46px] max-nav:pt-12"> + <div className={`grid border-b border-hairline ${reveal}`} style={d('120ms')}> + {HOOD_LINKS.map((l) => ( + <MdxLink + className="group relative grid grid-cols-[minmax(0,1fr)_20px] items-center gap-x-4 gap-y-2 pt-[22px] px-1 pb-6 border-t border-hairline no-underline text-muted-fg text-14.5 leading-[1.6] transition-colors duration-300 max-home-sm:pt-[18px] max-home-sm:px-0 max-home-sm:pb-5" + href={l.href} + key={l.href} + > + <span className="col-[1] grid gap-0.5 min-w-0"> + <strong className="text-17 leading-6 font-normal tracking-ui text-fg group-hover:text-link">{l.title}</strong> + <span>{l.text}</span> + <span className="justify-self-start mt-1.5 text-13 leading-5 text-muted-fg transition-colors duration-300 group-hover:text-link"> + {l.read} + </span> + </span> + <Icon + name="arrow-right" + className="size-[18px] text-olive-ring transition-[translate,color] duration-400 ease-out-quart group-hover:translate-x-1 group-hover:text-eyebrow" + /> + </MdxLink> + ))} + </div> + <MdxLink className={`${textLink} text-link mt-8 ${reveal}`} href="/next/internals"> + How it works as a whole + <Icon name="arrow-right" className={textLinkIcon} /> + </MdxLink> + </div> + </div> + </div> + ) +} diff --git a/docs/components/home/Stepper.tsx b/docs/components/home/Stepper.tsx new file mode 100644 index 00000000..82a9887b --- /dev/null +++ b/docs/components/home/Stepper.tsx @@ -0,0 +1,282 @@ +/** + * "Three steps" (#home-how): a vertical tablist (arrows/Home/End move and select) that + * switches the setup window's panes; the window's file tabs select too. + */ +import { useRef, useState, type KeyboardEvent, type ReactNode } from 'react' +import { Icon } from '~/components/ui/Icon' +import { BrandSymbol } from '~/components/ui/Brand' +import { container, d, Dim, Dots, H2, Kicker, Lede, lsec, reveal, section, symbol, winBar, winPaper, winTitle } from './ui' +import { STEP1_HTML, STEP3_HTML } from './tokens' + +const STEPS = [ + { + title: 'Write a function', + text: 'Write a function, type its inputs, and add it to your block map. Content can now call it: a hero banner, a product feed, an A/B test split. Nothing to learn, nothing to install in the function itself.', + }, + { + title: 'It becomes editable', + text: "One command turns the function's types into a form, the form editors use in the site editor, so marketing can change copy, images and campaigns whenever they need to.", + }, + { + title: 'Ship from Git', + text: 'Every change is a commit you can review like code. Add the hosted Deco CMS to preview drafts on your real pages and publish without a deploy.', + }, +] + +const FILE_TABS = [ + { icon: 'file' as const, label: '.deco/index.ts' }, + { icon: 'terminal' as const, label: 'Terminal' }, + { icon: 'file' as const, label: 'cms.ts' }, +] + +const FORM_ROWS: [string, number][] = [ + ['New checkout flow', 10], + ['Sticky header', 50], + ['Free shipping banner', 0], +] + +/** The pane's code, as the old page rendered it (frozen Prism markup; see tokens.tsx). */ +export function Code({ html }: { html: string }) { + return ( + <pre + data-lang="TypeScript" + data-label="TypeScript" + className="language-typescript flex-1 m-0 py-[22px] px-6 overflow-auto text-13 leading-5.5 bg-code-bg focus-visible:outline-offset-[-2px] max-sm:p-4 max-sm:text-12 max-sm:leading-5" + tabIndex={0} + > + <code className="language-typescript" dangerouslySetInnerHTML={{ __html: html }} /> + </pre> + ) +} + +export const fileTab = + 'inline-flex items-center gap-1.5 h-[30px] px-[13px] border rounded-full font-mono text-12 leading-4 font-normal whitespace-nowrap cursor-pointer transition-[color,background-color,border-color] max-xs:px-[11px]' +const fileTabOff = `${fileTab} border-border text-muted-fg hover:text-fg hover:border-olive-ring` +const fileTabOn = `${fileTab} pill-on` +const paneOff = + 'col-start-1 row-start-1 min-w-0 flex flex-col invisible opacity-0 translate-y-1.5 [transition:opacity_.3s_ease,translate_.4s_var(--ease-out-quart),visibility_0s_linear_.3s]' +const paneOn = 'col-start-1 row-start-1 min-w-0 flex flex-col visible opacity-100 [transition:opacity_.45s_ease,translate_.6s_var(--ease-out-expo)]' + +export function Result({ children }: { children: ReactNode }) { + return ( + <p className="flex items-center gap-2.5 mt-auto py-3.5 px-5 border-t border-hairline bg-tint text-14 leading-5 text-tint-fg flex-none max-sm:py-3 max-sm:px-4 max-sm:text-13.5 [&_code]:text-[.86em] [&_code]:bg-result-code-bg"> + <Icon name="check" strokeWidth={2.5} className="size-5 p-1 rounded-full bg-result-icon-bg text-result-icon-fg flex-none" /> + {children} + </p> + ) +} + +export interface StepperStep { + title: string + text: ReactNode +} + +/** + * The stepper's frame, shared by both homes: kicker, sticky heading and lede, the vertical tablist, + * and the setup window with its file tabs. `panes` are the window's panes, one per step (wrapped + * here in the tabpanels). + */ +export function StepperShell({ + kicker, + title, + lede, + steps, + fileTabs, + windowTitle, + panes, + titleClassName = 'max-w-[15ch] max-lg:max-w-none', +}: { + kicker: ReactNode + title: ReactNode + lede: ReactNode + steps: StepperStep[] + fileTabs: { icon: 'file' | 'terminal' | 'eye'; label: string }[] + windowTitle: string + panes: ReactNode[] + titleClassName?: string +}) { + const [step, setStep] = useState(1) + const tabs = useRef<(HTMLButtonElement | null)[]>([]) + + const select = (n: number, focus?: boolean) => { + setStep(n) + if (focus) tabs.current[n - 1]?.focus() + } + const onKey = (i: number) => (event: KeyboardEvent) => { + const k = event.key + const len = steps.length + let to: number | null = null + if (k === 'ArrowDown' || k === 'ArrowRight') to = (i + 1) % len + else if (k === 'ArrowUp' || k === 'ArrowLeft') to = (i - 1 + len) % len + else if (k === 'Home') to = 0 + else if (k === 'End') to = len - 1 + if (to !== null) { + event.preventDefault() + select(to + 1, true) + } + } + const pane = (n: number) => ({ + className: step === n ? paneOn : paneOff, + id: `step-pane-${n}`, + role: 'tabpanel', + 'aria-labelledby': `step-tab-${n}`, + }) + return ( + <div className={lsec} id="home-how"> + <div className={`${container} ${section}`}> + <Kicker className={reveal}>{kicker}</Kicker> + <div className="grid grid-cols-[minmax(0,.85fr)_minmax(0,1.15fr)] gap-14 items-start mt-11 max-lg:grid-cols-1 max-lg:gap-10"> + <div className="sticky top-[calc(var(--header-h)+48px)] max-lg:static"> + <H2 className={`${titleClassName} ${reveal}`} style={d('60ms')}> + {title} + </H2> + <Lede className={reveal} style={d('120ms')}> + {lede} + </Lede> + <div + className={`flex flex-col mt-8 border-b border-hairline ${reveal}`} + style={d('160ms')} + role="tablist" + aria-label="Three steps" + aria-orientation="vertical" + > + {steps.map((s, i) => { + const n = i + 1 + const on = step === n + return ( + <button + key={n} + ref={(el) => { + tabs.current[i] = el + }} + className="group relative grid grid-cols-[16px_minmax(0,1fr)] gap-x-5 w-full py-4 border-t border-hairline rounded-none bg-transparent text-inherit text-left transition-colors duration-300 before:absolute before:left-0 before:-top-px before:h-0.5 before:w-0 before:bg-nav-bar before:transition-[width] before:duration-600 before:ease-out-expo aria-selected:before:w-full" + type="button" + role="tab" + id={`step-tab-${n}`} + aria-controls={`step-pane-${n}`} + aria-selected={on} + tabIndex={on ? 0 : -1} + data-step={n} + onClick={() => select(n)} + onKeyDown={onKey(i)} + > + <span + className="pt-px text-13 leading-5.5 tabular-nums text-num-faint transition-colors duration-300 group-hover:text-fg group-aria-selected:text-step-on" + aria-hidden="true" + > + {`0${n}`} + </span> + <span> + <span className="block text-15 leading-5.5 font-normal tracking-ui text-fg transition-colors duration-300 group-aria-selected:font-medium"> + {s.title} + </span> + <span className="grid grid-rows-[0fr] mt-0 text-14 leading-[1.55] text-muted-fg opacity-0 [transition:grid-template-rows_.5s_var(--ease-out-quart),margin-top_.5s_var(--ease-out-quart),opacity_.4s_ease] group-aria-selected:grid-rows-[1fr] group-aria-selected:mt-1.5 group-aria-selected:opacity-100"> + <span className="min-h-0 overflow-hidden">{s.text}</span> + </span> + </span> + </button> + ) + })} + </div> + </div> + <div className={`${winPaper} flex flex-col min-w-0 min-h-[468px] rounded-none max-sm:min-h-0 ${reveal}`} style={d('140ms')} data-pagefind-ignore=""> + <div className={winBar}> + <Dots hidden /> + <span className={`${winTitle} max-sm:left-16 max-sm:right-3 max-sm:text-right`}>{windowTitle}</span> + </div> + <div + className="flex flex-wrap gap-1.5 py-2.5 px-4 border-b border-hairline flex-none max-sm:px-3 max-xs:flex-nowrap max-xs:overflow-x-auto max-xs:scrollbar-none" + aria-hidden="true" + > + {fileTabs.map((t, i) => ( + <span className={step === i + 1 ? fileTabOn : fileTabOff} data-step={i + 1} key={t.label} onClick={() => select(i + 1)}> + <Icon name={t.icon} className="size-[13px] max-xs:hidden" /> + {t.label} + </span> + ))} + </div> + <div className="flex-1 grid"> + {panes.map((p, i) => ( + <div {...pane(i + 1)} key={i}> + {p} + </div> + ))} + </div> + </div> + </div> + </div> + </div> + ) +} + +export function Stepper() { + return ( + <StepperShell + kicker="Three steps" + title={ + <> + Your function stays. <Dim>Its inputs move into a file.</Dim> + </> + } + lede="No rewrite and no new runtime. Name the function in a block map, generate a form from its types, and connect your app to your content with one call." + steps={STEPS} + fileTabs={FILE_TABS} + windowTitle="my-store — setup" + panes={[ + <> + <Code html={STEP1_HTML} /> + <Result> + <span> + The key <code>experiments</code> is now a block type content can refer to. + </span> + </Result> + </>, + <> + <div className="flex gap-3 mt-[22px] mx-6 py-[13px] px-[18px] rounded-full bg-term-bg font-mono text-13 leading-5 font-normal text-[#EAF3D6] overflow-x-auto whitespace-nowrap scrollbar-none max-sm:mt-4 max-sm:mx-4 max-sm:text-12 max-sm:rounded-xl"> + <span className="text-brand font-medium" aria-hidden="true"> + $ + </span> + <span>npx deco schema && npx deco content</span> + </div> + <div + className="flex-none mt-4 mx-6 mb-[22px] border border-st-border rounded-xl bg-st-bg text-st-fg overflow-hidden max-sm:mt-3 max-sm:mx-4 max-sm:mb-4" + role="group" + aria-label="The form the site editor builds from the schema" + > + <div className={`flex items-center gap-2 h-10 px-3.5 border-b border-st-border bg-bg-subtle text-13 font-medium ${symbol}`}> + <BrandSymbol /> + <span>Experiments</span> + <span className="ml-auto px-2 py-0.5 border border-st-border rounded-full bg-st-bg font-mono text-11 leading-4 font-normal text-st-muted"> + experiments + </span> + </div> + {FORM_ROWS.map(([label, n], i) => ( + <div + className={`grid grid-cols-[minmax(0,1fr)_72px_48px] items-center gap-3 h-[46px] px-3.5 text-13 max-sm:grid-cols-[minmax(0,1fr)_56px] ${i ? 'border-t border-st-border' : ''}`} + key={label} + > + <span>{label}</span> + <span className="h-7 flex items-center justify-end px-3 border border-st-border rounded-full bg-st-field font-mono text-12 leading-4 font-medium tabular-nums"> + {n} + </span> + <span className="font-mono text-11 leading-4 font-normal text-st-muted text-right max-sm:hidden">0–100</span> + </div> + ))} + </div> + <Result> + <span>The site editor shows each experiment as a number field clamped to 0–100.</span> + </Result> + </>, + <> + <Code html={STEP3_HTML} /> + <Result> + <span> + With the hosted Deco CMS (the site and token above), a prepared release reaches your servers on their next background check, no deploy. Without it, content ships + with each deploy. + </span> + </Result> + </>, + ]} + /> + ) +} diff --git a/docs/components/home/index.tsx b/docs/components/home/index.tsx new file mode 100644 index 00000000..17ba3757 --- /dev/null +++ b/docs/components/home/index.tsx @@ -0,0 +1,57 @@ +/** + * The home pages, one per docs version: `/` is the current release's (v7/, see v7/index.tsx) and + * `/next/` the next major's (this folder's Hero, Sections, Stepper, Footer). The routes render the + * default export with the version (src/routes/index.tsx and src/routes/$version/index.tsx). + * + * The next-major landing was ported from the old landing.html: hero with the Experiments journey, + * stack strip, "Every change is a commit", stability, publishing, the three-step stepper, + * "Small on purpose", final CTA and the site footer. Its copy is approved as is: keep it verbatim. + * + * Styled with Tailwind utilities on the components (shared pieces in ui.tsx; the range sliders' + * pseudo-elements in src/styles/components/home.css). Entrance motion: `enter` animates on load; + * `reveal` fades up once on first scroll-in (useReveal), shown at once with prefers-reduced-motion, + * without IntersectionObserver, and before printing. + */ +import { Icon } from '~/components/ui/Icon' +import { MdxLink } from '~/components/mdx/MdxLink' +import { Hero } from './Hero' +import { ContentModel, Publishing, SmallOnPurpose, Stability, StackStrip } from './Sections' +import { Stepper } from './Stepper' +import { FinalCta, NEXT_COLUMNS } from './Footer' +import { HomeFrame } from './Frame' +import { container, reveal, textLink, textLinkIcon } from './ui' +import { V7Home } from './v7' + +/** The next major's link back to the current release's home. */ +function CurrentVersionNote() { + return ( + <div className="border-t border-hairline"> + <div className={`${container} py-10 flex justify-center max-sm:py-8`}> + <MdxLink className={`${textLink} text-link ${reveal}`} href="/"> + <span className="font-normal text-muted-fg">Using Deco today?</span> See the current version + <Icon name="arrow-right" className={textLinkIcon} /> + </MdxLink> + </div> + </div> + ) +} + +function NextHome() { + return ( + <HomeFrame version="next" columns={NEXT_COLUMNS}> + <Hero /> + <StackStrip /> + <ContentModel /> + <Stability /> + <Publishing /> + <Stepper /> + <SmallOnPurpose /> + <CurrentVersionNote /> + <FinalCta /> + </HomeFrame> + ) +} + +export default function Home({ version }: { version: string }) { + return version === 'next' ? <NextHome /> : <V7Home /> +} diff --git a/docs/components/home/tokens.tsx b/docs/components/home/tokens.tsx new file mode 100644 index 00000000..0ee5194c --- /dev/null +++ b/docs/components/home/tokens.tsx @@ -0,0 +1,75 @@ +/** + * Hand-tokenised code for the landing's mockups (the old landing.html markup, as JSX), coloured + * with the syntax tokens (text-syn-*). The journey panes use the components below; the stepper's + * two code panes are HTML strings (the Prism markup the old page produced at runtime, frozen here + * with utility classes so nothing highlights in the browser). + */ +import type { ReactNode } from 'react' + +type C = { children: ReactNode; className?: string } +const span = (cls: string) => + function Token({ children, className }: C) { + return <span className={className ? `${cls} ${className}` : cls}>{children}</span> + } +export const K = span('text-syn-keyword') +export const T = span('text-syn-type') +export const P = span('text-syn-property') +export const S = span('text-syn-string') +export const N = span('text-syn-number') +export const F = span('text-syn-function') +export const Cm = span('text-syn-comment italic') +/** A JSDoc tag inside a comment. */ +export const Ck = span('text-fg not-italic font-semibold') +/** One numbered line of a numbered pane (the number is a CSS counter the pane resets). */ +export const L = span( + 'block whitespace-pre [counter-increment:ln] overflow-hidden text-ellipsis before:content-[counter(ln)] before:inline-block before:w-4 before:mr-3 before:text-right before:text-quiet before:tabular-nums before:select-none', +) + +const TOKEN = { + keyword: 'text-syn-keyword', + punctuation: 'text-syn-punctuation', + operator: 'text-syn-operator', + string: 'text-syn-string', + comment: 'text-syn-comment italic', + function: 'text-syn-function', + constant: 'text-syn-type', +} +const tok = (kind: keyof typeof TOKEN, text: string) => `<span class="${TOKEN[kind]}">${text}</span>` +const kw = (t: string) => tok('keyword', t) +const pu = (t: string) => tok('punctuation', t) +const op = (t: string) => tok('operator', t) +const str = (t: string) => tok('string', t) +const cm = (t: string) => tok('comment', t) +const fn = (t: string) => tok('function', t) + +/** Step 1 pane (.deco/index.ts). */ +export const STEP1_HTML = [ + cm('// experiments.ts: the function you already have'), + `${kw('export')} ${kw('default')} ${kw('function')} ${fn('experiments')}${pu('(')}input${op(':')} Experiments${pu(')')} ${pu('{')}`, + ` ${kw('return')} ${pu('{')} newCheckout${op(':')} ${fn('roll')}${pu('(')}input${pu('.')}newCheckout${pu(')')}${pu(',')} ${cm('/* … */')} ${pu('}')}${pu(';')}`, + pu('}'), + '', + cm('// .deco/index.ts: give it a name in a block map'), + `${kw('import')} ${kw('type')} ${pu('{')} Blocks ${pu('}')} ${kw('from')} ${str('"@decocms/blocks"')}${pu(';')}`, + `${kw('import')} experiments ${kw('from')} ${str('"../experiments"')}${pu(';')}`, + '', + `${kw('export')} ${kw('default')} ${pu('{')} experiments ${pu('}')} satisfies Blocks${pu(';')}`, +].join('\n') + +/** Step 3 pane (cms.ts). */ +export const STEP3_HTML = [ + `${kw('import')} ${pu('{')} createCMS ${pu('}')} ${kw('from')} ${str('"@decocms/blocks"')}${pu(';')}`, + `${kw('import')} blocks ${kw('from')} ${str('"./.deco"')}${pu(';')}`, + `${kw('import')} content ${kw('from')} ${str('"./.deco/blocks.gen"')}${pu(';')}`, + '', + cm('// .deco/blocks.gen is generated by `deco content`; don\'t edit it.'), + cm('// With a site and token, production reads releases from the hosted'), + cm('// Deco CMS; without them, it reads that generated module.'), + `${kw('export')} ${kw('const')} cms ${op('=')} ${fn('createCMS')}${pu('(')}${pu('{')}`, + ` blocks${pu(',')}`, + ` content${pu(',')}`, + ` ${cm("// your site's ID and site token (secret) for the hosted Deco CMS")}`, + ` site${op(':')} process${pu('.')}env${pu('.')}${tok('constant', 'DECO_SITE')}${pu(',')}`, + ` token${op(':')} process${pu('.')}env${pu('.')}${tok('constant', 'DECO_SITE_TOKEN')}${pu(',')}`, + `${pu('}')}${pu(')')}${pu(';')}`, +].join('\n') diff --git a/docs/components/home/ui.tsx b/docs/components/home/ui.tsx new file mode 100644 index 00000000..b7225790 --- /dev/null +++ b/docs/components/home/ui.tsx @@ -0,0 +1,107 @@ +/** + * The landing's shared pieces: motion, the 1200px container, section headings, buttons, text + * links, and the browser-window mock. Everything is Tailwind utilities; a class string kept in a + * constant here is still a literal, so Tailwind's scanner sees it. + */ +import type { CSSProperties, ReactNode } from 'react' + +/** `style={d('120ms')}`: the entrance/reveal delay (`--d`) the old markup set inline. */ +export const d = (ms: string) => ({ '--d': ms }) as CSSProperties + +/** Entrance on load (the hero). */ +export const enter = 'animate-enter [animation-delay:var(--d,0ms)]' + +/** + * Fades up once on first scroll-in. `reveal` stays the hook useReveal queries (it adds `.in`); the + * element is only hidden with JS on, motion allowed and not printing, until it has `.in`. + */ +export const reveal = + 'reveal transition-[opacity,translate] duration-1000 ease-out-expo delay-(--d) js:motion-safe:not-print:not-[.in]:opacity-0 js:motion-safe:not-print:not-[.in]:translate-y-6' + +/** The 1200px landing column with its 40px (16px on phones) gutters. */ +export const container = 'mx-auto w-full max-w-landing px-10 max-sm:px-4' + +/** Sections' vertical rhythm (112px; 80px on phones). */ +export const section = 'py-28 max-sm:py-20' +/** Every landing band after the stack strip starts with a hairline. */ +export const lsec = 'border-t border-hairline' +export const sectionHead = 'max-w-[720px] mb-14 max-sm:mb-9' + +const ON_BAND = { kicker: 'text-lime', h2: 'text-white', dim: 'text-[rgba(255,255,255,.5)]', lede: 'text-band-muted' } +const ON_PAPER = { kicker: 'text-eyebrow', h2: 'text-fg', dim: 'text-muted-fg', lede: 'text-muted-fg' } +type Tone = { band?: boolean } + +export function Kicker({ band, className = '', children, ...rest }: Tone & { className?: string; children: ReactNode; style?: CSSProperties; id?: string }) { + return ( + <p className={`block mb-5 text-13 leading-4.5 font-normal tracking-ui uppercase ${band ? ON_BAND.kicker : ON_PAPER.kicker} ${className}`} {...rest}> + {children} + </p> + ) +} + +/** The section h2 (fluid 30–48px). */ +export const h2 = 'm-0 text-section leading-[1.17] font-normal tracking-heading text-balance' +export const H2 = ({ band, className = '', children, ...rest }: Tone & { className?: string; children: ReactNode; style?: CSSProperties; id?: string }) => ( + <h2 className={`${h2} ${band ? ON_BAND.h2 : ON_PAPER.h2} ${className}`} {...rest}> + {children} + </h2> +) +export const Dim = ({ band, className = '', children }: Tone & { className?: string; children: ReactNode }) => ( + <span className={`${band ? ON_BAND.dim : ON_PAPER.dim} ${className}`}>{children}</span> +) + +export const Lede = ({ band, className = '', children, ...rest }: Tone & { className?: string; children: ReactNode; style?: CSSProperties }) => ( + <p className={`mt-3 max-w-[620px] text-17 leading-normal max-sm:text-16 [&_code]:text-[.82em] ${band ? ON_BAND.lede : ON_PAPER.lede} ${className}`} {...rest}> + {children} + </p> +) + +/** The hero/final CTA row and its pill buttons. */ +export const cta = 'flex flex-wrap items-center gap-3 mt-9 max-sm:mt-[30px] max-sm:gap-2.5' +const btn = + 'h-12 px-6 py-3 inline-flex items-center justify-center gap-2 border-0 rounded-full text-16 leading-6 font-medium tracking-ui no-underline whitespace-nowrap transition-[scale,background-color,color,box-shadow,opacity] ease-out-quart active:scale-[.97] max-sm:flex-auto' +export const btnPrimary = `${btn} bg-brand text-brand-ink hover:bg-brand-hover` +export const btnWhite = `${btn} bg-white text-[#282524] hover:bg-[#EEF1F2]` +/** The secondary button on paper (the final CTA). */ +export const btnOutline = `${btn} bg-surface text-fg shadow-[inset_0_0_0_1px_var(--border-strong)] hover:bg-bg-subtle` + +/** "Follow the quickstart →": an arrow link; the arrow nudges right on hover. */ +export const textLink = 'group inline-flex items-center gap-1.5 flex-none text-15 leading-5.5 font-medium no-underline rounded-sm' +export const textLinkIcon = 'size-[15px] transition-transform duration-350 ease-out-quart group-hover:translate-x-[3px]' +/** "For developers: … →": the same link as a block that wraps, the arrow kept with the last word. */ +export const devLink = 'group block w-fit max-w-full mt-9 gap-1.5 text-15 leading-5.5 font-medium no-underline rounded-sm' +export const devLinkIcon = `${textLinkIcon} inline-block ml-1.5 align-[-2px]` + +/** + * The browser-window mock: a frame (`win` plus its own background, border colour and shadow, which + * differ per window), a title bar and a centred title, in two sizes (the contract cards' are small). + */ +export const win = 'border overflow-hidden' +/** The usual frame: surface, hairline border, the deep window shadow. */ +export const winPaper = `${win} bg-surface border-hairline shadow-win` +const bar = 'relative flex items-center gap-2 border-b border-hairline flex-none' +export const winBar = `${bar} h-[38px] px-4 bg-win-bar` +export const winBarSmall = `${bar} h-8 px-3 bg-win-bar` +/** The hero window's bar, the same colour as its frame. */ +export const winBarDark = `${bar} h-[38px] px-4 bg-win-bg` +const title = 'absolute text-center leading-4 text-muted-fg whitespace-nowrap overflow-hidden text-ellipsis' +export const winTitle = `${title} left-22 right-22 text-11.5` +export const winTitleSmall = `${title} left-16 right-16 text-11` + +export function Dots({ hidden = false, small }: { hidden?: boolean; small?: boolean }) { + const dot = small ? 'size-2 rounded-full bg-win-dot' : 'size-2.5 rounded-full bg-win-dot' + return ( + <span className={`inline-flex flex-none ${small ? 'gap-[5px]' : 'gap-1.5'}`} aria-hidden={hidden ? 'true' : undefined}> + <i className={dot} /> + <i className={dot} /> + <i className={dot} /> + </span> + ) +} + +/** Deco's "d" symbol (<BrandSymbol/>) at 15px, the variant matching the theme (light in print). */ +export const symbol = + '[&_.sym]:size-[15px] [&_.sym]:flex-none [&_.sym-light]:inline-block [&_.sym-dark]:hidden dark:not-print:[&_.sym-light]:hidden dark:not-print:[&_.sym-dark]:inline-block' + +/** Mono text without ligatures (the old `.mono`). */ +export const mono = 'font-mono [font-variant-ligatures:none] [font-feature-settings:"liga"_0,"calt"_0]' diff --git a/docs/components/home/useReveal.ts b/docs/components/home/useReveal.ts new file mode 100644 index 00000000..02813f29 --- /dev/null +++ b/docs/components/home/useReveal.ts @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react' +import { prefersReducedMotion } from '~/src/lib/ui' + +/** + * The site's deco-reveal: every `.reveal` inside the returned ref fades up once (adds `.in`) when + * it first enters the viewport. A scroll check backs up the observer (anything scrolled into or + * past view is shown); reduced motion, no IntersectionObserver, and printing show everything. + * `.reveal` is only hidden under `html.js` (set before paint), so the prerendered page reads fine + * without JS. + */ +export function useReveal<T extends HTMLElement>() { + const ref = useRef<T>(null) + useEffect(() => { + const root = ref.current + if (!root) return + let pending = Array.from(root.querySelectorAll<HTMLElement>('.reveal:not(.in)')) + const show = (el: HTMLElement) => el.classList.add('in') + const revealAll = () => { + pending.forEach(show) + pending = [] + } + if (prefersReducedMotion() || !('IntersectionObserver' in window)) { + revealAll() + return + } + const check = () => { + if (!pending.length) return + const limit = innerHeight * 0.95 + pending = pending.filter((el) => { + const r = el.getBoundingClientRect() + if (r.width && r.top < limit) { + show(el) + return false + } + return true + }) + } + const io = new IntersectionObserver( + (entries) => + entries.forEach((entry) => { + if (!entry.isIntersecting) return + const el = entry.target as HTMLElement + show(el) + io.unobserve(el) + pending = pending.filter((p) => p !== el) + }), + { rootMargin: '0px 0px -6% 0px', threshold: 0.05 }, + ) + pending.forEach((el) => io.observe(el)) + let scheduled = false + const onScroll = () => { + if (scheduled) return + scheduled = true + requestAnimationFrame(() => { + scheduled = false + check() + }) + } + window.addEventListener('scroll', onScroll, { passive: true }) + window.addEventListener('resize', onScroll) + window.addEventListener('beforeprint', revealAll) + requestAnimationFrame(check) + return () => { + io.disconnect() + window.removeEventListener('scroll', onScroll) + window.removeEventListener('resize', onScroll) + window.removeEventListener('beforeprint', revealAll) + } + }, []) + return ref +} diff --git a/docs/components/home/v7/Hero.tsx b/docs/components/home/v7/Hero.tsx new file mode 100644 index 00000000..567cd614 --- /dev/null +++ b/docs/components/home/v7/Hero.tsx @@ -0,0 +1,364 @@ +/** + * The current release's hero: headline, CTAs, the install pill and the "From a section to a live + * page" window (Write it → Edit it → Store it → Serve it). Typing a headline in the Studio pane + * rewrites the JSON diff and the rendered page; Publish takes the change as the new baseline. + * Below 768px the panes become the same scroll-snap carousel as the next major's hero. + */ +import { useState, type CSSProperties } from 'react' +import { Icon } from '~/components/ui/Icon' +import { BrandSymbol } from '~/components/ui/Brand' +import { MdxLink } from '~/components/mdx/MdxLink' +import { toast } from '~/src/lib/ui' +import { Cobogo, cobogoEdges, heroBand, heroTitle, InstallButton } from '../Hero' +import { + Arrow, + dl, + foot, + footIcon, + footMono, + footText, + gut, + head, + JourneyPills, + meta, + pane, + pre, + preLn, + stepNum, + title, + useJourneyCarousel, +} from '../Journey' +import { Cm, Ck, K, L, P, S, T } from '../tokens' +import { btnPrimary, btnWhite, container, cta, d, Dots, enter, symbol, textLink, textLinkIcon, win, winBarDark, winTitle } from '../ui' + +const INSTALL = 'bun add @decocms/blocks @decocms/blocks-admin @decocms/tanstack' + +export function V7Hero() { + return ( + <div className={heroBand}> + <Cobogo className={`top-[120px] h-[calc(100%-120px)] max-nav:top-[88px] max-nav:h-[calc(100%-88px)] ${cobogoEdges}`} /> + <div className={container}> + <h1 id="home-title" className={`${heroTitle} ${enter}`} style={d('60ms')}> + Sections in code. + <br /> Pages in Studio. + <br /> <span className="text-[rgba(255,255,255,.52)]">Live on your stack.</span> + </h1> + <p className={`mt-5 max-w-[600px] text-15 leading-[1.6] text-band-muted ${enter}`} style={d('120ms')}> + Deco Blocks is the framework behind Deco sites and storefronts. Developers write React sections in TypeScript. Editors compose + pages, banners and campaigns in Deco Studio, the visual editor. Your site serves them from TanStack Start on Cloudflare Workers or + from Next.js, with commerce apps for VTEX, Shopify and more. + </p> + <div className={`${cta} ${enter}`} style={d('300ms')}> + <MdxLink className={btnPrimary} href="/v7/quickstart"> + Start with TanStack Start + </MdxLink> + <MdxLink className={btnWhite} href="/v7/quickstart-nextjs"> + Use Next.js + </MdxLink> + <InstallButton command={INSTALL} /> + </div> + </div> + <div className={container}> + <SectionJourney /> + </div> + </div> + ) +} + +const SAVED = 'Spring collection' +const STEPS = ['Write', 'Edit', 'Store', 'Serve'] + +/** Escapes a value for display inside a JSON string. */ +const jsonText = (v: string) => JSON.stringify(v).slice(1, -1) + +const field = 'pt-2.5 px-3.5' +const fieldLabel = 'block text-12 leading-4 text-st-muted' +const fieldBox = + 'mt-1.5 h-8 w-full flex items-center px-3 border border-st-border rounded-full bg-st-field text-12.5 leading-4 text-st-fg min-w-0 overflow-hidden text-ellipsis whitespace-nowrap' + +function SectionJourney() { + const [saved, setSaved] = useState(SAVED) + const [headline, setHeadline] = useState('Summer sale') + const { journeyRef, step, showPanel } = useJourneyCarousel() + const changed = headline !== saved + const shown = headline.trim() || 'Your headline' + + const publish = () => { + if (!changed) return + setSaved(headline) + toast("Published. Studio sent the change to your site's /.decofile endpoint.") + } + + return ( + <> + <div + className={`${win} mt-16 rounded-2xl bg-win-bg border-[rgba(255,255,255,.12)] shadow-[0_0_0_1px_rgba(255,255,255,.06),0_50px_120px_-40px_rgba(0,0,0,.6)] max-sm:mt-11 max-sm:rounded-box ${enter}`} + data-pagefind-ignore="" + style={{ '--d': '420ms' } as CSSProperties} + > + <div className={winBarDark}> + <Dots hidden /> + <span className={`${winTitle} max-sm:left-16 max-sm:right-4 max-sm:text-right`}>Deco Blocks · my-store — Home page</span> + </div> + <div + className="relative grid grid-cols-4 gap-2.5 p-2.5 max-rail:grid-cols-2 max-md:grid-cols-none max-md:grid-flow-col max-md:auto-cols-[86%] max-md:overflow-x-auto max-md:snap-x max-md:snap-mandatory max-md:scroll-px-2.5 max-md:overscroll-x-contain max-md:scrollbar-none" + role="group" + aria-label="From a section to a live page" + ref={journeyRef} + > + {/* 01 · the section's code */} + <div className={pane}> + <div className={head}> + <span className={stepNum}>01</span> + <span className={title}>Write it</span> + <span className={meta} title="src/sections/Hero.tsx"> + <span className="rail:hidden max-xs:hidden">src/sections/</span>Hero.tsx + </span> + </div> + <pre className={preLn}> + <code className="block"> + <L> + <K>export</K> <K>interface</K> <T>Props</T> {'{'} + </L> + <L> + {' '} + <Cm> + /** <Ck>@title</Ck> Headline */ + </Cm> + </L> + <L> + {' '} + <P>title</P>: <T>string</T>; + </L> + <L> + {' '} + <Cm> + /** <Ck>@title</Ck> Background image */ + </Cm> + </L> + <L> + {' '} + <P>image</P>: <T>ImageWidget</T>; + </L> + <L> + {' '} + <P>cta</P>?: {'{ '} + <P>label</P>: <T>string</T>; + </L> + <L> + {' '} + <P>href</P>: <T>string</T> {'};'} + </L> + <L>{'}'}</L> + <L> </L> + <L> + <K>export default function</K> <T>Hero</T>( + </L> + <L> + {' { '} + <P>title</P>, <P>image</P>, <P>cta</P> {'}: '} + <T>Props</T> + </L> + <L>) {'{ … }'}</L> + </code> + </pre> + <div className={foot}> + <span className={footMono}>bun run generate</span> + <span className={`${footText} text-eyebrow`} aria-hidden="true"> + → + </span> + <span className={footMono}>a form in Studio</span> + </div> + <Arrow /> + </div> + + {/* 02 · Studio's form, generated from Props; the headline is live */} + <div className={pane}> + <div className={head}> + <span className={stepNum}>02</span> + <span className={title}>Edit it</span> + <span className={meta}>Studio — Home page</span> + </div> + <div className="flex-1 flex flex-col bg-st-bg text-st-fg rounded-b-[11px]"> + <div className="flex items-center justify-between gap-2 h-[42px] px-3.5 border-b border-st-border"> + <span className={`inline-flex items-center gap-2 text-13 leading-5 font-medium ${symbol}`}> + <BrandSymbol /> + Hero + </span> + <span className="font-mono text-11 leading-4 font-normal text-st-muted px-2 py-0.5 rounded-full bg-st-field border border-st-border"> + sections/Hero.tsx + </span> + </div> + <div className={field}> + <label className={fieldLabel} htmlFor="v7-headline"> + Headline + </label> + <input + id="v7-headline" + type="text" + value={headline} + maxLength={40} + spellCheck={false} + autoComplete="off" + onChange={(e) => setHeadline(e.target.value)} + className={`${fieldBox} outline-none border-olive-ring shadow-[0_0_0_3px_color-mix(in_oklab,var(--brand)_40%,transparent)] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--brand)_70%,transparent)]`} + /> + </div> + <div className={field}> + <span className={fieldLabel}>Background image</span> + <span className={`${fieldBox} gap-2 pl-1`}> + <i className="size-6 flex-none rounded-full bg-[linear-gradient(135deg,#F2C14E,#E07A3F_55%,#2F6F4F)]" aria-hidden="true" /> + summer.jpg + </span> + </div> + <div className={`${field} grid grid-cols-2 gap-2`}> + <span className="min-w-0"> + <span className={fieldLabel}>CTA label</span> + <span className={fieldBox}>Shop now</span> + </span> + <span className="min-w-0"> + <span className={fieldLabel}>CTA link</span> + <span className={`${fieldBox} font-mono text-11.5`}>/summer</span> + </span> + </div> + <div className="mt-auto pt-3 flex items-center justify-between gap-2 px-3.5 py-2.5 border-t border-st-border"> + <span className="text-11 leading-4 text-st-muted">Fields from the Props type</span> + <button + type="button" + className="h-[30px] min-w-16 px-4 border-0 rounded-full bg-brand text-brand-ink text-12.5 font-medium transition-[background-color,color,scale] focus-visible:outline-ring enabled:hover:bg-brand-hover enabled:active:scale-[.96] disabled:bg-st-field disabled:text-st-muted disabled:shadow-[inset_0_0_0_1px_var(--st-border)] disabled:cursor-default" + disabled={!changed} + onClick={publish} + > + {changed ? 'Publish' : 'Published'} + </button> + </div> + </div> + <Arrow /> + </div> + + {/* 03 · the page block in the decofile */} + <div className={pane}> + <div className={head}> + <span className={stepNum}>03</span> + <span className={title}>Store it</span> + <span className={meta} title=".deco/blocks/pages-home.json"> + <span className="rail:hidden max-xs:hidden">.deco/blocks/</span>pages-home.json + </span> + </div> + <pre className={`${pre} pt-3 pl-3 pr-2.5`} aria-label="Diff of .deco/blocks/pages-home.json"> + <code className="block"> + <span className={dl}> + <span className={`${gut} text-faint`}> </span> + {'{ '} + <P>"path"</P>: <S>"/"</S>, + </span> + <span className={dl}> + <span className={`${gut} text-faint`}> </span> + {' '} + <P>"sections"</P>: [{'{'} + </span> + <span className={dl}> + <span className={`${gut} text-faint`}> </span> + {' '} + <P>"__resolveType"</P>: + </span> + <span className={dl}> + <span className={`${gut} text-faint`}> </span> + {' '} + <S>"site/sections/Hero.tsx"</S>, + </span> + {changed ? ( + <> + <span className={`${dl} bg-del-bg`}> + <span className={`${gut} text-del-fg`}>-</span> + {' '} + <P>"title"</P>: <S className="line-through decoration-[color-mix(in_oklab,var(--del-fg)_60%,transparent)]">"{jsonText(saved)}"</S>, + </span> + <span className={`${dl} bg-add-bg shadow-[inset_2px_0_0_var(--olive-ring)]`}> + <span className={`${gut} text-eyebrow`}>+</span> + {' '} + <P>"title"</P>: <S>"{jsonText(headline)}"</S>, + </span> + </> + ) : ( + <span className={dl}> + <span className={`${gut} text-faint`}> </span> + {' '} + <P>"title"</P>: <S>"{jsonText(saved)}"</S>, + </span> + )} + <span className={dl}> + <span className={`${gut} text-faint`}> </span> + {' '} + <P>"image"</P>: <S>"…/summer.jpg"</S> + </span> + <span className={dl}> + <span className={`${gut} text-faint`}> </span> + {' }]'} + </span> + <span className={dl}> + <span className={`${gut} text-faint`}> </span> + {'}'} + </span> + </code> + </pre> + <div className={`${foot} flex-wrap gap-y-1`} aria-live="polite"> + <span + className={`size-[7px] rounded-full flex-none ${changed ? 'bg-yellow shadow-[0_0_0_3px_rgba(255,193,22,.2)]' : 'bg-faint'}`} + aria-hidden="true" + /> + <span className={`${footText} whitespace-normal`}> + {changed ? 'Publish → live in seconds with Fast Deploy · or with your next deploy' : 'Published · no changes'} + </span> + </div> + <Arrow /> + </div> + + {/* 04 · the rendered page */} + <div className={pane}> + <div className={head}> + <span className={stepNum}>04</span> + <span className={title}>Serve it</span> + <span className={meta}>store.example.com</span> + </div> + <div className="flex-1 flex flex-col gap-2.5 p-3"> + <div className="flex items-center gap-1.5 h-6 px-2.5 rounded-full border border-hairline bg-surface text-10.5 leading-4 text-muted-fg"> + <Icon name="lock" className="size-3 flex-none" /> + <span className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">store.example.com</span> + </div> + <div className="relative overflow-hidden rounded-lg px-3.5 pt-6 pb-4 min-h-[118px] flex flex-col justify-end bg-[linear-gradient(135deg,#F2C14E_0%,#E07A3F_48%,#2F6F4F_100%)] text-white"> + <span className="block text-17 leading-tight font-medium tracking-heading [text-shadow:0_1px_8px_rgba(0,0,0,.25)] break-words">{shown}</span> + <span className="mt-2.5 self-start inline-flex items-center h-6 px-3 rounded-full bg-white text-[#282524] text-11 leading-4 font-medium"> + Shop now + </span> + </div> + <div className="rounded-lg border border-dashed border-border-strong p-2.5"> + <div className="grid grid-cols-3 gap-1.5" aria-hidden="true"> + {[0, 1, 2].map((i) => ( + <span className="block h-11 rounded-md bg-muted motion-safe:animate-pulse" key={i} /> + ))} + </div> + <p className="mt-2 text-10.5 leading-4 text-muted-fg">Deferred section · loads as you scroll</p> + </div> + </div> + <div className={foot}> + <Icon name="check" className={footIcon} /> + <span className={footText}>Rendered by your own code</span> + </div> + </div> + </div> + </div> + <JourneyPills steps={STEPS} step={step} onSelect={showPanel} /> + <div className={`flex items-baseline justify-between gap-6 mt-7 max-md:block max-md:mt-5 ${enter}`} style={{ '--d': '520ms' } as CSSProperties}> + <p className="m-0 max-w-[78ch] text-14 leading-5.5 text-band-muted"> + A section is a typed React component. Studio builds its form, the page is saved as JSON, and your site renders it.{' '} + <span className="text-white font-medium">Type a new headline.</span> + </p> + <MdxLink className={`${textLink} text-brand max-md:mt-3.5`} href="/v7/quickstart"> + Follow the quickstart + <Icon name="arrow-right" className={textLinkIcon} /> + </MdxLink> + </div> + </> + ) +} diff --git a/docs/components/home/v7/Sections.tsx b/docs/components/home/v7/Sections.tsx new file mode 100644 index 00000000..a2eab358 --- /dev/null +++ b/docs/components/home/v7/Sections.tsx @@ -0,0 +1,618 @@ +/** + * The current release's bands under the hero: stack strip, "How v7 works" cards, the production + * band, the commerce apps grid, Fast Deploy publishing, moving to v7, looking ahead, and the final + * CTA. Built from the next major's pieces (../Sections.tsx, ../ui.tsx); only copy and mockups differ. + */ +import { Icon, Mark, type IconName, type MarkName } from '~/components/ui/Icon' +import { MdxLink } from '~/components/mdx/MdxLink' +import { Cobogo } from '../Hero' +import { + bracket, + c, + cap, + card, + cardH3, + cardNum, + cardP, + chip, + ciVisual, + ciWin, + hairlineGrid, + lane, + laneGrid, + LaneLabel, + linked, + linkedFar, + mfLabel, + mfRow, + notes, + Point, + PubPoint, + step, + stepBox, + stepPlain, + stepSkip, + stIcon, + stKey, + stPill, + stRow, + tag, +} from '../Sections' +import { + btnOutline, + btnPrimary, + container, + cta, + d, + devLink, + devLinkIcon, + Dim, + Dots, + H2, + Kicker, + Lede, + lsec, + mono, + reveal, + section, + sectionHead, + textLink, + textLinkIcon, + win, + winBar, + winBarSmall, + winTitle, + winTitleSmall, +} from '../ui' + +const STACK: [MarkName, string][] = [ + ['tanstack', 'TanStack Start'], + ['cloudflare', 'Cloudflare Workers'], + ['nextjs', 'Next.js App Router'], + ['react', 'React 19'], + ['node', 'Node.js 24+'], + ['git', 'Content in your repo'], +] + +export function V7StackStrip() { + return ( + <div> + <div className={`${container} pt-18 pb-20 max-sm:py-14`}> + <Kicker className={`text-center ${reveal}`}>Runs on your stack</Kicker> + <ul className={`${hairlineGrid} list-none mt-8 p-0 grid-cols-6 max-xl:grid-cols-3 max-md:grid-cols-2 ${reveal}`} style={d('80ms')} aria-label="Runtimes"> + {STACK.map(([mark, label]) => ( + <li + className="h-24 flex items-center justify-center gap-2.5 px-3 bg-bg text-15 font-medium tracking-snug text-stack-fg text-center transition-colors duration-300 hover:bg-stack-hover hover:text-tint-fg max-md:h-20 max-md:text-14" + key={mark} + > + <Mark name={mark} className="size-[18px] flex-none" /> + {label} + </li> + ))} + </ul> + </div> + </div> + ) +} + +/** "Read: …" link under a card. */ +function CardLink({ href, children }: { href: string; children: string }) { + return ( + <MdxLink className={`${textLink} text-link mt-4`} href={href}> + {children} + <Icon name="arrow-right" className={textLinkIcon} /> + </MdxLink> + ) +} + +const fileRow = 'flex items-center gap-2 py-[5px] text-12 leading-5' + +export function ThreeRoles() { + return ( + <div className={lsec}> + <div className={`${container} ${section}`}> + <div className={sectionHead}> + <Kicker className={reveal}>How v7 works</Kicker> + <H2 className={reveal} style={d('60ms')}> + Developers build the pieces. <Dim>Editors assemble the pages.</Dim> + </H2> + <Lede className={reveal} style={d('120ms')}> + A section is a React component with typed props. A page is a list of sections stored as JSON in your repository. Studio turns the + types into forms, so a launch, a banner swap or an A/B test is an edit, not a ticket. + </Lede> + </div> + <div className={`${hairlineGrid} grid-cols-3 max-home-xl:grid-cols-1 ${reveal}`} style={d('160ms')}> + <div className={card}> + <div className={`${ciWin} bg-code-bg`} aria-hidden="true" data-pagefind-ignore=""> + <div className={winBarSmall}> + <Dots small /> + <span className={winTitleSmall}>src/sections/</span> + </div> + <div className={`px-4 py-3 ${mono}`}> + <div className={fileRow}> + <Icon name="file" className="size-[13px] text-eyebrow" /> + <span className="text-fg">Hero.tsx</span> + </div> + <div className={fileRow}> + <Icon name="file" className="size-[13px] text-eyebrow" /> + <span className="text-fg">ProductShelf.tsx</span> + </div> + <div className={fileRow}> + <Icon name="layers" className="size-[13px] text-eyebrow" /> + <span className="text-fg">Header/Header.tsx</span> + </div> + <div className="mt-2 pt-2.5 border-t border-hairline text-12 leading-5"> + <span className="text-syn-comment italic">// Header.tsx</span> + <br /> + <span className="text-syn-keyword">export const</span> <span className="text-syn-property">layout</span> ={' '} + <span className="text-syn-keyword">true</span>; + </div> + </div> + </div> + <div> + <span className={cardNum}>01</span> + <h3 className={cardH3}>Sections in TypeScript</h3> + <p className={cardP}> + Write React 19 components and type their props. One <code>generate</code> command builds the schema Studio reads, along with + the registries your site loads. Section loaders fetch data on the server before render. + </p> + <CardLink href="/v7/model">Blocks and sections</CardLink> + </div> + </div> + <div className={card}> + <div className={`${ciWin} bg-surface`} aria-hidden="true" data-pagefind-ignore=""> + <div className={winBarSmall}> + <Dots small /> + <span className={winTitleSmall}>Studio — Summer sale</span> + </div> + <div className="py-1 px-3.5"> + <div className={mfRow}> + <span className={mfLabel}>Path</span> + <span className={`h-8 flex items-center px-3 border rounded-full bg-surface min-w-0 whitespace-nowrap overflow-hidden ${mono} text-12.5 border-border text-fg`}> + /summer-sale + </span> + </div> + <div className={`${mfRow} border-t border-hairline items-start`}> + <span className={`${mfLabel} pt-1.5`}>Sections</span> + <span className="grid gap-1.5"> + <span className="flex gap-1.5 flex-wrap"> + <span className={`${chip} bg-tint font-mono text-tint-fg`}>Hero</span> + <span className={`${chip} bg-tint font-mono text-tint-fg gap-1.5`}> + ProductShelf + <Icon name="zap" className="size-3" /> + </span> + <span className={`${chip} bg-transparent border border-dashed border-border-strong text-muted-fg font-sans`}>+ Add</span> + </span> + <span className="inline-flex items-center gap-2 text-11.5 leading-4 text-muted-fg"> + <span className="relative inline-block w-7 h-4 rounded-full bg-pub-live-bg" aria-hidden="true"> + <i className="absolute top-0.5 right-0.5 size-3 rounded-full bg-white" /> + </span> + ProductShelf · Load async + </span> + </span> + </div> + </div> + </div> + <div> + <span className={cardNum}>02</span> + <h3 className={cardH3}>Pages in Studio</h3> + <p className={cardP}> + Editors create pages at any path, reorder sections, schedule content, and preview drafts on the real site before publishing. + Studio talks to your running site over a small, documented protocol. + </p> + <CardLink href="/v7/studio">Deco Studio and the admin protocol</CardLink> + </div> + </div> + <div className={card}> + <div + className={`${ciVisual} border bg-bg-warm p-4 flex flex-col justify-center gap-2 max-sm:py-3 max-sm:px-3.5`} + aria-hidden="true" + data-pagefind-ignore="" + > + <div className="flex-none border border-hairline rounded-xl bg-surface overflow-hidden"> + <div className="flex items-center gap-2 h-8 px-3 border-b border-hairline text-12 leading-4 text-muted-fg whitespace-nowrap overflow-hidden"> + <Icon name="git-branch" className="size-[13px] text-eyebrow" /> + <span className="min-w-0 overflow-hidden text-ellipsis">Multivariate · Home hero</span> + </div> + <div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 px-3 py-2 text-12 leading-5"> + <span className="text-fg"> + <Icon name="smartphone" className="inline size-3 mr-1 align-[-1px] text-eyebrow" /> + Mobile + </span> + <span className="text-quiet">→</span> + <span className="font-mono text-11.5 text-tint-fg">HeroMobile</span> + </div> + <div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 px-3 py-2 border-t border-hairline text-12 leading-5"> + <span className="text-fg"> + <Icon name="sliders" className="inline size-3 mr-1 align-[-1px] text-eyebrow" /> + Random 50% + </span> + <span className="text-quiet">→</span> + <span className="font-mono text-11.5 text-tint-fg">Hero B</span> + </div> + </div> + <div className="self-end max-w-[80%] -rotate-1 px-3 py-2 rounded-lg bg-[color-mix(in_oklab,var(--yellow)_28%,var(--surface))] text-fg text-12 leading-[17px] shadow-sm"> + <span className="block font-mono text-10.5 leading-3.5 text-muted-fg">deco_segment</span> + Same visitor, same variant + </div> + </div> + <div> + <span className={cardNum}>03</span> + <h3 className={cardH3}>Variants without code</h3> + <p className={cardP}> + Matchers pick what each visitor sees: device, date, cookie, location, URL or a random split that stays sticky per visitor. Add + your own matcher in a few lines. + </p> + <CardLink href="/v7/variants">Matchers and variants</CardLink> + </div> + </div> + </div> + </div> + </div> + ) +} + +const HEADERS: [string, string, 'live' | 'tint'][] = [ + ['X-Cache', 'STALE-HIT · revalidating', 'live'], + ['X-Cache-Profile', 'listing', 'tint'], + ['X-Cache-Segment', '9f2c41e7', 'tint'], + ['X-Trace-Id', 'trace id attached', 'tint'], +] + +export function Production() { + return ( + <div className={lsec}> + <div className="mx-auto w-full max-w-landing px-10 py-8 max-sm:px-2 max-sm:py-4"> + <div className="relative isolate overflow-hidden pt-22 px-18 pb-20 rounded-3xl text-band-fg [background-image:radial-gradient(700px_500px_at_85%_10%,rgba(30,110,55,.5),transparent_60%),linear-gradient(162deg,#145528_0%,#0C4420_50%,#07301A_100%)] max-xl:py-18 max-xl:px-12 max-sm:pt-14 max-sm:px-5 max-sm:pb-12 max-sm:rounded-2xl [&_:where(:focus-visible)]:outline-lime"> + <Cobogo className="top-0 h-full [mask-image:linear-gradient(to_right,transparent_0%,transparent_62%,#000_92%)]" /> + <div className="relative max-w-[900px] mb-[52px]"> + <Kicker band className={reveal}> + Built for production + </Kicker> + <H2 band className={reveal} style={d('60ms')}> + Fast at the edge. <Dim band className="min-home-sm:block">Observable when it isn't.</Dim> + </H2> + <Lede band className={reveal} style={d('120ms')}> + On Cloudflare Workers, the TanStack binding wraps your site in a cache-aware Worker. Pages are cached by visitor segment, + revalidated in the background, and served stale if an upstream fails. Spans, logs and metrics come built in. + </Lede> + </div> + <div className="relative grid grid-cols-[minmax(0,1fr)_minmax(0,1.04fr)] grid-rows-[auto_1fr] gap-x-16 items-start max-xl:gap-x-12 max-nav:grid-cols-1 max-nav:grid-rows-none max-nav:gap-y-10"> + <div + className={`${win} bg-surface col-[2] row-[1/span_2] rounded-2xl border-[rgba(255,255,255,.14)] shadow-[0_0_0_1px_rgba(0,0,0,.05),0_44px_90px_-34px_rgba(0,0,0,.6)] max-nav:col-[1] max-nav:row-auto ${reveal}`} + style={d('160ms')} + role="group" + aria-labelledby="headers-title" + data-pagefind-ignore="" + > + <div className={winBar}> + <Dots hidden /> + <span className={winTitle} id="headers-title"> + store.example.com — response headers + </span> + </div> + <div className="pt-4 px-[22px] pb-2 max-home-sm:px-3.5 max-home-sm:pb-1.5"> + <ul className="list-none m-0 p-0" role="list"> + {HEADERS.map(([key, value, kind], i) => ( + <li className={`${stRow} ${i ? 'border-t border-hairline' : ''}`} key={key}> + <span className={`${stKey} font-mono text-13`}>{key}</span> + {kind === 'live' ? ( + <span className={`${stPill} bg-pill-on-bg text-pill-on-fg shadow-[inset_0_0_0_1px_var(--pill-on-ring)]`}> + <i className="size-[7px] rounded-full flex-none bg-lime shadow-[0_0_0_3px_rgba(208,236,26,.22)]" aria-hidden="true" /> + {value} + </span> + ) : ( + <span className={`${stPill} bg-tint text-tint-fg ${key === 'X-Cache-Segment' ? 'font-mono' : ''}`}> + <Icon name="check" strokeWidth={2.25} className={stIcon} /> + {value} + </span> + )} + </li> + ))} + </ul> + <p className="mt-1 mb-3 text-center text-12.5 leading-[18px] text-muted-fg">Every response tells you how it was served.</p> + </div> + </div> + <ul className={`col-[1] row-[1] list-none m-0 p-0 border-b border-band-line max-nav:row-auto ${reveal}`} style={d('200ms')} role="list"> + <Point title="Cache profiles per page type"> + Product, listing, search, static and private pages each get edge, browser and loader policies. Cart and account pages are never + shared. + </Point> + <Point title="Logged-in visitors stay private"> + Logged-in visitors always bypass the shared cache, and tracking parameters never split it. + </Point> + <Point title="Deferred sections"> + Editors mark slow sections async. Visitors get the page first and the rest as they scroll, while crawlers get everything. + </Point> + <Point title="Observability included"> + Request spans, cache decisions and upstream commerce timings, exported to your own OpenTelemetry collector. + </Point> + </ul> + <div className={`col-[1] row-[2] max-nav:row-auto max-nav:-mt-1 ${reveal}`}> + <p className={`${devLink} text-lime`}> + <span className="font-normal text-band-muted">For developers:</span>{' '} + <MdxLink className="text-lime no-underline hover:underline" href="/v7/caching"> + caching + </MdxLink>{' '} + <span className="font-normal text-band-muted">·</span>{' '} + <MdxLink className="text-lime no-underline hover:underline" href="/v7/observability"> + <span className="whitespace-nowrap"> + observability + <Icon name="arrow-right" className={devLinkIcon} /> + </span> + </MdxLink> + </p> + <p className="mt-4 max-w-[46ch] text-13 leading-5 text-band-muted"> + The edge cache and Fast Deploy are features of the TanStack Start + Cloudflare Workers binding. Next.js sites use Next's own + rendering and caching. + </p> + </div> + </div> + </div> + </div> + </div> + ) +} + +const APPS: { href: string; name: string; line: string; tag: string; icon: IconName }[] = [ + { href: '/v7/vtex', name: 'VTEX', line: 'Catalog, search, cart, account, checkout proxy', tag: 'Full', icon: 'box' }, + { href: '/v7/shopify', name: 'Shopify', line: 'Storefront API products, cart and sign-in', tag: 'Full', icon: 'box' }, + { href: '/v7/wake', name: 'Wake', line: 'Catalog, cart, wishlist and checkout routes', tag: 'Full', icon: 'box' }, + { href: '/v7/magento', name: 'Magento', line: 'Cart, user and wishlist; catalog in progress', tag: 'Partial', icon: 'box' }, + { href: '/v7/salesforce', name: 'Salesforce', line: 'Personalization product recommendations', tag: 'Recommendations', icon: 'sparkle' }, + { href: '/v7/algolia', name: 'Algolia', line: 'Shared search client for your own loaders', tag: 'Client only', icon: 'search' }, + { href: '/v7/blog', name: 'Blog', line: 'Posts, categories and SEO from your content', tag: 'Content', icon: 'book' }, + { href: '/v7/resend', name: 'Resend', line: 'Transactional email from forms', tag: 'Email', icon: 'zap' }, + { href: '/v7/apps-website', name: 'Website', line: 'SEO, analytics, themes and fonts', tag: 'Every site', icon: 'globe' }, +] + +export function CommerceApps() { + return ( + <div className={lsec}> + <div className={`${container} ${section}`}> + <div className={sectionHead}> + <Kicker className={reveal}>Companion apps</Kicker> + <H2 className={reveal} style={d('60ms')}> + Commerce included. <Dim>Bring your platform.</Dim> + </H2> + <Lede className={reveal} style={d('120ms')}> + Apps are packages that plug a platform into your site: loaders for product pages, listings and search, actions for cart and + account, configured from a block in your content. Every app speaks the same schema.org-shaped commerce types, so your sections + don't care which backend fills them. + </Lede> + </div> + <ul className={`${hairlineGrid} list-none m-0 p-0 grid-cols-3 max-home-lg:grid-cols-2 max-home-sm:grid-cols-1 ${reveal}`} style={d('160ms')} role="list"> + {APPS.map((a) => ( + <li className="min-w-0 bg-bg" key={a.href}> + <MdxLink + className="group h-full grid grid-cols-[32px_minmax(0,1fr)] gap-x-3 pt-5 px-6 pb-6 no-underline text-inherit transition-colors duration-300 hover:bg-bg-subtle max-home-sm:px-4 max-home-sm:pt-4 max-home-sm:pb-5" + href={a.href} + > + <span className="size-8 grid place-items-center rounded-lg bg-tint text-tint-fg" aria-hidden="true"> + <Icon name={a.icon} className="size-4" /> + </span> + <span className="min-w-0 grid gap-1"> + <span className="flex flex-wrap items-center justify-between gap-2"> + <strong className="text-17 leading-6 font-normal tracking-ui text-fg group-hover:text-link">{a.name}</strong> + <span className={`${tag} shadow-[inset_0_0_0_1px_var(--border-strong)] text-muted-fg`}>{a.tag}</span> + </span> + <span className="text-14 leading-[1.55] text-muted-fg">{a.line}</span> + </span> + </MdxLink> + </li> + ))} + </ul> + <MdxLink className={`${textLink} text-link mt-8 ${reveal}`} href="/v7/apps"> + How apps work + <Icon name="arrow-right" className={textLinkIcon} /> + </MdxLink> + </div> + </div> + ) +} + +export function FastDeploy() { + return ( + <div className={lsec}> + <div className={`${container} ${section}`}> + <div className={sectionHead}> + <Kicker className={reveal}>Fast Deploy · optional, Workers only</Kicker> + <H2 className={reveal} style={d('60ms')}> + Publish in seconds, <Dim>not on the next deploy.</Dim> + </H2> + <Lede className={reveal} style={d('120ms')}> + By default, content ships inside each deploy, so your site always has a tested copy. Turn on Fast Deploy and Studio publishes go + to Cloudflare KV instead. Every Worker picks up the new content on its next check, while a code deploy still reads its own + snapshot. + </Lede> + </div> + <div className={`${win} bg-surface border-hairline shadow-win rounded-2xl ${reveal}`} style={d('160ms')} aria-hidden="true" data-pagefind-ignore=""> + <div className={winBar}> + <Dots /> + <span className={`${winTitle} max-home-sm:left-16 max-home-sm:right-3.5 max-home-sm:text-right`}>Publishing a change — store.example.com</span> + </div> + <div className="max-home:grid max-home:grid-cols-2 max-home:grid-rows-[auto_auto_auto]"> + <div className={lane}> + <LaneLabel title="Default" sub="content ships with your next deploy" /> + <ol className={`${laneGrid} list-none m-0 p-0 max-home:grid-rows-[repeat(6,38px)] max-home:gap-y-(--gap) max-home:justify-items-stretch`}> + <li style={c(1)} className={`${step} ${stepPlain}`}> + Edit + </li> + <li style={c(3)} className={`${step} ${stepPlain} ${linkedFar}`}> + Commit + </li> + <li style={c(4)} className={`${step} ${stepPlain} ${linked}`}> + Build + </li> + <li style={c(5)} className={`${step} ${stepPlain} ${linked}`}> + Deploy + </li> + <li style={c(6)} className={`${step} ${stepPlain} ${linked}`}> + Live + </li> + </ol> + <div className={notes}> + <span className={bracket}>code release</span> + <span className={cap}>at your next deploy</span> + </div> + </div> + <div className={`${lane} border-t border-hairline bg-[color-mix(in_oklab,var(--tint)_70%,var(--surface))] max-home:border-t-0 max-home:border-l`}> + <LaneLabel title="With Fast Deploy" sub="content served from KV" /> + <ol className={`${laneGrid} list-none m-0 p-0 max-home:grid-rows-[repeat(6,38px)] max-home:gap-y-(--gap) max-home:justify-items-stretch`}> + <li style={c(1)} className={`${step} ${stepPlain}`}> + Edit + </li> + <li style={c(3)} className={`${step} ${stepPlain} ${linkedFar}`}> + Publish + </li> + <li style={c(4)} className={`${step} ${stepSkip} ${linked} max-home:hidden`}> + Build + </li> + <li style={c(5)} className={`${step} ${stepSkip} ${linked} max-home:hidden`}> + Deploy + </li> + {/* Below 1000px, Build and Deploy collapse into this one pill spanning their rows. */} + <li + className={`${stepBox} ${stepSkip} ${linked} hidden max-home:flex max-home:row-[4/6] max-home:self-stretch px-2.5 max-home-lg:px-2 max-home:text-13 max-home:leading-[17px] max-home:no-underline max-home:rounded-[18px] max-home:text-fg max-home-sm:px-1.5`} + > + No build · no deploy + </li> + <li style={c(6)} className={`${step} ${linked} border-transparent bg-pub-live-bg text-pub-live-fg shadow-[0_6px_18px_-8px_rgba(7,64,26,.55)]`}> + Live + </li> + </ol> + <div className={notes}> + <span className={`${bracket} before:border-dashed`}>no build · no deploy</span> + <span className={`${cap} font-medium text-tint-fg`}>live within seconds</span> + </div> + </div> + </div> + </div> + <ul className={`${hairlineGrid} list-none mt-7 p-0 grid-cols-3 max-home-lg:grid-cols-2 max-home-sm:grid-cols-1 ${reveal}`} style={d('200ms')} role="list"> + <PubPoint title="Keyed to each deployment">New code never reads old content, and a rollback brings its own content back.</PubPoint> + <PubPoint title="Safe fallback">If KV can't be read, the site serves the content bundled with the deploy.</PubPoint> + <PubPoint title="Preview first"> + Draft links render unpublished changes on allow-listed hosts, and nothing is cached or indexed. + </PubPoint> + </ul> + <MdxLink className={`${devLink} text-link ${reveal}`} href="/v7/releases"> + <span className="font-normal text-muted-fg">For developers:</span> deploying and Fast{' '} + <span className="whitespace-nowrap"> + Deploy + <Icon name="arrow-right" className={devLinkIcon} /> + </span> + </MdxLink> + </div> + </div> + ) +} + +export function MoveToV7() { + const cardLink = + 'group relative grid grid-cols-[minmax(0,1fr)_20px] items-start gap-x-4 pt-6 px-7 pb-7 bg-bg no-underline text-inherit transition-colors duration-300 hover:bg-bg-subtle max-sm:px-4 max-sm:pt-5 max-sm:pb-6' + return ( + <div className={`${lsec} bg-bg-subtle`}> + <div className={`${container} ${section}`}> + <div className={sectionHead}> + <Kicker className={reveal}>Already on Deco?</Kicker> + <H2 className={reveal} style={d('60ms')}> + Bring your site along. + </H2> + </div> + <div className={`${hairlineGrid} grid-cols-2 max-nav:grid-cols-1 ${reveal}`} style={d('120ms')}> + <MdxLink className={cardLink} href="/v7/migrate-from-fresh"> + <span className="grid gap-2"> + <strong className="text-19 leading-[1.3] font-normal tracking-[-.012em] text-fg group-hover:text-link">From Fresh and Deno</strong> + <span className="text-14.5 leading-[1.6] text-muted-fg"> + <code>deco-migrate</code> rewrites a Fresh site to TanStack Start and React 19 in place: imports, JSX, Tailwind 4, routes and + the Worker entry. A post-migration audit tells you what's left. + </span> + </span> + <Icon name="arrow-right" className="size-[18px] mt-1 text-olive-ring transition-[translate,color] duration-400 ease-out-quart group-hover:translate-x-1 group-hover:text-eyebrow" /> + </MdxLink> + <MdxLink className={cardLink} href="/v7/upgrade-from-start"> + <span className="grid gap-2"> + <strong className="text-19 leading-[1.3] font-normal tracking-[-.012em] text-fg group-hover:text-link">From @decocms/start 6.x</strong> + <span className="text-14.5 leading-[1.6] text-muted-fg"> + <code>deco-upgrade-6-to-7</code> rewrites your imports to the split packages and updates <code>package.json</code>. A checklist + covers the rest. + </span> + </span> + <Icon name="arrow-right" className="size-[18px] mt-1 text-olive-ring transition-[translate,color] duration-400 ease-out-quart group-hover:translate-x-1 group-hover:text-eyebrow" /> + </MdxLink> + </div> + <MdxLink className={`${textLink} text-link mt-8 ${reveal}`} href="/v7/nextjs-from-start"> + <span className="font-normal text-muted-fg">On Next.js with @decocms/start 5.x?</span> Move to the split packages + <Icon name="arrow-right" className={textLinkIcon} /> + </MdxLink> + </div> + </div> + ) +} + +export function LookingAhead() { + return ( + <div className={lsec}> + <div className={`${container} py-14 max-sm:py-10`}> + <div + className={`flex flex-wrap items-center justify-between gap-x-10 gap-y-5 rounded-box border border-preview-border bg-preview-bg px-7 py-6 max-sm:px-4 max-sm:py-5 ${reveal}`} + > + <p className="m-0 max-w-[68ch] flex gap-3 text-15 leading-[1.6] text-fg-body"> + <i className="mt-[7px] size-2 flex-none rounded-full bg-preview-dot shadow-[0_0_0_4px_var(--preview-dot-ring)]" aria-hidden="true" /> + <span> + <strong className="font-medium text-fg">The next major is being designed in the open.</strong> It explores a smaller, + framework-agnostic API built on plain functions, edited in Studio without your site running. v7 is what you build on today. + </span> + </p> + <span className="flex flex-wrap items-center gap-x-6 gap-y-2"> + <MdxLink className={`${textLink} text-link`} href="/next/"> + See the next major + <Icon name="arrow-right" className={textLinkIcon} /> + </MdxLink> + <MdxLink className={`${textLink} text-link`} href="/roadmap"> + Roadmap + <Icon name="arrow-right" className={textLinkIcon} /> + </MdxLink> + </span> + </div> + </div> + </div> + ) +} + +const noteLink = 'text-inherit underline decoration-1 underline-offset-2 rounded-[2px] transition-colors hover:text-fg' + +export function V7FinalCta() { + return ( + <div className="py-28 bg-bg text-center max-sm:py-20" role="group" aria-labelledby="final-title"> + <div className={`${container} flex flex-col items-center`}> + <H2 id="final-title" className={`max-w-[760px] mx-auto ${reveal}`}> + Start with one section. <Dim>Ship it today.</Dim> + </H2> + <div className={`${cta} justify-center ${reveal}`} style={d('120ms')}> + <MdxLink className={btnPrimary} href="/v7/quickstart"> + Quickstart · TanStack + </MdxLink> + <MdxLink className={btnOutline} href="/v7/quickstart-nextjs"> + Quickstart · Next.js + </MdxLink> + </div> + <p className={`mt-4 mx-auto max-w-[560px] text-13 leading-5 text-quiet ${reveal}`} style={d('120ms')}> + Or read{' '} + <MdxLink className={noteLink} href="/v7/architecture"> + how v7 works + </MdxLink> + , the{' '} + <MdxLink className={noteLink} href="/v7/packages"> + packages and exports + </MdxLink> + , or{' '} + <MdxLink className={noteLink} href="/v7/internals"> + what's under the hood + </MdxLink> + . + </p> + </div> + </div> + ) +} diff --git a/docs/components/home/v7/Stepper.tsx b/docs/components/home/v7/Stepper.tsx new file mode 100644 index 00000000..e7049b3c --- /dev/null +++ b/docs/components/home/v7/Stepper.tsx @@ -0,0 +1,135 @@ +/** + * "From zero to an editable page": the current release's three steps, in the stepper frame both + * homes share (../Stepper.tsx). + */ +import { BrandSymbol } from '~/components/ui/Brand' +import { Code, Result, StepperShell, type StepperStep } from '../Stepper' +import { Dim, symbol } from '../ui' +import { HERO_TSX_HTML } from './tokens' + +const STEPS: StepperStep[] = [ + { + title: 'Write a section.', + text: ( + <> + A React component and its <code>Props</code> type, with JSDoc labels for the form. + </> + ), + }, + { + title: 'Generate.', + text: "One command writes the schema, the section registry and the loader map, and caches what didn't change.", + }, + { + title: 'Open it in Studio.', + text: ( + <> + Studio reads your schema from <code>/live/_meta</code> and your content from <code>/.decofile</code>, then previews each change on + your own code. + </> + ), + }, +] + +const FILE_TABS = [ + { icon: 'file' as const, label: 'Hero.tsx' }, + { icon: 'terminal' as const, label: 'Terminal' }, + { icon: 'eye' as const, label: 'Studio' }, +] + +/* The generate log, in the real format ("[generate] <generator> <time> (fresh|cached)"); the + timings are left out on purpose so the mock implies no numbers. */ +const LOG: [string, string][] = [ + ['blocks', 'fresh'], + ['sections', 'fresh'], + ['loaders', 'cached'], + ['schema', 'fresh'], +] + +const FORM: [string, string][] = [ + ['Headline', 'Summer sale'], + ['Background image', 'summer.jpg'], + ['CTA', 'Shop now → /summer'], +] + +export function V7Stepper() { + return ( + <StepperShell + kicker="From zero to an editable page" + title={ + <> + Three steps. <Dim>Then hand it to your editors.</Dim> + </> + } + titleClassName="max-w-[16ch] max-lg:max-w-none" + lede="The quickstart builds a TanStack Start site on Cloudflare Workers with one section, one page and the endpoints Studio reads." + steps={STEPS} + fileTabs={FILE_TABS} + windowTitle="Deco Blocks · my-store — setup" + panes={[ + <> + <Code html={HERO_TSX_HTML} /> + <Result> + <span> + <code>src/sections/Hero.tsx</code> is now a section editors can place. + </span> + </Result> + </>, + <> + <div className="flex gap-3 mt-[22px] mx-6 py-[13px] px-[18px] rounded-full bg-term-bg font-mono text-13 leading-5 font-normal text-[#EAF3D6] overflow-x-auto whitespace-nowrap scrollbar-none max-sm:mt-4 max-sm:mx-4 max-sm:text-12 max-sm:rounded-xl"> + <span className="text-brand font-medium" aria-hidden="true"> + $ + </span> + <span>bun run generate</span> + </div> + <pre className="flex-none mt-4 mx-6 mb-[22px] py-4 px-5 border border-code-border rounded-xl bg-code-bg font-mono text-12.5 leading-6 text-code-fg overflow-x-auto max-sm:mt-3 max-sm:mx-4 max-sm:mb-4 max-sm:px-4 max-sm:text-12"> + <code> + {LOG.map(([gen, state]) => ( + <span className="block" key={gen}> + <span className="text-syn-comment">[generate]</span> {gen} <span className={state === 'fresh' ? 'text-syn-string' : 'text-muted-fg'}>({state})</span> + </span> + ))} + <span className="block"> + <span className="text-syn-comment">[generate]</span> total (3 fresh, 1 cached) + </span> + </code> + </pre> + <Result> + <span> + <code>.deco/meta.gen.json</code> describes every section for Studio. + </span> + </Result> + </>, + <> + <div + className="flex-none mt-[22px] mx-6 mb-[22px] border border-st-border rounded-xl bg-st-bg text-st-fg overflow-hidden max-sm:mt-4 max-sm:mx-4 max-sm:mb-4" + role="group" + aria-label="The form Studio builds from the section's Props" + > + <div className={`flex items-center gap-2 h-10 px-3.5 border-b border-st-border bg-bg-subtle text-13 font-medium ${symbol}`}> + <BrandSymbol /> + <span>Home page · Hero</span> + <span className="ml-auto px-2 py-0.5 border border-st-border rounded-full bg-st-bg font-mono text-11 leading-4 font-normal text-st-muted"> + sections/Hero.tsx + </span> + </div> + {FORM.map(([label, value], i) => ( + <div + className={`grid grid-cols-[minmax(0,.8fr)_minmax(0,1.2fr)] items-center gap-3 h-[46px] px-3.5 text-13 ${i ? 'border-t border-st-border' : ''}`} + key={label} + > + <span>{label}</span> + <span className="h-7 flex items-center px-3 border border-st-border rounded-full bg-st-field text-12.5 leading-4 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"> + {value} + </span> + </div> + ))} + </div> + <Result> + <span>Edits preview live. Publishing updates your site.</span> + </Result> + </>, + ]} + /> + ) +} diff --git a/docs/components/home/v7/columns.ts b/docs/components/home/v7/columns.ts new file mode 100644 index 00000000..d5c51e6e --- /dev/null +++ b/docs/components/home/v7/columns.ts @@ -0,0 +1,45 @@ +import type { FooterColumn } from '../Footer' + +/** The current release's footer columns (the home at /). */ +export const V7_COLUMNS: FooterColumn[] = [ + { + title: 'Docs', + links: [ + ['/v7/architecture', 'How it works'], + ['/v7/quickstart', 'Quickstart'], + ['/v7/model', 'Blocks & sections'], + ['/v7/content', 'The decofile'], + ['/v7/loaders', 'Loaders & actions'], + ['/v7/studio', 'Studio'], + ], + }, + { + title: 'Guides', + links: [ + ['/v7/tanstack', 'TanStack Start'], + ['/v7/nextjs', 'Next.js'], + ['/v7/releases', 'Deploying & Fast Deploy'], + ['/v7/caching', 'Caching'], + ['/v7/troubleshooting', 'Troubleshooting'], + ], + }, + { + title: 'Apps', + links: [ + ['/v7/apps', 'Overview'], + ['/v7/vtex', 'VTEX'], + ['/v7/shopify', 'Shopify'], + ['/v7/blog', 'Blog'], + ['/v7/apps-website', 'Website'], + ], + }, + { + title: 'More', + links: [ + ['/v7/upgrade-from-start', 'Upgrading from 6.x'], + ['/v7/cli', 'CLI reference'], + ['/next/', 'Next version'], + ['/roadmap', 'Roadmap'], + ], + }, +] diff --git a/docs/components/home/v7/index.tsx b/docs/components/home/v7/index.tsx new file mode 100644 index 00000000..6a5c5612 --- /dev/null +++ b/docs/components/home/v7/index.tsx @@ -0,0 +1,27 @@ +/** + * The current release's home (/): the v7 landing. Same visual language as the next major's + * (../Hero, ../Sections, ../Stepper, ../ui); spec in the docs design notes: every claim is something + * the v7 packages do, and the edge cache and Fast Deploy are qualified as TanStack/Workers features. + */ +import { HomeFrame } from '../Frame' +import { V7_COLUMNS } from './columns' +import { V7Hero } from './Hero' +import { CommerceApps, FastDeploy, LookingAhead, MoveToV7, Production, ThreeRoles, V7FinalCta, V7StackStrip } from './Sections' +import { V7Stepper } from './Stepper' + +export function V7Home() { + return ( + <HomeFrame version="v7" columns={V7_COLUMNS}> + <V7Hero /> + <V7StackStrip /> + <ThreeRoles /> + <Production /> + <CommerceApps /> + <FastDeploy /> + <V7Stepper /> + <MoveToV7 /> + <LookingAhead /> + <V7FinalCta /> + </HomeFrame> + ) +} diff --git a/docs/components/home/v7/tokens.ts b/docs/components/home/v7/tokens.ts new file mode 100644 index 00000000..17234d43 --- /dev/null +++ b/docs/components/home/v7/tokens.ts @@ -0,0 +1,40 @@ +/** + * The v7 stepper's code pane (src/sections/Hero.tsx), hand-tokenised as an HTML string coloured with + * the syntax tokens, like ../tokens.tsx does for the next major's panes. + */ +const TOKEN = { + keyword: 'text-syn-keyword', + punctuation: 'text-syn-punctuation', + operator: 'text-syn-operator', + string: 'text-syn-string', + comment: 'text-syn-comment italic', + function: 'text-syn-function', + type: 'text-syn-type', + property: 'text-syn-property', +} +const tok = (kind: keyof typeof TOKEN, text: string) => `<span class="${TOKEN[kind]}">${text}</span>` +const kw = (t: string) => tok('keyword', t) +const pu = (t: string) => tok('punctuation', t) +const ty = (t: string) => tok('type', t) +const str = (t: string) => tok('string', t) +const cm = (t: string) => tok('comment', t) +const fn = (t: string) => tok('function', t) +const pr = (t: string) => tok('property', t) + +export const HERO_TSX_HTML = [ + `${kw('import')} ${kw('type')} ${pu('{')} ImageWidget ${pu('}')} ${kw('from')} ${str('"@decocms/blocks/types/widgets"')}${pu(';')}`, + '', + `${kw('export')} ${kw('interface')} ${ty('Props')} ${pu('{')}`, + ` ${cm('/** @title Headline */')}`, + ` ${pr('title')}${pu(':')} ${ty('string')}${pu(';')}`, + ` ${cm('/** @title Background image */')}`, + ` ${pr('image')}${pu(':')} ${ty('ImageWidget')}${pu(';')}`, + ` ${pr('cta')}${pu('?:')} ${pu('{')} ${pr('label')}${pu(':')} ${ty('string')}${pu(';')} ${pr('href')}${pu(':')} ${ty('string')} ${pu('};')}`, + pu('}'), + '', + `${kw('export')} ${kw('default')} ${kw('function')} ${fn('Hero')}${pu('({')} title${pu(',')} image${pu(',')} cta ${pu('}:')} ${ty('Props')}${pu(')')} ${pu('{')}`, + ` ${kw('return')} ${pu('(')}`, + ` ${pu('<')}section${pu('>')} ${cm('{/* your markup */}')} ${pu('</')}section${pu('>')}`, + ` ${pu(')')}${pu(';')}`, + pu('}'), +].join('\n') diff --git a/docs/components/mdx/Callout.tsx b/docs/components/mdx/Callout.tsx new file mode 100644 index 00000000..590495f4 --- /dev/null +++ b/docs/components/mdx/Callout.tsx @@ -0,0 +1,51 @@ +import { Children, isValidElement, type ReactNode } from 'react' +import { cx } from '~/src/lib/ui' +import { CodeBlock } from './CodeBlock' +import { Table } from './Table' + +/** + * A boxed note. + * + * <Callout>**Status.** The API on these pages is proposed…</Callout> info icon + * <Callout type="warning">**Don't** …</Callout> amber, warning icon + * <Callout type="preview">…</Callout> lime "intro note" with a dot + * + * Inline content (all on the tag's line) is wrapped in one paragraph; put blank-line separated + * paragraphs on their own lines for several. + */ +export function Callout({ type = 'note', children }: { type?: CalloutType; children: ReactNode }) { + return <div className={calloutClass(type)}>{hasBlock(children) ? children : <p>{children}</p>}</div> +} + +export type CalloutType = 'note' | 'warning' | 'preview' + +/** + * The callout box's classes, for markup that can't use <Callout> (the icon is a masked ::before). + * Its paragraphs are 15px with no gap between them, whatever the article's prose says. + */ +export function calloutClass(type: CalloutType = 'note') { + return cx( + 'relative my-7 rounded-box border py-4 pr-5.5 pl-[50px] max-sm:py-3.5 max-sm:pr-4 max-sm:pl-11', + '[&_p]:m-0 [&_p]:text-15 [&_p]:leading-[25px]', + "before:absolute before:content-['']", + type === 'preview' + ? // a lime dot instead of an icon + 'border-preview-border bg-preview-bg [&_p]:text-fg before:top-[23px] before:left-[23px] before:size-2.5 before:rounded-full before:bg-preview-dot before:shadow-[0_0_0_4px_var(--preview-dot-ring)] max-sm:before:top-[21px] max-sm:before:left-[19px]' + : cx( + '[&_p]:text-fg-body before:top-5 before:left-5 before:size-4 max-sm:before:top-[18px] max-sm:before:left-4 print:break-inside-avoid-page print:shadow-none', + type === 'warning' + ? 'border-warn-border bg-warn-bg before:bg-warn-icon before:[mask:var(--ico-warn)_center/16px_no-repeat]' + : 'border-border bg-note-bg before:bg-eyebrow before:[mask:var(--ico-info)_center/16px_no-repeat]', + ), + ) +} + +const BLOCK_TAGS = new Set(['p', 'ul', 'ol', 'div', 'table', 'pre', 'figure', 'dl', 'blockquote', 'h2', 'h3', 'h4']) +const BLOCK_COMPONENTS = new Set<unknown>([CodeBlock, Table]) + +/** True when MDX passed block children (paragraphs, lists, code): they render as is, not wrapped in a <p>. */ +export function hasBlock(children: ReactNode): boolean { + return Children.toArray(children).some( + (c) => isValidElement(c) && (typeof c.type === 'string' ? BLOCK_TAGS.has(c.type) : BLOCK_COMPONENTS.has(c.type)), + ) +} diff --git a/docs/components/mdx/CodeBlock.tsx b/docs/components/mdx/CodeBlock.tsx new file mode 100644 index 00000000..e9f8181d --- /dev/null +++ b/docs/components/mdx/CodeBlock.tsx @@ -0,0 +1,225 @@ +import { useEffect, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from 'react' +import { Icon } from '~/components/ui/Icon' +import { announce, copyText, cx, toast } from '~/src/lib/ui' + +/** + * A highlighted code block: the panel with its header (file name or language), language badge and + * Copy button. You don't use it directly — every fenced block in MDX renders through it (it's the + * `pre` override), with the props build/rehype-docs.ts puts on the <pre>: + * + * ```ts title="cms.ts" -> header "cms.ts" (mono, file icon) + "TypeScript" badge + * ```tsx title="blocks.tsx (Next.js)" path in mono, the note after it in text + * ```json title="A plain request handler" a description, not a path: plain text header + * ```bash -> header "SHELL" (language only), no badge + * ```bash (one line: npm/npx/pnpm/yarn/bun …) -> compact "$ command" pill + * ```text (contains box-drawing │) -> diagram line-height + */ +export const LANG_LABELS: Record<string, string> = { + typescript: 'TypeScript', + tsx: 'TSX', + javascript: 'JavaScript', + jsx: 'JSX', + json: 'JSON', + jsonc: 'JSON', + bash: 'Shell', + shellscript: 'Shell', + yaml: 'YAML', + html: 'HTML', + css: 'CSS', + diff: 'Diff', + text: 'Text', +} + +// "cms.ts" is a path; "A plain request handler" describes the block; "blocks.tsx (Next.js)" and +// ".deco/schema.gen.json, abridged" are a path with a note. +const PATH = /^([^\s,]*\/[^\s,]*|[^\s,]+\.[a-z]\w*)(?=$|[\s,])/i + +type PreProps = ComponentProps<'pre'> & { + 'data-lang'?: string + 'data-title'?: string + 'data-cmd'?: boolean | string + 'data-url'?: boolean | string + 'data-diagram'?: boolean | string +} + +export function CopyButton({ target, withLabel, className }: { target: RefObject<HTMLElement | null>; withLabel?: boolean; className?: string }) { + const [copied, setCopied] = useState(false) + useEffect(() => { + if (!copied) return + const t = setTimeout(() => setCopied(false), 1800) + return () => clearTimeout(t) + }, [copied]) + return ( + <button + type="button" + // `copy-button`: styles in src/styles/components/docs.css (also a hook: the search index skips it). + className={cx( + className, + 'copy-button', + withLabel ? 'pr-3 pl-2.5 max-xs:px-2' : 'px-2', + copied ? 'border-transparent bg-tint text-tint-fg' : 'border-transparent bg-transparent text-muted-fg hover:border-code-border hover:bg-surface hover:text-fg', + )} + aria-label={copied ? 'Copied' : 'Copy code example'} + onClick={async () => { + const ok = await copyText((target.current?.textContent ?? '').replace(/\s+$/, '')) + if (!ok) return toast('Select the code to copy it.') + setCopied(true) + announce('Copied to clipboard') + }} + > + <Icon name="copy" className={cx('size-3.5', copied && 'hidden')} /> + <Icon name="check" className={cx('size-3.5', !copied && 'hidden')} /> + {withLabel && <span className="max-xs:hidden">{copied ? 'Copied' : 'Copy'}</span>} + </button> + ) +} + +/** Tab stop + named region only when the code scrolls; edge fades where it continues. */ +function useScrollHints(ref: RefObject<HTMLPreElement | null>) { + useEffect(() => { + const pre = ref.current + if (!pre) return + const fade = () => { + const x = pre.scrollLeft + const maxX = pre.scrollWidth - pre.clientWidth + const maxY = pre.scrollHeight - pre.clientHeight + pre.classList.toggle('fade-l', x > 1) + pre.classList.toggle('fade-r', maxX > 1 && x < maxX - 1) + pre.classList.toggle('fade-b', maxY > 1 && pre.scrollTop < maxY - 1) + } + const fit = () => { + if (!pre.offsetParent) return + const scrolls = pre.scrollWidth > pre.clientWidth + 1 || pre.scrollHeight > pre.clientHeight + 1 + if (scrolls) { + pre.tabIndex = 0 + pre.setAttribute('role', 'region') + // Two scrolling blocks with the same file name (two "checkout.ts" panes) would be two + // regions with one name: number them ("checkout.ts, 2 of 2"). + const base = pre.dataset.regionLabel ?? '' + const same = [...document.querySelectorAll<HTMLPreElement>('pre[data-region-label]')].filter((p) => p.dataset.regionLabel === base) + pre.setAttribute('aria-label', same.length > 1 ? `${base}, ${same.indexOf(pre) + 1} of ${same.length}` : base) + } else { + pre.removeAttribute('tabindex') + pre.removeAttribute('role') + } + fade() + } + let raf = 0 + const onScroll = () => { + if (!raf) + raf = requestAnimationFrame(() => { + raf = 0 + fade() + }) + } + fit() + const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(fit) : null + ro?.observe(pre) + pre.addEventListener('scroll', onScroll, { passive: true }) + return () => { + ro?.disconnect() + pre.removeEventListener('scroll', onScroll) + cancelAnimationFrame(raf) + } + }, [ref]) +} + +/** + * The panel: bordered box; inside a <figure> (titled blocks) the figure carries the margin. No + * radius here: cx() only joins strings, so each use picks one (rounded-box or rounded-full) rather + * than overriding a shared one. + */ +const PANEL = 'relative overflow-hidden border border-code-border bg-code-bg print:break-inside-avoid-page print:shadow-none' + +/** The scrolling <pre>: `code-pre` (src/styles/components/docs.css) carries its scroll-fade masks. */ +const PRE = 'code-pre' + +/** The one-line "$ command" pill: the prompt is a ::before on the code, outside the selection. */ +const PRE_CMD = "min-w-0 flex-1 px-5.5 py-[13px] [scrollbar-width:none] [&_code]:before:mr-3 [&_code]:before:font-medium [&_code]:before:text-eyebrow [&_code]:before:select-none [&_code]:before:content-['$']" + +export function CodeBlock(props: PreProps) { + const { + 'data-lang': lang = 'text', + 'data-title': title, + 'data-cmd': cmd, + 'data-url': isUrl, + 'data-diagram': diagram, + children, + className, + ...rest + } = props + const ref = useRef<HTMLPreElement>(null) + useScrollHints(ref) + const label = isUrl ? 'URL' : (LANG_LABELS[lang] ?? lang) + const ariaLabel = title || (label === 'URL' ? 'URL' : `${label} code`) + const pre = ( + <pre + {...rest} + ref={ref} + className={cx( + className, + PRE, + cmd ? PRE_CMD : 'px-5.5 py-4.5 max-sm:px-4 max-sm:py-3.5', + diagram ? 'leading-[1.3]' : 'max-sm:leading-[21px] print:leading-[1.5]', + )} + data-lang={label} + data-label={title || label} + aria-label={ariaLabel} + data-region-label={ariaLabel} + > + {children} + </pre> + ) + // `code-lang` and `code-head`: styles in src/styles/components/docs.css (also hooks: the search index skips them). + const badge = ( + <span className={cx('code-lang', cmd && 'mr-1')} aria-hidden="true"> + {label} + </span> + ) + if (cmd) { + return ( + <div className={cx(PANEL, 'my-7 flex items-center rounded-full')}> + {pre} + {badge} + <CopyButton target={ref} className="mr-[7px]" /> + </div> + ) + } + const path = title ? PATH.exec(title) : null + const kind = !title ? 'lang' : path ? 'path' : 'desc' + let name: ReactNode = title || label + if (title && path && path[0].length < title.length) + name = ( + <> + {path[0]} + <span className="font-sans text-13 tracking-ui text-muted-fg">{title.slice(path[0].length)}</span> + </> + ) + const panel = ( + <div className={cx(PANEL, 'rounded-box', title ? 'my-0' : 'my-7')}> + <div className="code-head"> + <Icon name={kind !== 'path' ? (label === 'Shell' ? 'terminal' : 'code') : 'file'} className="size-3.5 text-eyebrow" /> + <span + className={cx( + 'min-w-0 flex-1 truncate', + kind === 'lang' ? 'font-sans eyebrow-label' : 'text-fg', + kind === 'desc' && 'font-sans text-13.5 tracking-ui', + )} + aria-hidden="true" + > + {name} + </span> + {title && badge} + <CopyButton target={ref} withLabel /> + </div> + {pre} + </div> + ) + if (!title) return panel + return ( + <figure className="my-7 min-w-0"> + <figcaption className="sr-only">{title}</figcaption> + {panel} + </figure> + ) +} diff --git a/docs/components/mdx/Flow.tsx b/docs/components/mdx/Flow.tsx new file mode 100644 index 00000000..0955438d --- /dev/null +++ b/docs/components/mdx/Flow.tsx @@ -0,0 +1,51 @@ +import { Children, Fragment, isValidElement, type CSSProperties, type ReactNode } from 'react' + +/** + * A left-to-right diagram of numbered steps (stacks vertically on phones). Arrows are drawn + * between nodes automatically. + * + * <Flow label="Authoring flow"> + * <FlowNode title="Your TypeScript">Functions with typed inputs: UI and data fetchers alike</FlowNode> + * <FlowNode title="CLI → JSON Schema">`deco schema` writes `.deco/schema.gen.json`</FlowNode> + * <FlowNode title="Studio">Forms and previews for editors, built from the schema</FlowNode> + * </Flow> + * + * `title` may be JSX (`title={<code>load()</code>}`). Keep a node's text on the same line as its + * tags so MDX doesn't wrap it in a paragraph. + */ +export function Flow({ label, children }: { label?: string; children: ReactNode }) { + const nodes = Children.toArray(children).filter(isValidElement) + const cols = nodes.flatMap((_, i) => (i ? ['1px', 'minmax(0, 1fr)'] : ['minmax(0, 1fr)'])).join(' ') + return ( + <div + // Numbered by a CSS counter (no extra DOM); one column with ↓ arrows below 768px. Right after a + // <Small> caption it keeps the caption's 32px. + className="my-7 grid grid-cols-(--flow-cols) overflow-hidden rounded-2xl border border-border bg-surface [counter-reset:flow] max-md:grid-cols-1 [[data-small]+&]:mt-8" + aria-label={label} + style={{ '--flow-cols': cols } as CSSProperties} + > + {nodes.map((node, i) => ( + <Fragment key={i}> + {i > 0 && ( + <div + className="relative z-0 flex items-center justify-center bg-hairline text-[0px] text-eyebrow before:absolute before:top-1/2 before:left-1/2 before:-z-1 before:size-6.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:border before:border-border before:bg-surface before:content-[''] after:font-sans after:text-13 after:leading-none after:content-['→'] max-md:h-px max-md:after:content-['↓']" + aria-hidden="true" + > + → + </div> + )} + {node} + </Fragment> + ))} + </div> + ) +} + +export function FlowNode({ title, children }: { title: ReactNode; children?: ReactNode }) { + return ( + <div className="flex min-w-0 flex-col gap-1 px-5.5 pt-5.5 pb-6 [counter-increment:flow] before:mb-3.5 before:text-13 before:leading-4 before:text-eyebrow before:tabular-nums before:content-[counter(flow,decimal-leading-zero)] max-md:px-5 max-md:py-4.5 max-md:before:mb-2"> + <strong className="text-16 leading-5.5 font-medium tracking-snug text-fg">{title}</strong> + {children != null && <small className="text-14 leading-5 text-muted-fg [&_code]:text-12">{children}</small>} + </div> + ) +} diff --git a/docs/components/mdx/Heading.tsx b/docs/components/mdx/Heading.tsx new file mode 100644 index 00000000..d61aa112 --- /dev/null +++ b/docs/components/mdx/Heading.tsx @@ -0,0 +1,52 @@ +import type { ComponentProps } from 'react' +import { Icon } from '~/components/ui/Icon' + +/** + * The hover permalink at a heading's left edge. Shown while its heading is hovered or it has + * focus; hidden below 900px and in print. Styled by `heading-anchor` (src/styles/components/docs.css), + * also a hook: the Roadmap's link handler and the search index's exclude list look for it. + * `not-prose`: no article link styling. + */ +export function HeadingAnchor({ id, label }: { id: string; label: string }) { + return ( + <a + className="heading-anchor not-prose" + href={`#${id}`} + aria-label={`Link to ${label}`} + > + <Icon name="link" className="size-3.5" /> + </a> + ) +} + +/** + * h2/h3 overrides: the heading keeps its text as its accessible name (aria-label, set at build + * time) and gets a hover permalink. Ids come from build/rehype-docs.ts. Their type is the + * article's (src/styles/prose.css), which the Roadmap's headings share. + */ +function anchor(id: string | undefined, label: string | undefined) { + return id ? <HeadingAnchor id={id} label={label ?? id} /> : null +} + +export function H2({ children, ...props }: ComponentProps<'h2'>) { + return ( + <h2 {...props}> + {children} + {anchor(props.id, props['aria-label'])} + </h2> + ) +} + +export function H3({ children, ...props }: ComponentProps<'h3'>) { + return ( + <h3 {...props}> + {children} + {anchor(props.id, props['aria-label'])} + </h3> + ) +} + +/** The page title. Focusable (tabIndex -1) so "Back to top" and route changes can move focus to it. */ +export function H1(props: ComponentProps<'h1'>) { + return <h1 tabIndex={-1} {...props} /> +} diff --git a/docs/components/mdx/Hosted.tsx b/docs/components/mdx/Hosted.tsx new file mode 100644 index 00000000..874cdb04 --- /dev/null +++ b/docs/components/mdx/Hosted.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react' +import { cx } from '~/src/lib/ui' +import { hasBlock } from './Callout' +import { EYEBROW } from './Small' +import { MdxLink } from './MdxLink' + +/** + * A short notice on a framework page: what the hosted Deco CMS changes here, and a link to the + * hosted page that explains it. + * + * <Hosted to="/next/hosted-publishing">**Skip the deploy wait.** With the hosted Deco CMS, …</Hosted> + * <Hosted to="/next/hosted#connect-your-site" label="Connect your site">…</Hosted> + * + * `to` is a root-relative docs path (an optional #hash), rendered through MdxLink like any content + * link. Children are one or two sentences; inline content on the tag's line becomes one paragraph. + */ +export function Hosted({ to, label = 'How the hosted Deco CMS does it', children }: { to: string; label?: string; children: ReactNode }) { + return ( + <aside aria-label={`Hosted Deco CMS: ${label}`} className={HOSTED}> + {/* The aside's label already names it (and its link) for screen readers; this is its visible twin. */} + <div aria-hidden="true" className={cx(EYEBROW, 'mb-2! flex items-center gap-2')}> + <span className="size-2 shrink-0 rounded-full bg-preview-dot shadow-[0_0_0_3px_var(--preview-dot-ring)]" /> + Hosted Deco CMS + </div> + {hasBlock(children) ? children : <p>{children}</p>} + <p className="mt-2.5!"> + <MdxLink href={to} className="font-medium"> + {label} + <span aria-hidden="true"> →</span> + </MdxLink> + </p> + </aside> + ) +} + +/** The notice's classes: a lime-tinted box with a forest (lime in dark mode) left rule. */ +const HOSTED = cx( + 'my-7 rounded-box border border-preview-border border-l-4 border-l-preview-dot bg-preview-bg py-4 pr-5.5 pl-5 max-sm:py-3.5 max-sm:pr-4 max-sm:pl-4', + // the prose layer styles strong, code and the link; paragraphs are 15px with no gap, like <Callout> + '[&_p]:m-0 [&_p]:text-15 [&_p]:leading-[25px] [&_p]:text-fg-body', + 'print:break-inside-avoid-page', +) diff --git a/docs/components/mdx/Kbd.tsx b/docs/components/mdx/Kbd.tsx new file mode 100644 index 00000000..f6979e58 --- /dev/null +++ b/docs/components/mdx/Kbd.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from 'react' + +/** A key or shortcut: <Kbd>⌘K</Kbd>. (The mono font and 11px size are kbd's base style.) */ +export function Kbd({ children }: { children: ReactNode }) { + return <kbd>{children}</kbd> +} diff --git a/docs/components/mdx/MdxLink.tsx b/docs/components/mdx/MdxLink.tsx new file mode 100644 index 00000000..d80e7f82 --- /dev/null +++ b/docs/components/mdx/MdxLink.tsx @@ -0,0 +1,35 @@ +import type { ComponentProps } from 'react' +import { Link } from '@tanstack/react-router' +import { roadmapTarget, ROADMAP_ROOT } from '~/components/roadmap/sections' + +/** + * Links in content. Write site links as root-relative paths without the base path: + * [Quickstart](/next/quickstart) [Publishing](/next/releases-and-deployment#publishing) [Roadmap](/roadmap#roadmap-api) + * They become router links (client-side navigation, base path added). `#id` stays an in-page link; + * anything else (https://…) is a plain external link. + * + * Roadmap links may name just the old id: `/roadmap#roadmap-api--x` goes to the Roadmap page that + * holds it (/roadmap/api#roadmap-api--x; see components/roadmap/sections.ts), `/roadmap` to /roadmap/. + */ +export function MdxLink({ href = '', children, ...rest }: ComponentProps<'a'>) { + if (href.startsWith('/') && !href.startsWith('//')) { + let [path, hash] = href.split('#') as [string, string | undefined] + if (path === '/roadmap' || path === ROADMAP_ROOT) { + const t = hash ? roadmapTarget(hash) : undefined + path = t?.to ?? ROADMAP_ROOT + // An id that isn't a Roadmap id keeps its fragment, so the post-build link check reports it. + hash = t ? t.hash : hash + } + return ( + <Link to={path || '/'} hash={hash} activeOptions={{ exact: true, includeHash: true }} activeProps={{}} {...rest}> + {children} + </Link> + ) + } + const external = /^[a-z][a-z0-9+.-]*:/i.test(href) + return ( + <a href={href} rel={external ? 'noopener' : undefined} {...rest}> + {children} + </a> + ) +} diff --git a/docs/components/mdx/README.md b/docs/components/mdx/README.md new file mode 100644 index 00000000..570ce8d6 --- /dev/null +++ b/docs/components/mdx/README.md @@ -0,0 +1,227 @@ +# MDX components + +Everything a page under `content/<version>/*.mdx` can use. The map is `mdxComponents` in +[`index.tsx`](./index.tsx); pages get it automatically (no imports in MDX files). + +Plain Markdown covers most of a page: paragraphs, `**bold**`, `` `code` ``, lists, links, GFM +tables, fenced code. The components below cover what Markdown can't. + +## Page structure + +```mdx +--- +title: Quickstart # = the h1's plain text (checked) +nav: Quickstart # sidebar label (defaults to title) +group: Getting started # sidebar group +kind: docs # docs | internals (Under the hood); default docs +order: 2 # position in the version's reading order +eyebrow: Getting started # small label above the h1; defaults to group +description: … # optional <meta name="description"> +--- + +# Quickstart + +The first paragraph after the h1 is the lede (larger, muted). + +## A section ← h2: in the "On this page" rail, gets a permalink +### A sub-section ← h3: indented in the rail +``` + +- One `# h1` per page, first thing after the frontmatter. The layout renders the eyebrow above + it; the inline "On this page" outline is inserted after the h1 and its lede automatically. +- Heading ids are generated from the text with the old site's slug rule (`## 1. Make the + function configurable` → `#1-make-the-function-configurable`, duplicates get `-2`, `-3`). To + pin an id, write the heading as JSX: `<h2 id="publishing">Publishing</h2>` (it still gets the + permalink and rail entry). + +## Links + +```mdx +[Quickstart](/next/quickstart) another page (client-side navigation) +[Publishing](/next/releases-and-deployment#publishing) a heading on another page +[Roadmap](/roadmap#roadmap-api) the Roadmap (version-less) +[Key terms](#key-terms) a heading on this page +[GitHub](https://github.com/decocms/blocks) external +``` + +Root-relative, **without** the base path (`/blocks/` on GitHub Pages is added for you). Old +single-page anchors map like this: `#quickstart` → `/next/quickstart`, +`#releases-and-deployment--publishing` → `/next/releases-and-deployment#publishing` (drop the +`<section id>--` prefix), `#roadmap-…` → `/roadmap#roadmap-…`. `bun run check` and the build +fail on links to pages or headings that don't exist. + +## Code blocks + +Fenced code; the meta after the language sets the header. + +````mdx +```ts title="cms.ts" +export const cms = createCMS({ blocks, content }); +``` +```` + +| Fence | Renders | +|---|---| +| ` ```ts title="cms.ts" ` | Header with file icon and `cms.ts` in mono, a "TypeScript" badge, Copy. (Was `<figure class="code-example"><figcaption>cms.ts</figcaption>`.) | +| ` ```tsx title="blocks.tsx (Next.js)" ` | Path in mono, the note after it in text. | +| ` ```json title="A plain request handler" ` | A description rather than a path: plain-text header. | +| ` ```ts ` (no title) | Header shows the language ("TYPESCRIPT"), Copy. (Was `<pre class="code-block">`.) | +| ` ```bash ` with one line starting `npm`/`npx`/`pnpm`/`yarn`/`bun` | Compact `$ command` pill. | +| ` ```bash ` with a URL alone | Labelled "URL". | +| ` ```text ` containing `│` box drawing | Diagram line height. | + +Languages: `ts`/`typescript`, `tsx`, `js`, `jsx`, `json`, `jsonc`, `bash`/`sh`, `yaml`, `html`, +`css`, `diff`, `text`. A `ts` block that contains JSX is highlighted as TSX automatically (the old +rule). Highlighting is Shiki at build time with a CSS-variable theme (`--syn-*` tokens), so it +follows light/dark/print. Write code verbatim (no HTML entities): `a && b`, `<T>`. + +## Components + +### `<Callout>` + +```mdx +<Callout>**Status.** The API on these pages is proposed and not released yet.</Callout> + +<Callout type="warning"> + +**Don't** call `createCMS` per request. + +A second paragraph. + +</Callout> +``` + +`type`: `note` (default, info icon; was `.callout`), `warning` (amber; was `.callout.warning`), +`preview` (lime dot; was `.intro-note`). Inline content on one line becomes one paragraph. + +### `<Hosted>` + +```mdx +<Hosted to="/next/hosted-publishing">**Skip the deploy wait.** With the hosted Deco CMS, a commit to your production branch is served as a release and reaches running servers within seconds, with no rebuild.</Hosted> + +<Hosted to="/next/hosted#connect-your-site" label="Connect your site">…</Hosted> +``` + +A notice on a framework page saying what the hosted Deco CMS changes there, with a link to the +hosted page that explains it. Renders an `<aside aria-label="Hosted Deco CMS: <label>">` (so several on a page stay distinguishable): a lime box with a +forest left rule (lime in dark mode), a "Hosted Deco CMS" eyebrow with a dot, the text, and the +link (`label`, default "How the hosted Deco CMS does it", then →). `to` is required: a +root-relative docs path, optionally with `#hash`; it goes through `MdxLink`, and `bun run check` +validates it like any link. Inline content on the tag's line becomes one paragraph. + +Rules for writers: + +- The framework page stays complete without it: put it **after** the core instructions, never + instead of them. +- One or two sentences of factual benefit (what changes, how fast), no "upgrade" language. +- At most one per section and two per page; never on the hosted pages themselves. +- `to` points at a hosted page (`/next/hosted`, `/next/hosted-*`). + +### Tables + +GFM tables render inside a scrolling, bordered wrapper (the `table` override). Inline code in a +cell that contains spaces may wrap on phones; single identifiers don't. + +```mdx +| Piece | What it does | +|---|---| +| **SDK** `@decocms/blocks` | Reads your content… | +``` + +If a cell needs a list or several paragraphs, write the table as JSX (`<table><thead>…`); it +still gets the wrapper. + +### `<Flow>` / `<FlowNode>` + +```mdx +<Flow label="Authoring flow"> + <FlowNode title="Your TypeScript">Functions with typed inputs: UI and data fetchers alike</FlowNode> + <FlowNode title="CLI → JSON Schema">`deco schema` writes `.deco/schema.gen.json`</FlowNode> + <FlowNode title="Studio">Forms and previews for editors, built from the schema</FlowNode> +</Flow> +``` + +Numbered cells with arrows between them (drawn automatically); stacks on phones. `title` takes +JSX: `title={<><code>load()</code> from memory</>}`. Keep each node's text on the tag's line. +Was `.flow > .flow-node + .flow-arrow`. + +### `<Terms>` / `<Term>` + +```mdx +<Terms> + <Term name="Block function">One of your functions with a typed first parameter. See [Quickstart](/next/quickstart).</Term> + <Term name="Block map">A plain object of your block functions, such as `{ experiments }`.</Term> +</Terms> +``` + +Was `<dl class="terms">`. + +### `<Small>` + +```mdx +<Small>Top: four route paths. Below: the trie built from them.</Small> +<Small style={{ margin: '18px 0 6px' }}>Background path · on first use, then about once a minute</Small> +``` + +A small muted line (diagram captions, labels above a Flow). Was `<p class="small muted">`. + +### `<Steps>` / `<Step>` + +A Markdown ordered list (`1. …`) already renders as the numbered hairline rows. `<Steps>` + +`<Step>` produce the same `<ol><li>` when a step needs JSX or nested blocks. + +### `<Kbd>`, `<Eyebrow>` + +`<Kbd>⌘K</Kbd>`. `<Eyebrow>` is the uppercase label (the layout already puts one above the h1). + +## Widgets + +Interactive pieces (the "How resolution works" walkthrough, …) live in `components/widgets/`. +Every capitalized export of `components/widgets/index.tsx` is added to this map, so a page writes +`<Walkthrough />` with no import. A page that uses a name nobody exports fails to render. + +### `<Walkthrough />` + +```mdx +Step through `client.resolve("SummerCard")` to watch [the lookup rule](/next/blocks#the-lookup-rule) at work, … + +<Walkthrough /> +``` + +The "How resolution works" explorer (was `<div class="explorer">` + the `trace-*` script in +app.js): the CMS-call toggle (`resolve("SummerCard")` / `{ run: false }`), the output toggle +(Descriptor / React tree), the four step pills, the code at that step, its caption and call count, +and "Next step →". It takes no props; its data (captions, counts) is in +`components/widgets/Walkthrough.tsx` and the code for each state in +`components/widgets/walkthrough-code.mdx` (highlighted at build time like any fence). Write the +surrounding prose in the page; the widget renders only the explorer box. Use it once per page +(it keeps the old element ids: `#trace-code`, `#trace-caption`, `#trace-stat`, `#trace-next`). + +## Internal (don't write these) + +- `TocInline`: inserted after the h1/lede by `build/rehype-docs.ts`. +- `H1`/`H2`/`H3`, `MdxLink`, `CodeBlock`, `Table`: the element overrides behind headings, links, + fenced code and tables. + +## Styling + +- What these components render carries Tailwind utilities in the TSX (theme: `src/styles/theme.css`). + The code panel's parts and the heading permalink repeat many times per page, so they use named + classes written with `@apply` in `src/styles/components/docs.css` (`code-head`, `code-pre`, + `copy-button`, `heading-anchor`, …). +- What MDX writes as bare HTML (h1–h3, the lede, p, lists, links, strong/em, table cells) can't carry + classes, so it's styled by the prose layer, `src/styles/prose.css`: rules scoped to + `.doc-section`, in their own cascade layer below every class, so any utility wins over them. +- `not-prose` on an element opts it and its contents out of the prose layer (widgets, Roadmap + blocks, the inline outline). +- Shared pieces for markup outside MDX: `<Eyebrow>` / `EYEBROW`, `<Small>` / `SMALL`, + `<Callout>` / `calloutClass(type)`, `<HeadingAnchor>`. +- Hook classes kept for scripts: `heading-anchor`, `toc-inline`, `code-head`, `code-lang`, + `copy-button` (the search index skips them), and `fade-l` / `fade-r` / `fade-b` on a scrolling + `<pre>` (toggled by CodeBlock). + +## Adding a component + +Add a file here, export it from `index.tsx` and add it to `mdxComponents`, then document it in +this file. Keep it server-renderable (the page is prerendered); client-only behaviour goes in +`useEffect`. diff --git a/docs/components/mdx/Small.tsx b/docs/components/mdx/Small.tsx new file mode 100644 index 00000000..e511552a --- /dev/null +++ b/docs/components/mdx/Small.tsx @@ -0,0 +1,28 @@ +import type { CSSProperties, ReactNode } from 'react' + +/** The small muted line's classes (for markup that can't use <Small>). mt-8: it opens a new block. */ +export const SMALL = 'mt-8 mb-4 text-14 leading-5.5 text-muted-fg' + +/** + * A small, muted line: diagram captions and labels above a <Flow> (the old `p.small.muted`). + * + * <Small>Top: four route paths. Below: the trie built from them.</Small> + * <Small style={{ marginBottom: 6 }}>Request path · synchronous, never touches the network</Small> + * + * `data-small` lets a following <Flow> keep the caption's spacing. + */ +export function Small({ children, style }: { children: ReactNode; style?: CSSProperties }) { + return ( + <p className={SMALL} style={style} data-small=""> + {children} + </p> + ) +} + +/** The eyebrow's classes: the small uppercase label above a heading. */ +export const EYEBROW = 'm-0 mb-3.5 block font-sans leading-4.5 eyebrow-label' + +/** The small uppercase label above a heading (the layout already renders one above each page's h1). */ +export function Eyebrow({ children }: { children: ReactNode }) { + return <p className={EYEBROW}>{children}</p> +} diff --git a/docs/components/mdx/Steps.tsx b/docs/components/mdx/Steps.tsx new file mode 100644 index 00000000..29d7ceb9 --- /dev/null +++ b/docs/components/mdx/Steps.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react' + +/** + * Numbered rows ("01", "02", … on hairlines). A plain Markdown ordered list (`1. …`) already + * renders this way inside an article; use <Steps> when a step needs block content that Markdown + * list syntax makes awkward. + * + * <Steps> + * <Step>**Serve from memory.** `load()` returns the newest release…</Step> + * <Step>**Ask for the hash.** …</Step> + * </Steps> + */ +export function Steps({ children }: { children: ReactNode }) { + return <ol>{children}</ol> +} + +export function Step({ children }: { children: ReactNode }) { + return <li>{children}</li> +} diff --git a/docs/components/mdx/Table.tsx b/docs/components/mdx/Table.tsx new file mode 100644 index 00000000..7cb4ae1c --- /dev/null +++ b/docs/components/mdx/Table.tsx @@ -0,0 +1,18 @@ +import type { ComponentProps } from 'react' +import { cx } from '~/src/lib/ui' + +/** + * Every Markdown/GFM table renders inside a horizontally scrolling, bordered wrapper. On phones the + * table keeps a minimum width (wider with three or more columns) and scrolls. The cells are MDX + * output, so their look is in the article's prose layer (src/styles/prose.css). + */ +export function Table(props: ComponentProps<'table'>) { + return ( + <div className="my-7 w-full overflow-x-auto rounded-box border border-border bg-surface [scrollbar-width:thin] max-sm:rounded-xl print:break-inside-avoid-page print:overflow-visible print:shadow-none"> + <table + {...props} + className={cx('w-full border-collapse text-14 leading-5.5 max-sm:min-w-[520px] max-sm:text-13.5 max-sm:has-[tr>:nth-child(3)]:min-w-[640px]', props.className)} + /> + </div> + ) +} diff --git a/docs/components/mdx/Terms.tsx b/docs/components/mdx/Terms.tsx new file mode 100644 index 00000000..15b2d451 --- /dev/null +++ b/docs/components/mdx/Terms.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' + +/** + * A glossary: bold terms with their definitions (a <dl>). + * + * <Terms> + * <Term name="Block function">One of your functions with a typed first parameter… See [Quickstart](/next/quickstart).</Term> + * <Term name="Block map">A plain object of your block functions, such as `{ experiments }`.</Term> + * </Terms> + */ +export function Terms({ children }: { children: ReactNode }) { + return <dl className="my-[18px]">{children}</dl> +} + +export function Term({ name, children }: { name: ReactNode; children: ReactNode }) { + return ( + <> + <dt className="mt-3.5 text-16 leading-[1.6] font-semibold text-fg first:mt-0">{name}</dt> + <dd className="mt-0.5 text-16 leading-[1.72] text-fg-body">{children}</dd> + </> + ) +} diff --git a/docs/components/mdx/TocInline.tsx b/docs/components/mdx/TocInline.tsx new file mode 100644 index 00000000..61f18139 --- /dev/null +++ b/docs/components/mdx/TocInline.tsx @@ -0,0 +1,54 @@ +import { useRef } from 'react' +import { Icon } from '~/components/ui/Icon' +import { onRailClick, useRailItems } from '~/src/layout/Rail' +import type { RailItem } from '~/src/lib/nav' +import { cx } from '~/src/lib/ui' + +/** + * The collapsible "On this page" outline shown below 1200px. Inserted automatically after each + * page's h1 and lede by build/rehype-docs.ts; you never write it yourself. + */ +export function TocInline() { + return <TocInlineView items={useRailItems()} /> +} + +const hrefOf = (item: RailItem) => (item.id ? `#${item.id}` : '#main') + +/** + * The outline itself: the rail's items in a <details>. `toc-inline` stays as a hook (the search + * index skips it); `not-prose` keeps the article's list and link styles out. + */ +export function TocInlineView({ items }: { items: RailItem[] }) { + const ref = useRef<HTMLDetailsElement>(null) + if (items.length < 2) return null + return ( + <details className="toc-inline not-prose group mt-7 mb-2 hidden rounded-2xl border border-border bg-bg-subtle max-rail:block print:hidden" id="toc-inline" ref={ref}> + <summary className="flex h-[46px] cursor-pointer list-none items-center gap-2.5 rounded-2xl px-4 eyebrow-label [&::-webkit-details-marker]:hidden"> + <Icon name="list" className="text-eyebrow" /> + <span className="flex-1">On this page</span> + <Icon name="chevron-down" className="text-muted-fg transition-transform duration-300 ease-out-quart group-open:rotate-180" /> + </summary> + <ul className="m-0 list-none border-t border-hairline pt-1 pr-4 pb-3 pl-[22px]" id="toc-inline-list"> + {items.map((item) => ( + <li key={item.id ?? '_top'}> + <a + href={hrefOf(item)} + className={cx( + // toc-link (src/styles/components/docs.css): the look shared with the rail. + 'toc-link rounded-[2px] py-[7px] text-14 leading-5 font-normal hover:text-fg', + item.depth === 1 ? 'text-fg' : 'text-muted-fg', + item.depth === 3 && 'pl-4', + 'has-[>.gx-n]:pl-[22px]', + )} + onClick={(e) => { + if (ref.current) ref.current.open = false + onRailClick(item, e) + }} + dangerouslySetInnerHTML={{ __html: item.html }} + /> + </li> + ))} + </ul> + </details> + ) +} diff --git a/docs/components/mdx/index.tsx b/docs/components/mdx/index.tsx new file mode 100644 index 00000000..51f581a3 --- /dev/null +++ b/docs/components/mdx/index.tsx @@ -0,0 +1,51 @@ +/** + * The components MDX pages can use, and the HTML element overrides. See README.md for the API. + * + * Widgets (interactive pieces like the walkthrough) live in components/widgets/; anything that + * folder's index.tsx exports by name is available to MDX too, e.g. `<Walkthrough />`. + */ +import type { ComponentType } from 'react' +import type { MDXComponents } from 'mdx/types' +import { Callout } from './Callout' +import { Hosted } from './Hosted' +import { CodeBlock } from './CodeBlock' +import { Table } from './Table' +import { H1, H2, H3 } from './Heading' +import { MdxLink } from './MdxLink' +import { Flow, FlowNode } from './Flow' +import { Terms, Term } from './Terms' +import { Steps, Step } from './Steps' +import { Kbd } from './Kbd' +import { Small, Eyebrow } from './Small' +import { TocInline } from './TocInline' + +export { Callout, Hosted, CodeBlock, Table, H1, H2, H3, MdxLink, Flow, FlowNode, Terms, Term, Steps, Step, Kbd, Small, Eyebrow, TocInline } + +const widgetModules = import.meta.glob<Record<string, unknown>>('/components/widgets/index.tsx', { eager: true }) +const widgets: Record<string, ComponentType<never>> = {} +for (const mod of Object.values(widgetModules)) + for (const [name, value] of Object.entries(mod)) if (/^[A-Z]/.test(name) && typeof value === 'function') widgets[name] = value as ComponentType<never> + +export const mdxComponents: MDXComponents = { + // element overrides + a: MdxLink, + pre: CodeBlock, + table: Table, + h1: H1, + h2: H2, + h3: H3, + // components + Callout, + Hosted, + Flow, + FlowNode, + Terms, + Term, + Steps, + Step, + Kbd, + Small, + Eyebrow, + TocInline, + ...(widgets as MDXComponents), +} diff --git a/docs/components/roadmap/RoadmapPage.tsx b/docs/components/roadmap/RoadmapPage.tsx new file mode 100644 index 00000000..3ec85327 --- /dev/null +++ b/docs/components/roadmap/RoadmapPage.tsx @@ -0,0 +1,141 @@ +/** + * One Roadmap page (a section of the old single Roadmap page) in the docs shell: the Roadmap's + * sidebar, breadcrumb, the section's article, pager and "On this page" rail. + * + * Behaviour carried over from the old page script: + * - links inside the article (plain <a href>, see SectionViews.tsx) navigate client-side; + * - Feature readiness has a status/site filter; a site plan's "See where the N features it uses + * stand" presets the site filter; a link to a row the filter hides clears the filter first, so + * the row can be scrolled to (on click, and on back/forward); + * - the rail follows the filter (only the categories still shown). + */ +import { useEffect, useMemo, useRef, useState } from 'react' +import { flushSync } from 'react-dom' +import { useRouter, useRouterState } from '@tanstack/react-router' +import { getVersion } from '~/src/lib/content' +import { DocsShell } from '~/src/layout/DocsShell' +import { pagerCard, type Crumb, type NavGroup, type PagerLink } from '~/src/lib/nav' +import { prefersReducedMotion } from '~/src/lib/ui' +import { roadmap as rm } from './instance' +import { plain } from './model' +import { TocInline } from '~/components/mdx/TocInline' +import { NO_FILTER, sectionView, TocSlot, type FeatureFilter } from './SectionViews' +import { DOCS_VERSION, ROADMAP_ROOT, roadmapTarget, SECTION_GROUPS, SECTION_IDS, sectionPath, type SectionId } from './sections' + +const BASE = import.meta.env.BASE_URL +const groupOf = (id: SectionId) => SECTION_GROUPS.find((g) => g.ids.includes(id))!.title + +function sidebar(current: SectionId): NavGroup[] { + return SECTION_GROUPS.map((g) => ({ + title: g.title, + items: g.ids.map((id) => ({ label: rm.SEC[id].nav, to: sectionPath(id), active: id === current })), + })) +} + +function crumbs(id: SectionId): Crumb[] { + const nav = rm.SEC[id].nav + const group = groupOf(id) + return [{ label: 'Roadmap', to: ROADMAP_ROOT }, ...(group !== 'Roadmap' && group !== nav ? [{ label: group }] : []), { label: nav }] +} + +/** A section's pager card: a section alone in a group of its name goes by its title ("Overview" says little). */ +function card(id: SectionId): PagerLink { + const nav = rm.SEC[id].nav + const group = groupOf(id) + return { + to: sectionPath(id), + title: group === nav ? plain(rm.SEC[id].title) : nav, + sub: group !== 'Roadmap' && group !== nav ? `Roadmap › ${group}` : 'Roadmap', + } +} + +/** One reading order: the next major's docs, then Under the hood, then the Roadmap's sections. */ +function pager(id: SectionId): { prev?: PagerLink; next?: PagerLink } { + const i = SECTION_IDS.indexOf(id) + const docs = getVersion(DOCS_VERSION)?.pages ?? [] + const prev = i > 0 ? card(SECTION_IDS[i - 1]) : docs.length ? pagerCard(docs[docs.length - 1]) : undefined + const next = i < SECTION_IDS.length - 1 ? card(SECTION_IDS[i + 1]) : undefined + return { prev, next } +} + +const stripBase = (pathname: string) => (pathname.startsWith(BASE) ? `/${pathname.slice(BASE.length)}` : pathname) + +/** The filter survives moving between Roadmap pages (as it did on the old single page). */ +let lastFilter: FeatureFilter = NO_FILTER + +export default function RoadmapPage({ section }: { section: SectionId }) { + const router = useRouter() + const hash = useRouterState({ select: (s) => s.location.hash }) + const [filter, setFilterState] = useState<FeatureFilter>(lastFilter) + const setFilter = (f: FeatureFilter) => { + lastFilter = f + setFilterState(f) + } + + const view = useMemo(() => sectionView(rm, section, filter, setFilter), [section, filter]) + + // A row hidden by the filter can't be scrolled to: clear the filter first. + const hiddenTarget = (id: string) => { + const el = id ? document.getElementById(id) : null + return Boolean(el && el.closest('.gx-sec') && el.closest('[hidden]')) + } + const scrollTo = useRef<string | null>(null) + useEffect(() => { + const id = decodeURIComponent(hash || '') + // An old single-page fragment (/roadmap#roadmap-api--x) whose target now lives on another + // section's page: go there (/roadmap/api#roadmap-api--x), replacing the history entry. + const target = section === 'roadmap' && id ? roadmapTarget(id) : undefined + if (target && target.to !== ROADMAP_ROOT) { + router.navigate({ to: target.to as never, hash: target.hash, replace: true }) + return + } + if (section === 'roadmap-features' && hiddenTarget(id)) { + scrollTo.current = id + setFilter(NO_FILTER) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hash, section]) + useEffect(() => { + const id = scrollTo.current + if (!id) return + scrollTo.current = null + document.getElementById(id)?.scrollIntoView({ block: 'start', behavior: 'auto' }) + }, [filter]) + + useEffect(() => { + // Capture phase: runs before the router's own link handling and before a same-hash click + // (which fires no navigation) is ignored, so the target is visible by the time it scrolls. + const onCapture = (event: MouseEvent) => { + const a = (event.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null + if (!a || a.origin !== location.origin || a.pathname !== location.pathname || !a.hash) return + if (hiddenTarget(decodeURIComponent(a.hash.slice(1)))) flushSync(() => setFilter(NO_FILTER)) + } + // Bubble phase: links in the article navigate client-side. + const onClick = (event: MouseEvent) => { + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + const a = (event.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null + if (!a || !a.closest('.gx-sec') || a.classList.contains('heading-anchor') || a.target || a.origin !== location.origin) return + if (!(a.pathname === BASE.replace(/\/$/, '') || a.pathname.startsWith(BASE))) return + const site = a.getAttribute('data-gx-site') + // "See where the N features it uses stand": preset the site filter, then go. + if (site) setFilter({ v: '', s: site }) + event.preventDefault() + const to = stripBase(a.pathname) + const h = a.hash ? decodeURIComponent(a.hash.slice(1)) : undefined + if (!h && to === stripBase(location.pathname)) window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }) + router.navigate({ to: to as never, hash: h }) + } + document.addEventListener('click', onCapture, true) + document.addEventListener('click', onClick) + return () => { + document.removeEventListener('click', onCapture, true) + document.removeEventListener('click', onClick) + } + }, [router]) + + return ( + <DocsShell nav={sidebar(section)} crumbs={crumbs(section)} pager={pager(section)} rail={view.rail}> + <TocSlot.Provider value={<TocInline />}>{view.article}</TocSlot.Provider> + </DocsShell> + ) +} diff --git a/docs/components/roadmap/SectionViews.tsx b/docs/components/roadmap/SectionViews.tsx new file mode 100644 index 00000000..b379511b --- /dev/null +++ b/docs/components/roadmap/SectionViews.tsx @@ -0,0 +1,948 @@ +/** + * The Roadmap's sections, one per page (a port of the former Python Roadmap generator's render functions). Router-free + * markup: internal links are plain <a href> with the base path, and RoadmapPage turns clicks on + * them into client-side navigation. That keeps these components renderable on their own, which + * scripts/check-roadmap.ts does to run the page's self-checks. + * + * Levels: the page title is the h1, the sections' sub-headings and to-dos are h2 (the old h3), + * feature rows are h3 (the old h4). Styled with utilities; the prose layer (.doc-section) gives + * the h1, lede, sub-headings, paragraphs and links their docs look. + * + * Each section returns its article plus its "On this page" entries (`rail`), so the page's outline + * comes from the data rather than from the DOM. + */ +import { createContext, Fragment, useContext, type ReactNode } from 'react' +import { HeadingAnchor } from '~/components/mdx/Heading' +import { Small } from '~/components/mdx/Small' +import type { RailItem } from '~/src/lib/nav' +import { esc, GROUP_SEC, KIND, OPEN, plain, slug, VC, type Roadmap, type WorkGroup } from './model' +import type { SectionId } from './sections' + +// ------------------------------------------------------------------------------ styles +// Status colours: a status (data-v code) sets --vc (marker), --vbg/--vfg (pill) on the element +// that shows it, so one set of utilities (bg-(--vbg), before:bg-(--vc)) draws every status. +const VVARS: Record<string, string> = { + g: '[--vc:var(--gx-g)] [--vbg:var(--gx-g-bg)] [--vfg:var(--gx-g-fg)]', + p: '[--vc:var(--gx-p)] [--vbg:var(--gx-p-bg)] [--vfg:var(--gx-p-fg)]', + a: '[--vc:var(--gx-a)] [--vbg:var(--gx-a-bg)] [--vfg:var(--gx-a-fg)]', + c: '[--vc:var(--gx-c)] [--vbg:var(--gx-c-bg)] [--vfg:var(--gx-c-fg)]', + n: '[--vc:var(--gx-n)] [--vbg:var(--gx-n-bg)] [--vfg:var(--gx-n-fg)]', +} +const vvars = (code?: string) => (code ? VVARS[code] : '') +/** + * The status marker before a label: a dot, or a diamond for "to build" (so red and amber never + * differ by colour alone). `status-dot` (src/styles/components/docs.css) also sets the status + * colours from the element's data-v, so its users don't need vvars(). + */ +const DOT = 'status-dot' +/** The unchecked to-do box (decorative), on ::before or ::after. */ +const BOX_BEFORE = 'before:box-border before:size-[18px] before:flex-none before:rounded-[5px] before:border-[1.5px] before:border-quiet before:bg-surface' +/** A note with nothing to do: a short rule in the box's place. */ +const NOTE_MARK = 'before:size-[18px] before:flex-none before:bg-[linear-gradient(var(--quiet),var(--quiet))] before:bg-[length:10px_1.5px] before:bg-center before:bg-no-repeat' +const BOX_AFTER = 'after:box-border after:size-[18px] after:flex-none after:rounded-[5px] after:border-[1.5px] after:border-quiet after:bg-surface' +/** The small uppercase labels (Sites, The site, detail-list labels, filter groups). */ +const LABEL = 'text-12 leading-4.5 font-normal tracking-label uppercase text-eyebrow' +/** Links in a run ("Delivered by", "In these docs", …) are set apart by space alone. */ +const RUN = '[&>:is(a,span):not(:last-child)]:mr-[.85em]' +/** The to-do callouts on the overview: the callout box model with the to-do box in the icon's place. */ +const TODO_CALLOUT = `relative my-7 rounded-box border py-4 pr-[22px] pl-[50px] max-sm:py-3.5 max-sm:pr-4 max-sm:pl-11 print:break-inside-avoid ${BOX_BEFORE} before:absolute before:top-[19px] before:left-5 max-sm:before:top-[17px] max-sm:before:left-[15px]` +const TODO_CALLOUT_P = 'm-0 max-w-none text-15 leading-[25px] text-fg-body' +/** Pills (status, step kind, row tags) keep their fills on paper. */ +const PRINT_EXACT = 'print:[print-color-adjust:exact]' + +// ------------------------------------------------------------------------------ small parts +/** An element whose content is an HTML field of the data (trusted, from data/roadmap.json). */ +function Html({ as: Tag = 'span', html, ...rest }: { as?: 'span' | 'p' | 'dd' | 'a' | 'div'; html: string } & Record<string, unknown>) { + return <Tag {...rest} dangerouslySetInnerHTML={{ __html: html }} /> +} + +/** Joins nodes with a separator (" " between links in a run). */ +function join(nodes: ReactNode[], sep: ReactNode = ' '): ReactNode[] { + return nodes.flatMap((n, i) => (i ? [<Fragment key={`s${i}`}>{sep}</Fragment>, <Fragment key={i}>{n}</Fragment>] : [<Fragment key={i}>{n}</Fragment>])) +} + +const pad = (n: number) => String(n).padStart(2, '0') + +/** A plain sub-heading (h2) with its permalink; text only. */ +function SubHeading({ id, className, hidden, children }: { id: string; className?: string; hidden?: boolean; children: string }) { + return ( + <h2 id={id} aria-label={children} className={className} hidden={hidden}> + {children} + <HeadingAnchor id={id} label={children} /> + </h2> + ) +} + +/** + * A to-do's heading: box, (number,) title; everything below hangs under the title's column. The + * box is decorative (the item's first child says "To do" to screen readers); a note with nothing + * to do gets a short rule in its place. + */ +function TodoHeading({ id, title, n, note }: { id: string; title: string; n?: number; note?: boolean }) { + const label = (n ? `${pad(n)} ` : '') + plain(title) + return ( + <h2 + id={id} + aria-label={label} + className={`todo-heading ${note ? NOTE_MARK : BOX_BEFORE}`} // src/styles/components/docs.css + > + {n ? ( + <> + <span className="gx-n relative -top-[.39em] mr-[.6em] inline-block min-w-[1.6em] flex-none align-[.22em] text-[.56em] leading-none tracking-normal text-eyebrow tabular-nums">{pad(n)}</span>{' '} + </> + ) : null} + <Html className="min-w-0" html={title} /> + <HeadingAnchor id={id} label={label} /> + </h2> + ) +} +/** + * The rail entry for a to-do heading (its markup minus the permalink). The number is a quiet + * tabular index; Rail.tsx hangs a wrapped title under itself (`a:has(> .gx-n)`). + */ +const todoRail = (id: string, title: string, n?: number): RailItem => ({ + id, + html: (n ? `<span class="gx-n inline-block min-w-[22px] indent-0 text-11.5 leading-none tracking-normal text-quiet tabular-nums">${pad(n)}</span> ` : '') + `<span>${title}</span>`, + depth: 2, +}) + +/** A to-do row: a hairline above, the heading's box in the left gutter. Kills the docs' list numbers and dashes. */ +const TODO_ITEM = 'relative m-0 mt-12 border-t border-hairline pt-[30px] pr-0 pb-0 pl-[34px] before:content-none [&::marker]:content-none max-md:mt-10 max-md:pt-[26px] max-md:pl-[30px]' + +/** One to-do: the screen-reader "To do" (the box is on its heading), its heading, then its parts. With `note`, "Note". */ +function Todo({ note, heading, children }: { note?: boolean; heading: ReactNode; children: ReactNode }) { + return ( + <li className={note ? `rm-note ${TODO_ITEM}` : TODO_ITEM}> + <span className="sr-only" data-pagefind-ignore=""> + {note ? 'Note' : 'To do'} + </span> + {heading} + {children} + </li> + ) +} +/** The to-do lists: the rows draw their own rules and boxes. */ +const TODO_LIST = 'm-0 max-w-none list-none border-0 p-0' + +/** The paragraph right under a to-do's heading. */ +const META = 'mt-0 mb-3 flex flex-wrap items-center gap-x-3.5 gap-y-1.5 text-13 leading-5 text-muted-fg' + +/** + * Today / Plan (and link rows) as a description list. The Today row sits on the plain surface and + * the rows after it on a neutral fill; link-run rows (any label but Today and Plan) mark their dd. + * No overflow clipping, so focus rings on the links inside stay whole; the rows round their own corners. + */ +function Detail({ rows }: { rows: [label: string, body: string | ReactNode][] }) { + return ( + <dl className={`gx-wf mt-[18px] rounded-box border border-border bg-muted print:break-inside-avoid ${PRINT_EXACT}`}> + {rows.map(([lab, body], i) => { + const run = lab !== 'Today' && lab !== 'Plan' + const afterToday = i > 0 && rows[i - 1][0] === 'Today' + // Search leaves out the labels and the link runs after the Plan (as the old index did). + const skip = i > 0 && !afterToday + const cls = [ + 'grid grid-cols-[minmax(0,1fr)] gap-1 px-[18px] pb-4 first:rounded-t-[13px] last:rounded-b-[13px] @min-rm-sm:grid-cols-[124px_minmax(0,1fr)] @min-rm-sm:gap-x-5 @min-rm-sm:px-5', + PRINT_EXACT, + lab === 'Today' ? 'bg-surface' : '', + afterToday ? 'border-t border-hairline' : '', + skip ? 'pt-0' : 'pt-4', + ].join(' ') + const dd = [DD, run ? RUN : ''].join(' ') + return ( + <div key={lab} className={cls} data-pagefind-ignore={skip ? '' : undefined}> + <dt className={`m-0 ${LABEL} @min-rm-sm:leading-[25px]`} data-pagefind-ignore=""> + {lab} + </dt> + {typeof body === 'string' ? <Html as="dd" className={dd} html={body} /> : <dd className={dd}>{body}</dd>} + </div> + ) + })} + </dl> + ) +} + +/** A detail's body: HTML from the data (paragraphs, lists) at the detail's own size. */ +const DD = 'm-0 text-15 leading-[1.65] text-fg-body [&_li]:text-[length:inherit] [&_li]:leading-[inherit] [&>*+*]:mt-2.5 [&>:is(p,ul)]:m-0 [&>:is(p,ul)]:max-w-none [&>:is(p,ul)]:text-[length:inherit] [&>:is(p,ul)]:leading-[inherit]' + +/** Feature chips: links to the features' rows, named from the data, with the status for screen readers. */ +function Chips({ rm, ids }: { rm: Roadmap; ids: string[] }) { + return ( + <div + className="gx-chips not-prose mt-4 flex flex-wrap items-center gap-1.5 empty:hidden before:mr-1.5 before:text-12 before:leading-4.5 before:tracking-label before:text-eyebrow before:uppercase before:content-['Features']" + data-pagefind-ignore="" + > + {ids.map((id) => { + const f = rm.FEATS[id] + const v = VC[f.status] + return ( + <a + key={id} + href={rm.hrefOf(`roadmap-f-${id}`)} + data-v={v} + className={`feature-chip ${DOT} before:bg-[var(--vc,var(--faint))]`} + > + <span className="sr-only">{rm.VLABEL(f.status)}: </span> + {f.name} + </a> + ) + })} + </div> + ) +} + +const SITES_LABEL = "before:mr-2 before:text-12 before:tracking-label before:text-eyebrow before:uppercase before:content-['Sites']" +/** The sites a to-do or feature concerns ("Sites" label before them in a to-do's meta line). */ +function SiteTags({ rm, repos, label }: { rm: Roadmap; repos: string[]; label?: boolean }) { + return repos.length ? ( + <span className={label ? `text-muted-fg ${SITES_LABEL}` : 'text-muted-fg'}> + {repos.map((r) => rm.SHORT[r]).join(' · ')} + </span> + ) : null +} + +/** A link to a to-do, named by its title. */ +const ItemLink = ({ rm, id, className }: { rm: Roadmap; id: string; className?: string }) => <Html as="a" href={rm.hrefOf(id)} className={className} html={rm.title(id)} /> + +/** A link to a work item or, with the site and step number, to a site step. */ +function RefLink({ rm, id }: { rm: Roadmap; id: string }) { + const s = rm.STEP.get(id) + if (!s) return <ItemLink rm={rm} id={id} /> + return ( + <span> + <ItemLink rm={rm} id={id} />{' '} + <span className="text-13.5 text-muted-fg"> + ({rm.SHORT[s.site]} step {s.n}) + </span> + </span> + ) +} + +/** A status label with its marker ("<b>11</b> to build", "Done"). */ +const LG = `inline-flex items-center gap-1.5 whitespace-nowrap ${DOT}` + +/** Counts by status, as "<b>11</b> to build" markers. */ +function Legend({ rm, ids }: { rm: Roadmap; ids: string[] }) { + return ( + <> + {rm + .counts(ids) + .filter(([, n]) => n) + .map(([v, n]) => ( + <span key={v} className={LG} data-v={VC[v]}> + <b className="font-medium text-fg tabular-nums">{n}</b> {rm.vword(v, n)} + </span> + ))} + </> + ) +} + +function OpenWork({ rm, ids }: { rm: Roadmap; ids: string[] }) { + const eff: Record<string, number> = { L: 0, M: 0, S: 0 } + for (const i of ids) if (OPEN.has(rm.FEATS[i].status)) eff[rm.FEATS[i].effort]++ + return ( + <> + Open work: <b>{eff.L}</b> L · <b>{eff.M}</b> M · <b>{eff.S}</b> S + </> + ) +} + +/** + * One status bar (all sites, or one site), with its legend and effort tally. From 640px of column + * the label sits in its own column left of the bar, legend and description. + */ +function BarRow({ rm, label, labelText, ids, descHtml, big }: { rm: Roadmap; label: ReactNode; labelText: string; ids: string[]; descHtml?: string; big?: boolean }) { + const cs = rm.counts(ids).filter(([, n]) => n) + const aria = cs.map(([v, n]) => `${n} ${rm.vword(v, n)}`).join(', ') + return ( + <div className="grid grid-cols-[minmax(0,1fr)] gap-y-2 print:break-inside-avoid @min-rm:grid-cols-[200px_minmax(0,1fr)] @min-rm:gap-x-6"> + <p + className="m-0 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5 text-14 leading-5 text-fg @min-rm:col-1 @min-rm:row-span-3 @min-rm:row-start-1 @min-rm:flex-col @min-rm:justify-start @min-rm:self-start [&_b]:font-medium" + data-pagefind-ignore="" + > + {label} + <span className="text-12.5 text-muted-fg">{ids.length} features</span> + </p> + <div + className={`flex ${big ? 'h-4' : 'h-3'} gap-0.5 overflow-hidden rounded-full bg-gx-track @min-rm:col-2 @min-rm:row-1 @min-rm:mt-1 @min-rm:self-center`} + role="img" + aria-label={`${labelText}: ${aria}`} + data-pagefind-ignore="" + > + {cs.map(([v, n]) => ( + <i key={v} data-v={VC[v]} className={`min-w-1 bg-(--vc) ${vvars(VC[v])} ${PRINT_EXACT}`} style={{ flex: `${n} 0 0` }} /> + ))} + </div> + <p className="m-0 flex flex-wrap items-center gap-x-4 gap-y-1 text-12.5 leading-5 text-muted-fg @min-rm:col-2 @min-rm:row-2" data-pagefind-ignore=""> + <Legend rm={rm} ids={ids} /> + <span className="ml-auto [&_b]:font-medium [&_b]:text-fg-body" title="Effort of the features to build, to finish and left to site code"> + <OpenWork rm={rm} ids={ids} /> + </span> + </p> + {descHtml ? ( + <Html + as="p" + className="mx-0 mt-0.5 mb-0 max-w-none text-14 leading-[1.6] text-fg-body @min-rm:col-2 @min-rm:row-3 [&_code]:text-12.5 [&>span]:text-muted-fg" + html={descHtml} + /> + ) : null} + </div> + ) +} + +/** A status pill. */ +const Pill = ({ rm, v, className = '' }: { rm: Roadmap; v: string; className?: string }) => ( + <b + className={`gx-vp ${DOT} ${PRINT_EXACT} ${className}`} // gx-vp: src/styles/components/docs.css + data-v={VC[v]} + > + {rm.VLABEL(v)} + </b> +) + +/** A site step's kind pill: Fix now, Before migrating, Blocker, Site work, Content, Note. */ +const KIND_CLS: Record<string, string> = { + now: 'bg-gx-now-bg text-gx-now-fg', + pre: 'bg-gx-a-bg text-gx-a-fg', + blocker: 'bg-gx-g-bg text-gx-g-fg', + content: 'bg-gx-p-bg text-gx-p-fg', + fix: 'bg-transparent text-muted-fg inset-ring inset-ring-border-strong', +} +const Kind = ({ k, className = '' }: { k: keyof typeof KIND; className?: string }) => ( + <span + className={`gx-kind inline-flex h-[22px] items-center rounded-full px-2.5 text-12 leading-4 font-medium tracking-[.01em] whitespace-nowrap ${KIND_CLS[k] ?? 'bg-muted text-fg-body'} ${PRINT_EXACT} ${className}`} + data-k={k} + > + {KIND[k]} + </span> +) + +/** The status key for chip dots (the to-build marker is a diamond, so it doesn't rely on colour). */ +function Key({ rm }: { rm: Roadmap }) { + return ( + <span className="ml-1 inline-flex flex-wrap gap-x-3.5 gap-y-0.5 text-13.5"> + {rm.VERDICTS.filter((v) => rm.TOTALS[v]).map((v) => ( + <span key={v} className={LG} data-v={VC[v]}> + {rm.vword(v, 1)} + </span> + ))} + </span> + ) +} + +/** + * The inline "On this page" outline, placed after the lede. Provided by RoadmapPage (it needs the + * docs shell's context); empty when a section is rendered on its own (scripts/check-roadmap.ts). + */ +export const TocSlot = createContext<ReactNode>(null) + +/** + * The section's article: eyebrow (with its count), h1, lede, the inline outline, then the body. + * `gx-sec` is the query container (the tiles, bars, detail lists and feature rows reflow on the + * column's width, not the window's) and RoadmapPage's hook for links inside the article. + */ +function Article({ rm, id, count, lede, children }: { rm: Roadmap; id: SectionId; count?: string; lede: ReactNode; children: ReactNode }) { + const s = rm.SEC[id] + return ( + <article id={id} className="doc-section doc-page gx-sec @container" aria-labelledby={`${id}-title`} data-pagefind-body=""> + <p className="eyebrow eyebrow-label mt-0 mb-3.5 flex flex-wrap items-center gap-x-3 gap-y-1.5 leading-4.5"> + {s.eyebrow} + {count ? ( + <span + className="rm-count inline-flex h-[22px] items-center gap-[7px] rounded-full pr-2.5 pl-2 text-12 leading-4 tracking-[.01em] whitespace-nowrap normal-case text-muted-fg tabular-nums inset-ring inset-ring-border-strong before:box-border before:size-2.5 before:flex-none before:rounded-[3px] before:border-[1.25px] before:border-quiet before:bg-surface" + data-pagefind-ignore="" + > + {count} + </span> + ) : null} + </p> + <Html as={'h1' as 'span'} id={`${id}-title`} tabIndex={-1} html={s.title} /> + {lede} + {useContext(TocSlot)} + {children} + </article> + ) +} + +export interface SectionView { + article: ReactNode + rail: RailItem[] +} +const OVERVIEW: RailItem = { id: null, html: 'Overview', depth: 1 } + +const blockerLede = (rm: Roadmap) => + "Ranked by how much each one blocks, in the reviewers' judgment, so the order doesn't follow the feature count shown beside each." + + (rm.allSitesBlockers ? ' Every one hits all three sites.' : '') + +// ------------------------------------------------------------------------------ 1 · overview +export function overview(rm: Roadmap): SectionView { + const n = rm.FIDS.length + const T = rm.TOTALS + const F = Object.values(rm.FEATS) + // The intro is a run of <p>s; the first is the lede, and the inline outline goes after it. + const [ledeHtml, ...restHtml] = rm.introParagraphs + const shorts = rm.REPOS.map((r) => <b key={r}>{rm.SHORT[r]}</b>) + const nStudio = rm.STUDIO_NEW.length + rm.STUDIO_LEGACY.length + const nLater = rm.WORK_BY_GROUP.later.length + const nRelease = rm.CHANGES.length - nLater + const head = (id: string, text: string): RailItem => ({ id, html: esc(text), depth: 2 }) + const article = ( + <Article rm={rm} id="roadmap" lede={<Html as="p" html={ledeHtml} />}> + {restHtml.map((h, i) => ( + <Html key={i} as="p" html={h} /> + ))} + {rm.OV.fix_now ? ( + <div className={`${TODO_CALLOUT} border-border bg-bg-subtle`}> + <p className={TODO_CALLOUT_P}> + <span className="sr-only" data-pagefind-ignore=""> + To do:{' '} + </span> + <Kind k="now" className="mr-2 align-[1px]" /> + <Html html={rm.OV.fix_now} /> + </p> + </div> + ) : null} + <SubHeading id="roadmap--the-ten-release-blockers">The ten release blockers</SubHeading> + <p>{blockerLede(rm)} Each links to where it stands today, the plan and the work items that deliver it.</p> + {/* The docs' numbered hairline rows, each with its to-do box before the number. */} + <ol className="my-[18px] list-none border-b border-hairline p-0"> + {rm.TOP.map((g, i) => ( + <li + key={g.id} + data-n={pad(i + 1)} + className={`relative m-0 flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 border-t border-hairline py-3.5 pr-0 pl-[78px] before:absolute before:top-4 before:left-9 before:text-13 before:leading-6 before:text-ol-num before:tabular-nums before:content-[attr(data-n)] ${BOX_AFTER} after:absolute after:top-[19px] after:left-0.5`} + > + <span className="sr-only" data-pagefind-ignore=""> + To do:{' '} + </span> + <ItemLink rm={rm} id={g.id} className="font-medium" /> + <span className="flex-none text-12.5 leading-5 text-muted-fg tabular-nums" data-pagefind-ignore=""> + {g.features.length} features + </span> + </li> + ))} + </ol> + <SubHeading id="roadmap--release-readiness">Release readiness</SubHeading> + <Html as="p" html={rm.OV.readiness_lead} /> + {/* The verdict tiles: the legend and the totals in one. */} + <ul + className="not-prose mx-0 mt-6 mb-2 grid max-w-none list-none grid-cols-[minmax(0,1fr)] gap-px overflow-hidden rounded-2xl border border-border bg-border p-0 print:break-inside-avoid @min-rm:grid-cols-[repeat(5,minmax(0,1fr))]" + aria-label="Features by status" + > + {rm.VERDICTS.map((v) => ( + <li + key={v} + className="m-0 grid grid-cols-[64px_minmax(0,1fr)] items-center gap-x-3 bg-bg px-[18px] py-3.5 @min-rm:flex @min-rm:flex-col @min-rm:items-start @min-rm:gap-3.5 @min-rm:px-4 @min-rm:py-5" + > + <span className={`text-34 leading-none font-light tracking-[-.03em] tabular-nums @min-rm:text-44 ${v === 'done' ? 'text-quiet' : 'text-fg'}`}>{T[v]}</span> + <Pill rm={rm} v={v} className="justify-self-start" /> + <span className="col-2 mt-1.5 text-13 leading-4.5 text-muted-fg @min-rm:-mt-1">{rm.status(v).tile}</span> + </li> + ))} + </ul> + <div className="mt-7 mb-8 grid gap-5"> + <BarRow rm={rm} big label={<b>All three sites</b>} labelText="All three sites" ids={rm.FIDS} /> + {rm.REPOS.map((r) => ( + <BarRow + key={r} + rm={rm} + label={ + <a href={rm.hrefOf(rm.SITE_SEC[r])}> + <b>{rm.NAME[r]}</b> + </a> + } + labelText={rm.NAME[r]} + ids={rm.REPO_IDS[r]} + descHtml={`<span>${rm.REPO_DESC[r]}</span> ${rm.HEADLINE[r]}`} + /> + ))} + </div> + <p className="mt-5 mb-3 text-14 leading-5.5 text-muted-fg"> + Each bar counts the features in its scope: all three sites, then each site. Open work counts the features still to build, finish or leave to site code, by relative effort: S, M or L. Short + names on this page: {join(shorts.slice(0, -1), ', ')} and {shorts[shorts.length - 1]}. + </p> + <div className={`${TODO_CALLOUT} border-warn-border bg-warn-bg`}> + <Html + as="p" + className={`${TODO_CALLOUT_P} [&_.rm-do-t]:mb-1 [&_.rm-do-t]:block [&_.rm-do-t]:w-fit`} + html={`<span class="sr-only" data-pagefind-ignore="">To do: </span>${rm.OV.fix_docs}`} + /> + </div> + <SubHeading id="roadmap--using-this-page">Using this page</SubHeading> + <ul> + <li> + <a href={rm.hrefOf('roadmap-blockers')}>Release blockers</a>: the ten items that gate the release, each with where it stands today, the plan and the work items that deliver it. + </li> + <li> + <a href={rm.hrefOf('roadmap-studio-new')}>Site editor support</a>: what has to work for editors, on a next-major site and with legacy content ({nStudio} items). + </li> + <li> + <a href={rm.hrefOf('roadmap-api')}>Work items</a>: {nRelease} changes, deduplicated from the {n} per-feature proposals, in four groups: the API, the CLI, the site editor and the Deco + API, and these docs. + </li> + <li> + <a href={rm.hrefOf('roadmap-later')}>After the first release</a>: {nLater} follow-up{nLater === 1 ? '' : 's'} planned once the first release ships. They don't block it. + </li> + <li> + <a href={rm.hrefOf('roadmap-storefront')}>Site migrations</a>: each site's plan as a checklist, in execution order ({rm.nSteps} steps). + </li> + <li> + <a href={rm.hrefOf('roadmap-features')}>Feature readiness</a>: every feature with its status, effort, sites and a short summary, the parts of these docs it concerns, and the roadmap + items that address it. Feature chips anywhere on this page link there. + </li> + </ul> + <p> + The lists overlap, so their counts don't add up to one total: the release blockers and most site editor items are delivered by work items, and link to them, and several site steps depend on the + same work. Nothing is checked off yet; the boxes mark open items and don't track progress. + </p> + <Small> + How this list was made: each site's features were catalogued from its code, with file and line evidence, and grouped into {rm.CATS.length} categories. Each feature was then assessed against + these docs, and a second reviewer checked each assessment against the site editor's and the framework's code. That check changed {F.filter((f) => f.first_rated).length} statuses, all to “to finish”, + and a later review changed {F.filter((f) => f.rated_before_review).length} more. {F.filter((f) => f.confidence !== 'high').length} of the {n} assessments are medium confidence and the rest + are high, and {F.filter((f) => f.unconfirmed_sub_claim).length} contain a sub-claim the second reviewer couldn't confirm. Feature readiness marks each of these on its row. + </Small> + </Article> + ) + return { + article, + rail: [OVERVIEW, head('roadmap--the-ten-release-blockers', 'The ten release blockers'), head('roadmap--release-readiness', 'Release readiness'), head('roadmap--using-this-page', 'Using this page')], + } +} + +// ------------------------------------------------------------------------------ 2 · release blockers +export function blockers(rm: Roadmap): SectionView { + const article = ( + <Article + rm={rm} + id="roadmap-blockers" + count={`${rm.TOP.length} to do`} + lede={<p>{blockerLede(rm)} Each says where things stand today, the plan, and the work items that deliver it; the chips link to the affected features.</p>} + > + <ol className={TODO_LIST}> + {rm.TOP.map((g, i) => { + const h = rm.html.blocker.get(g.id)! + return ( + <Todo key={g.id} heading={<TodoHeading id={g.id} title={g.title} n={i + 1} />}> + {rm.allSitesBlockers ? null : ( + <p className={META} data-pagefind-ignore=""> + <SiteTags rm={rm} repos={rm.sitesOf(g.features)} label /> + </p> + )} + <Detail + rows={[ + ['Today', h.today], + ['Plan', h.plan], + [ + 'Delivered by', + <> + {join(g.delivered_by.map((k) => <ItemLink rm={rm} id={k} />))} + {h.note ? ( + <> + {' '} + <Html className="mt-1 block text-14 text-muted-fg" html={h.note} /> + </> + ) : null} + </>, + ], + ]} + /> + <Chips rm={rm} ids={g.features} /> + </Todo> + ) + })} + </ol> + </Article> + ) + return { article, rail: [OVERVIEW, ...rm.TOP.map((g, i) => todoRail(g.id, g.title, i + 1))] } +} + +// ------------------------------------------------------------------------------ 3 · studio support +const STUDIO_LEDE = { + 'roadmap-studio-new': + "What has to work when a migrated site runs against today's site editor. Each item says what happens today and, where a work item's plan covers it, which work items deliver it. The chips link to the features it touches.", + 'roadmap-studio-legacy': + 'Content and assumptions the site editor already has, which the next major has to handle. Each item says what happens today and, where the plan covers it, which work items or site steps handle it.', +} as const + +export function studio(rm: Roadmap, id: 'roadmap-studio-new' | 'roadmap-studio-legacy'): SectionView { + // Unnumbered: unlike the blockers and the site plans, the order isn't a ranking. + const items = id === 'roadmap-studio-new' ? rm.STUDIO_NEW : rm.STUDIO_LEGACY + const article = ( + <Article rm={rm} id={id} count={`${items.length} to do`} lede={<p>{STUDIO_LEDE[id]}</p>}> + <ul className={TODO_LIST}> + {items.map((x) => ( + <Todo key={x.id} heading={<TodoHeading id={x.id} title={x.title} />}> + <Detail + rows={[ + ['Today', rm.html.studio.get(x.id)!.today], + ...(x.delivered_by.length ? [['Delivered by', <>{join(x.delivered_by.map((r) => <RefLink rm={rm} id={r} />))}</>] as [string, ReactNode]] : []), + ]} + /> + <Chips rm={rm} ids={x.features} /> + </Todo> + ))} + </ul> + </Article> + ) + return { article, rail: [OVERVIEW, ...items.map((x) => todoRail(x.id, x.title))] } +} + +// ------------------------------------------------------------------------------ 4 · work items +export function changes(rm: Roadmap, key: WorkGroup): SectionView { + const id = GROUP_SEC[key] + const items = rm.WORK_BY_GROUP[key] + const sort = "They're sorted by how many features need them." + const lede = { + api: ( + <> + {items.length} changes to the SDK, the templates and the <code className="is-short">@decocms/apps-*</code> packages. {sort} + </> + ), + cli: ( + <> + {items.length} changes to the <code className="is-short">deco</code> CLI's output, codegen and migration tooling. {sort} + </> + ), + studio: ( + <> + {items.length} changes on the site editor side and in the Deco API's release service. {sort} + </> + ), + docs: ( + <> + {items.length} changes to the guides, recipes and reference. Two of them correct statements that are wrong today; they come first, and the rest are sorted by how many features need them. + </> + ), + later: ( + <> + {items.length} follow-up{items.length === 1 ? '' : 's'} planned after the first release. None of them blocks the release, and their APIs aren't designed yet. {sort} + </> + ), + }[key] + const article = ( + <Article + rm={rm} + id={id} + count={`${items.length} to do`} + lede={ + <p> + {lede} A chip's dot shows that feature's status: <Key rm={rm} />. + </p> + } + > + <ul className={TODO_LIST}> + {items.map((c) => { + const ids = c.features + // How many of the item's features have the status To build, said as part of the feature + // count, so it doesn't read as the item's own status or as a count of sub-tasks. + const nBuild = ids.filter((x) => rm.FEATS[x].status === 'to-build').length + let nf = `${ids.length} feature${ids.length !== 1 ? 's' : ''}` + if (nBuild) nf += nBuild === ids.length && ids.length === 1 ? ', to build' : `, ${nBuild} of them to build` + const rows: [string, string | ReactNode][] = [['Plan', rm.html.work.get(c.id)!.plan]] + if (c.docs.length) rows.push(['In these docs', <>{join(c.docs.map((x) => <a href={rm.hrefOf(x)}>{rm.docLabel(x)}</a>))}</>]) + const cl = rm.CLEARS.get(c.id) + // One part of the blocker, not all of it: most blockers need two to four work items. + if (cl) rows.push([`Part of blocker${cl.length > 1 ? 's' : ''}`, <>{join(cl.map((i) => <ItemLink rm={rm} id={rm.TOP[i - 1].id} />))}</>]) + return ( + <Todo key={c.id} heading={<TodoHeading id={c.id} title={c.title} />}> + <p className={META} data-pagefind-ignore=""> + <span className="text-fg-body">{nf}</span> + <SiteTags rm={rm} repos={rm.sitesOf(ids)} label /> + </p> + <Detail rows={rows} /> + <Chips rm={rm} ids={ids} /> + </Todo> + ) + })} + </ul> + </Article> + ) + return { article, rail: [OVERVIEW, ...items.map((c) => todoRail(c.id, c.title))] } +} + +// ------------------------------------------------------------------------------ 5 · site migrations +export function sitePlan(rm: Roadmap, r: string): SectionView { + const id = rm.SITE_SEC[r] + const ids = rm.REPO_IDS[r] + const steps = rm.PLAN[r] + const nNote = steps.filter((s) => s.no_action).length + const count = `${steps.length - nNote} to do` + (nNote ? ` · ${nNote} note` : '') + const article = ( + <Article rm={rm} id={id} count={count} lede={<Html as="p" html={rm.HEADLINE[r]} />}> + <Html + as="p" + className="mt-0 mb-2 text-15 leading-[1.6] text-muted-fg" + html={`<span class="mr-2.5 text-12 leading-4.5 font-normal tracking-label uppercase text-eyebrow" data-pagefind-ignore="">The site</span>${rm.REPO_DESC[r]}`} + /> + <div className="mt-7 mb-8 grid gap-5"> + <BarRow rm={rm} big label={<b>{rm.NAME[r]}</b>} labelText={rm.NAME[r]} ids={ids} /> + </div> + <p> + The steps are in execution order, each tagged: <b>Fix now</b> (a live bug, migration or not), <b>Before migrating</b> (an upgrade that comes first), <b>Blocker</b> (must be solved before this + site can move; separate from the ten release blockers), <b>Site work</b>, <b>Content</b> (stored content and data shapes), and <b>Note</b>.{nNote ? ' A note with nothing to do has no box.' : ''}{' '} + <a href={rm.hrefOf('roadmap-features')} data-gx-site={rm.SHORT[r]}> + See where the {ids.length} features it uses stand + </a> + . + </p> + <ol className={TODO_LIST}> + {steps.map((s, i) => ( + <Todo key={s.id} note={s.no_action} heading={<TodoHeading id={s.id} title={s.title} n={i + 1} note={s.no_action} />}> + <p className={META} data-pagefind-ignore=""> + <Kind k={s.kind} /> + </p> + <Html as="p" className="mt-0" html={rm.html.step.get(s.id)!.text} /> + <Chips rm={rm} ids={s.features} /> + </Todo> + ))} + </ol> + </Article> + ) + return { article, rail: [OVERVIEW, ...steps.map((s, i) => todoRail(s.id, s.title, i + 1))] } +} + +// ------------------------------------------------------------------------------ 6 · feature readiness +/** The status/site filter of the feature list: `v` a status code ("g"…), `s` a site short name. */ +export interface FeatureFilter { + v: string + s: string +} +export const NO_FILTER: FeatureFilter = { v: '', s: '' } + +export function featureHit(rm: Roadmap, fid: string, k: 'v' | 's', val: string): boolean { + if (!val) return true + const f = rm.FEATS[fid] + return k === 'v' ? VC[f.status] === val : f.sites.some((r) => rm.SHORT[r] === val) +} +const shown = (rm: Roadmap, fid: string, f: FeatureFilter) => featureHit(rm, fid, 'v', f.v) && featureHit(rm, fid, 's', f.s) + +/** The categories' headings that the filter leaves visible, for the rail. */ +export function featureRail(rm: Roadmap, filter: FeatureFilter): RailItem[] { + return [ + OVERVIEW, + ...rm.CATS.filter((c) => rm.featuresIn(c.id).some((fid) => shown(rm, fid, filter))).map((c) => ({ id: `roadmap-features--${slug(c.title)}`, html: esc(c.title), depth: 2 as const })), + ] +} + +/** A feature row's assessment tag (Medium confidence, First rated …). */ +const TAG = 'gx-tg inline-flex h-5 items-center rounded-full px-2 text-11.5 leading-4 whitespace-nowrap text-muted-fg not-italic inset-ring inset-ring-border-strong' + +function RowTags({ rm, fid }: { rm: Roadmap; fid: string }) { + // The earlier statuses are ratings the assessment gave and later corrected, not progress that + // was undone, so the tags name the rating ("First rated …") rather than a past state ("Was …"). + const f = rm.FEATS[fid] + return ( + <> + {f.confidence !== 'high' ? <i className={TAG}>Medium confidence</i> : null} + {f.first_rated ? ( + <i className={TAG} title="The assessor's first rating; verification against the code changed it"> + First rated {rm.vq(f.first_rated)} + </i> + ) : null} + {f.rated_before_review ? ( + <i className={TAG} title="The status a later review changed"> + Rated {rm.vq(f.rated_before_review)} before review + </i> + ) : null} + {f.unconfirmed_sub_claim ? ( + <i className={TAG} title="Contains a sub-claim the verifier couldn't confirm"> + Unconfirmed sub-claim + </i> + ) : null} + </> + ) +} + +/** The filter's groups and their labels. */ +const FG = 'flex flex-wrap items-center gap-1.5' +const FL = `mr-1 min-w-14 ${LABEL}` +/** A feature row's link runs: where these docs cover it, and what addresses it; the labels are CSS so each row stays small. */ +const FX_RUN = `mb-0 max-w-none text-13 leading-[21px] text-muted-fg before:mr-2.5 before:text-11.5 before:tracking-label before:text-eyebrow before:uppercase ${RUN}` +/** Inline padding: a 24px target (WCAG 2.5.8) without moving the lines. */ +const RUN_LINK = 'py-1 text-13' + +export function features(rm: Roadmap, filter: FeatureFilter = NO_FILTER, setFilter?: (f: FeatureFilter) => void): SectionView { + const T = rm.TOTALS + const all = rm.FIDS + const nShown = all.filter((fid) => shown(rm, fid, filter)).length + const countText = nShown === all.length ? `Showing all ${nShown} features` : nShown ? `Showing ${nShown} of ${all.length} features` : 'No feature matches both filters' + /** A filter button: pressed state, and the rows it would show given the other filter. */ + const btn = (k: 'v' | 's', val: string, children: ReactNode, extra?: { title?: string; count?: boolean }) => { + const other = k === 'v' ? 's' : 'v' + const n = all.filter((fid) => featureHit(rm, fid, k, val) && featureHit(rm, fid, other, filter[other])).length + return ( + <button + key={val || 'all'} + type="button" + className="inline-flex h-8 cursor-pointer items-center gap-[7px] rounded-full border border-border bg-surface px-[13px] text-13 leading-4.5 text-muted-fg [transition:color_.25s_ease,border-color_.25s_ease,background-color_.25s_ease,scale_.25s_var(--ease-out-quart)] hover:border-border-strong hover:text-fg active:scale-[.97] aria-pressed:border-transparent aria-pressed:bg-fg aria-pressed:text-bg max-sm:h-[30px] max-sm:px-[11px]" + data-fk={k} + data-fv={val} + aria-pressed={filter[k] === val} + title={extra?.title} + onClick={() => setFilter?.({ ...filter, [k]: val })} + > + {children} + {extra?.count === false ? null : ( + <> + {' '} + <span className="text-quiet tabular-nums in-aria-pressed:text-inherit in-aria-pressed:opacity-80">{n}</span> + </> + )} + </button> + ) + } + const done = T.done + const gone = T['goes-away'] + const article = ( + <Article + rm={rm} + id="roadmap-features" + count={`${done} of ${all.length - gone} done · ${gone} go away`} + lede={ + <p> + All {all.length} features the three sites use, grouped into {rm.CATS.length} categories, and where each one stands. Within a category, features to build come first, then those to finish, + left to site code and going away, each ordered by effort (L, M, S). Each row names the parts of these docs the feature concerns and, below that, the release blockers and work items that + address it. Feature chips elsewhere on this page link to these rows. + </p> + } + > + {/* The status legend: one status per line, label (with its marker) then meaning. */} + <dl className="mt-5 mb-0 grid gap-1 text-14 leading-5.5 @max-[460px]:gap-2" aria-label="Statuses"> + {rm.VERDICTS.map((v) => ( + <div key={v} className="grid grid-cols-[7.5rem_minmax(0,1fr)] gap-x-3 @max-[460px]:grid-cols-[minmax(0,1fr)]"> + <dt className="m-0"> + <b className={`${LG} font-medium text-fg`} data-v={VC[v]}> + {rm.VLABEL(v)} + </b> + </dt> + <Html as="dd" className="m-0 text-muted-fg" html={rm.status(v).legend} /> + </div> + ))} + </dl> + {/* Without JavaScript the filter does nothing, so it's hidden unless <html> has the `js` class + (set by the inline head script before first paint, so it never shifts the list). */} + <div className="not-prose mt-7 mb-2 flex flex-col gap-2.5 rounded-2xl border border-border bg-bg-subtle p-3.5 no-js:hidden max-sm:p-3 print:hidden" data-pagefind-ignore=""> + <div className={FG} role="group" aria-label="Filter by status"> + <span className={FL}>Status</span> + {btn('v', '', 'All statuses', { count: false })} + {rm.VERDICTS.filter((v) => T[v]).map((v) => + btn( + 'v', + VC[v], + <> + <i data-v={VC[v]} className={`size-[7px] flex-none rounded-full bg-(--vc) data-[v=g]:rounded-[1px] data-[v=g]:[transform:rotate(45deg)_scale(.9)] ${vvars(VC[v])}`} /> + {rm.VLABEL(v)} + </>, + ), + )} + </div> + <div className={`${FG} border-t border-hairline pt-2.5`} role="group" aria-label="Filter by site"> + <span className={FL}>Site</span> + {btn('s', '', 'All sites', { count: false })} + {rm.REPOS.map((r) => btn('s', rm.SHORT[r], rm.SHORT[r], { title: rm.NAME[r] }))} + </div> + <p className="m-0 text-13 leading-5 text-muted-fg" aria-live="polite"> + {countText} + </p> + </div> + {rm.CATS.map((cat) => { + const ids = rm.featuresIn(cat.id) + // A category with no matching rows hides whole: heading, description and list. + const off = !ids.some((fid) => shown(rm, fid, filter)) + const hid = `roadmap-features--${slug(cat.title)}` + return ( + <Fragment key={cat.id}> + <SubHeading id={hid} className="mt-14 text-25 max-sm:mt-11 max-sm:text-22" hidden={off}> + {cat.title} + </SubHeading> + <p className="mt-0 mb-4 text-14 leading-5.5 text-muted-fg" hidden={off}> + {ids.length} feature{ids.length !== 1 ? 's' : ''} · {cat.description} + <span className="mt-1.5 flex flex-wrap gap-x-3.5 gap-y-0.5 text-12.5" data-pagefind-ignore=""> + <Legend rm={rm} ids={ids} /> + </span> + </p> + {/* Rows bleed evenly into the margin, so their text lines up with the column. */} + <ul className="-mx-3 my-0 max-w-none list-none border-t border-hairline p-0 max-sm:-mx-2" hidden={off}> + {ids.map((fid) => { + const f = rm.feature(fid) + const back = rm.BACK.get(fid) + return ( + <li + key={fid} + id={`roadmap-f-${fid}`} + data-v={f.v} + hidden={!shown(rm, fid, filter)} + className="feature-row" + > + <h3 className="m-0 text-16 leading-[23px] font-medium tracking-snug text-wrap text-fg @min-rm:col-1 @min-rm:row-1">{f.name}</h3> + <p + className="m-0 flex max-w-none flex-wrap items-center gap-x-3 gap-y-1 text-12.5 leading-5 text-muted-fg @min-rm:col-1 @min-rm:row-2 @min-rm:self-start" + data-pagefind-ignore="" + > + <Pill rm={rm} v={f.status} /> + <span>Effort {f.effort}</span> + <SiteTags rm={rm} repos={f.sites} /> + <RowTags rm={rm} fid={fid} /> + </p> + <div className="@min-rm:col-2 @min-rm:row-span-2 @min-rm:row-start-1 @min-rm:self-start"> + <Html as="p" className="m-0 max-w-none text-15 leading-[1.62] text-fg-body [&_code]:text-12.5" html={f.summaryHtml} /> + {f.docLinks.length ? ( + <p className={`${FX_RUN} mt-2 before:content-['In_these_docs']`} data-pagefind-ignore=""> + {join(f.docLinks.map((l) => <a href={l.href} className={RUN_LINK}>{l.label}</a>))} + </p> + ) : null} + {back ? ( + <p className={`${FX_RUN} mt-0.5 before:content-['Addressed_by']`} data-pagefind-ignore=""> + {back.map((b) => ( + <Fragment key={b.id}> + {b.n ? ( + <a href={rm.hrefOf(b.id)} title={plain(b.title)} className={RUN_LINK}> + Blocker {pad(b.n)} + </a> + ) : ( + <Html as="a" href={rm.hrefOf(b.id)} className={RUN_LINK} html={b.title} /> + )}{' '} + </Fragment> + ))} + </p> + ) : null} + </div> + </li> + ) + })} + </ul> + </Fragment> + ) + })} + </Article> + ) + return { article, rail: featureRail(rm, filter) } +} + +/** A section's view (features with its filter state). */ +export function sectionView(rm: Roadmap, id: SectionId, filter?: FeatureFilter, setFilter?: (f: FeatureFilter) => void): SectionView { + switch (id) { + case 'roadmap': + return overview(rm) + case 'roadmap-blockers': + return blockers(rm) + case 'roadmap-studio-new': + case 'roadmap-studio-legacy': + return studio(rm, id) + case 'roadmap-api': + return changes(rm, 'api') + case 'roadmap-cli': + return changes(rm, 'cli') + case 'roadmap-platform': + return changes(rm, 'studio') + case 'roadmap-docs': + return changes(rm, 'docs') + case 'roadmap-later': + return changes(rm, 'later') + case 'roadmap-features': + return features(rm, filter, setFilter) + default: { + const site = rm.REPOS.find((r) => rm.SITE_SEC[r] === id)! + return sitePlan(rm, site) + } + } +} diff --git a/docs/components/roadmap/index.tsx b/docs/components/roadmap/index.tsx new file mode 100644 index 00000000..cdec428b --- /dev/null +++ b/docs/components/roadmap/index.tsx @@ -0,0 +1,7 @@ +/** + * The Roadmap: the to-do list for the next major, one page per section (see sections.ts for the + * URLs). Rendered from data/roadmap.json (model.ts checks it; SectionViews.tsx renders it); the routes + * are src/routes/roadmap/index.tsx (/roadmap/, the overview) and src/routes/roadmap/$section.tsx. + */ +export { default } from './RoadmapPage' +export { roadmapDocumentTitle, roadmapHref, roadmapTarget, sectionForSlug, sectionPath, ROADMAP_PATHS, ROADMAP_ROOT } from './sections' diff --git a/docs/components/roadmap/instance.ts b/docs/components/roadmap/instance.ts new file mode 100644 index 00000000..60a0c66a --- /dev/null +++ b/docs/components/roadmap/instance.ts @@ -0,0 +1,9 @@ +/** The app's Roadmap model: data/roadmap.json with the next major's docs pages from the content manifest. */ +import data from '~/data/roadmap.json' +import { manifest } from '~/src/lib/content' +import { createRoadmap, docLabelsFromManifest, type RoadmapData } from './model' + +export const roadmap = createRoadmap(data as unknown as RoadmapData, { + docs: docLabelsFromManifest(manifest), + base: import.meta.env.BASE_URL, +}) diff --git a/docs/components/roadmap/model.ts b/docs/components/roadmap/model.ts new file mode 100644 index 00000000..b157d318 --- /dev/null +++ b/docs/components/roadmap/model.ts @@ -0,0 +1,486 @@ +/** + * The Roadmap's data model: data/roadmap.json, checked and turned into what the pages render. + * A port of the former Python Roadmap generator's data layer (removed). Pure (no React, no router, no Vite), so the app, + * scripts/check-roadmap.ts and vite.config.ts can all use it. + * + * Data errors (unknown ids, broken references, counts the copy depends on) throw a RoadmapDataError + * with the old script's messages, which fails the build. Links into the docs that don't resolve to + * a page yet are collected in `docProblems` instead (the docs are ported page by page), and + * scripts/check-roadmap.ts reports them. + * + * HTML fields of the data (titles, Today/Plan text, the overview copy) may contain `<code>`, links + * and `{{item:ID}}`. Links are written as the old single page's `#id` and rewritten here to real + * URLs (see sections.ts): `#roadmap-…` to the Roadmap page holding that id, anything else to the + * docs page of the next major (`#studio-compatibility` -> /next/studio-compatibility, `#releases-and-deployment--publishing` -> + * /next/releases-and-deployment#publishing). + */ +import { docsHref, roadmapHref, SECTION_IDS, type SectionId } from './sections' + +// ------------------------------------------------------------------------------ data shapes +export interface StatusData { + id: string + label: string + word: string + word_one: string + tile: string + legend: string +} +export interface SectionData { + id: SectionId + nav: string + eyebrow: string + title: string +} +export interface FeatureData { + name: string + category: string + status: string + effort: 'L' | 'M' | 'S' + sites: string[] + summary: string + docs: string[] + confidence: string + first_rated: string | null + rated_before_review: string | null + unconfirmed_sub_claim: boolean +} +export interface BlockerData { + id: string + title: string + today: string + plan: string + features: string[] + delivered_by: string[] + delivered_note?: string +} +export interface StudioItemData { + id: string + title: string + today: string + features: string[] + delivered_by: string[] +} +export interface WorkItemData { + id: string + group: 'api' | 'cli' | 'studio' | 'docs' | 'later' + title: string + plan: string + features: string[] + docs: string[] + pinned?: boolean +} +export interface StepData { + id: string + kind: keyof typeof KIND + title: string + text: string + features: string[] + no_action: boolean +} +export interface SiteData { + id: string + name: string + short: string + section: SectionId + description: string + headline: string + steps: StepData[] +} +export interface CategoryData { + id: string + title: string + description: string +} +export interface RoadmapData { + statuses: StatusData[] + sections: SectionData[] + overview: { intro: string; readiness_lead: string; fix_docs: string; fix_now?: string } + blockers: BlockerData[] + studio_new: StudioItemData[] + studio_legacy: StudioItemData[] + work_items: WorkItemData[] + sites: SiteData[] + categories: CategoryData[] + features: Record<string, FeatureData> +} + +// ------------------------------------------------------------------------------ constants +/** Work-item groups -> their sections. `later` holds follow-ups planned after the first release (not release work). */ +export const GROUP_SEC = { api: 'roadmap-api', cli: 'roadmap-cli', studio: 'roadmap-platform', docs: 'roadmap-docs', later: 'roadmap-later' } as const +export type WorkGroup = keyof typeof GROUP_SEC +/** Status id -> its one-letter code (data-v="…"; SectionViews.tsx maps it to the --gx-* colours). */ +export const VC: Record<string, string> = { 'to-build': 'g', 'to-finish': 'p', 'site-code': 'a', done: 'c', 'goes-away': 'n' } +/** Statuses that count as open work (the "Open work" effort tally). */ +export const OPEN = new Set(['to-build', 'to-finish', 'site-code']) +/** Site-step kinds. The site-migration lede names these labels. */ +export const KIND = { now: 'Fix now', pre: 'Before migrating', blocker: 'Blocker', work: 'Site work', content: 'Content', fix: 'Note' } as const +export const EFF_RANK: Record<string, number> = { L: 0, M: 1, S: 2 } +/** The page says "the ten release blockers" in several places. */ +export const N_BLOCKERS = 10 + +export class RoadmapDataError extends Error {} + +export function check(cond: unknown, msg: string): asserts cond { + if (!cond) throw new RoadmapDataError(`roadmap: ${msg}`) +} + +// ------------------------------------------------------------------------------ text helpers +export const esc = (s: string) => + String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''') + +const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' } +export const unescapeHtml = (s: string) => + s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e: string) => + e[0] === '#' ? String.fromCodePoint(e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)) : (ENTITIES[e.toLowerCase()] ?? m), + ) + +/** Visible text of an HTML fragment. */ +export const plain = (s: string) => unescapeHtml(s.replace(/<[^>]+>/g, '')) + +/** Same rule as the docs' heading slugs (build/slugify.ts), applied to plain text. */ +export function slug(text: string): string { + return ( + plain(text) + .toLowerCase() + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'section' + ) +} + +/** `<code>` of at most 24 characters gets `class="is-short"` (white-space: nowrap, src/styles/base.css). */ +export const shortCode = (html: string) => + html.replace(/<code>([\s\S]*?)<\/code>/g, (m, inner: string) => (plain(inner).trim().length <= 24 ? `<code class="is-short">${inner}</code>` : m)) + +// ------------------------------------------------------------------------------ the model +export interface DocPageInfo { + /** The docs page's sidebar label (the old data-nav). */ + label: string +} + +export interface RoadmapOptions { + /** + * The next major's docs pages by old section id (= file name): `studio-compatibility` -> its label. Built from + * the content manifest (docLabelsFromManifest). Ids missing here are reported in docProblems. + */ + docs: Map<string, DocPageInfo> + /** Base path prepended to every internal link ("/" or "/blocks/"). */ + base?: string +} + +export interface BackLink { + /** Roadmap id of the blocker or work item. */ + id: string + /** Blocker number (1-based), for blockers. */ + n?: number + title: string +} + +export type Roadmap = ReturnType<typeof createRoadmap> + +export function createRoadmap(input: RoadmapData, opts: RoadmapOptions) { + // Titles and HTML fields get rewritten below; never touch the caller's (imported) object. + const d: RoadmapData = structuredClone(input) + const base = opts.base ?? '/' + const docProblems = new Set<string>() + + // --- links ------------------------------------------------------------------------------- + /** An internal link target: Roadmap ids to their page, anything else to the docs. */ + const hrefOf = (id: string): string => { + const rm = roadmapHref(id) + if (rm) return base + rm.slice(1) + const page = id.split('--')[0] + if (!opts.docs.has(page)) docProblems.add(`link to #${id}: no docs page "${page}" (content/next/${page}.mdx)`) + return base + docsHref(id).slice(1) + } + /** + * Rewrites the old `href="#id"` links of an HTML field to real URLs, and marks short inline code + * (≤ 24 characters) `is-short` so it never wraps, as the old page script did for every section. + */ + const links = (html: string) => shortCode(html).replace(/href="#([^"]*)"/g, (_, id: string) => `href="${hrefOf(id)}"`) + + // --- statuses ---------------------------------------------------------------------------- + const VERDICTS = d.statuses.map((s) => s.id) + check(JSON.stringify(VERDICTS) === JSON.stringify(Object.keys(VC)), `statuses must be ${JSON.stringify(Object.keys(VC))} in that order, got ${JSON.stringify(VERDICTS)}`) + const ST = Object.fromEntries(d.statuses.map((s) => [s.id, s])) + for (const s of d.statuses) s.legend = links(s.legend) + const VLABEL = (v: string) => ST[v].label + const vword = (v: string, n: number) => (n === 1 ? ST[v].word_one : ST[v].word) + const vq = (v: string) => `“${ST[v].word_one}”` + + // --- titles ------------------------------------------------------------------------------ + const checkTitle = (t: string, where: string) => { + // Titles are HTML: plain text, entities and <code> only. + const rest = t.replace(/<\/?code>/g, '') + check(t && t.trim() === t, `${where}: empty title or stray whitespace`) + check(!/[<>]|&(?!(?:amp|lt|gt|quot|#\d+);)/.test(rest), `${where}: title has markup other than <code>: ${JSON.stringify(t)}`) + } + + // --- sections ---------------------------------------------------------------------------- + check(JSON.stringify(d.sections.map((s) => s.id)) === JSON.stringify(SECTION_IDS), "sections must list the page's section ids in order") + for (const s of d.sections) { + check(s.nav && s.eyebrow, `section ${s.id} needs nav and eyebrow`) + checkTitle(s.title, `section ${s.id}`) + } + const SEC = Object.fromEntries(d.sections.map((s) => [s.id, s])) as Record<SectionId, SectionData> + + // --- features ---------------------------------------------------------------------------- + const CATS = d.categories + const catIds = new Set(CATS.map((c) => c.id)) + const FEATS = d.features + const FIDS = Object.keys(FEATS) + const REPOS = d.sites.map((s) => s.id) + for (const [fid, f] of Object.entries(FEATS)) { + check(catIds.has(f.category), `feature ${fid}: unknown category ${JSON.stringify(f.category)}`) + check(f.status in VC, `feature ${fid}: unknown status ${JSON.stringify(f.status)}`) + check(f.effort in EFF_RANK, `feature ${fid}: effort must be L, M or S`) + check(f.sites.length && f.sites.every((s) => REPOS.includes(s)), `feature ${fid}: unknown sites ${JSON.stringify(f.sites)}`) + for (const k of ['first_rated', 'rated_before_review'] as const) check(f[k] === null || f[k]! in VC, `feature ${fid}: ${k} must be a status id or null`) + // Every feature names at least one part of these docs. + check(f.docs.length, `feature ${fid}: no docs ids`) + check(!/[<>]/.test(f.name), `feature ${fid}: the name is plain text`) + } + const counts = (ids: string[]): [string, number][] => { + const c: Record<string, number> = {} + for (const i of ids) c[FEATS[i].status] = (c[FEATS[i].status] ?? 0) + 1 + return VERDICTS.map((v) => [v, c[v] ?? 0]) + } + const TOTALS = Object.fromEntries(counts(FIDS)) as Record<string, number> + const REPO_IDS = Object.fromEntries(REPOS.map((r) => [r, FIDS.filter((fid) => FEATS[fid].sites.includes(r))])) + + const SHORT = Object.fromEntries(d.sites.map((s) => [s.id, s.short])) + /** The name the page shows for a site (bars, the intro's links, the site filter's tooltip). */ + const NAME = Object.fromEntries(d.sites.map((s) => [s.id, s.name])) + for (const s of d.sites) check(!/[<>&]/.test(s.name + s.short), `site ${s.id}: name and short are plain text`) + const SITE_SEC = Object.fromEntries(d.sites.map((s) => [s.id, s.section])) as Record<string, SectionId> + check( + JSON.stringify(Object.values(SITE_SEC)) === JSON.stringify(['roadmap-storefront', 'roadmap-blog', 'roadmap-faststore']), + 'sites must map, in order, to the roadmap-storefront, roadmap-blog and roadmap-faststore sections', + ) + const REPO_DESC = Object.fromEntries(d.sites.map((s) => [s.id, links(s.description)])) + + // --- to-dos: every one has an explicit id (its anchor); titles are looked up by id --------- + const TITLE = new Map<string, string>() + const checkFeatures = (ids: string[], where: string) => { + const bad = ids.filter((i) => !(i in FEATS)) + check(ids.length && !bad.length, `${where}: unknown or missing feature ids ${JSON.stringify(bad)}`) + } + const reg = (item: { id: string; title: string; features: string[] }, prefix: string, what: string) => { + check(item.id.startsWith(`${prefix}--`), `${what} ${JSON.stringify(item.id)} must start with ${prefix}--`) + check(!TITLE.has(item.id), `duplicate id ${JSON.stringify(item.id)}`) + checkTitle(item.title, `${what} ${item.id}`) + checkFeatures(item.features, `${what} ${item.id}`) + TITLE.set(item.id, item.title) + } + + const TOP = d.blockers + for (const b of TOP) reg(b, 'roadmap-blockers', 'blocker') + check(TOP.length === N_BLOCKERS, `the page says 'ten release blockers'; the data has ${TOP.length}`) + const STUDIO_NEW = d.studio_new + const STUDIO_LEGACY = d.studio_legacy + for (const x of STUDIO_NEW) reg(x, 'roadmap-studio-new', 'Studio item') + for (const x of STUDIO_LEGACY) reg(x, 'roadmap-studio-legacy', 'Studio item') + const CHANGES = d.work_items + for (const c of CHANGES) { + check(c.group in GROUP_SEC, `work item ${c.id}: unknown group ${JSON.stringify(c.group)}`) + reg(c, GROUP_SEC[c.group], 'work item') + } + const WORK = new Map(CHANGES.map((c) => [c.id, c])) + const PLAN: Record<string, StepData[]> = {} + /** step id -> its site, number and step. */ + const STEP = new Map<string, { site: string; n: number; step: StepData }>() + for (const s of d.sites) { + PLAN[s.id] = s.steps + s.steps.forEach((step, i) => { + reg(step, s.section, 'site step') + check(step.kind in KIND, `site step ${step.id}: unknown kind ${JSON.stringify(step.kind)}`) + // A Note with nothing to do (the migration removes it) is drawn without a box and isn't counted. + check(!step.no_action || step.kind === 'fix', `site step ${step.id}: only a Note can carry no action`) + STEP.set(step.id, { site: s.id, n: i + 1, step }) + }) + } + + // Titles were checked as written; from here on they carry the is-short class on short code. + for (const s of d.sections) s.title = shortCode(s.title) + for (const item of [...TOP, ...STUDIO_NEW, ...STUDIO_LEGACY, ...CHANGES, ...[...STEP.values()].map((x) => x.step)]) { + item.title = shortCode(item.title) + TITLE.set(item.id, item.title) + } + + // Work items delivering each blocker (each is one part of it, not all of it), and back. + const CLEARS = new Map<string, number[]>() + TOP.forEach((b, i) => { + for (const k of b.delivered_by) { + check(WORK.has(k), `blocker ${b.id}: delivered_by ${JSON.stringify(k)} is not a work item`) + CLEARS.set(k, [...(CLEARS.get(k) ?? []), i + 1]) + } + }) + // Studio items point to work items or site steps; a pointer must share at least one feature with the item. + for (const x of [...STUDIO_NEW, ...STUDIO_LEGACY]) { + for (const ref of x.delivered_by) { + check(WORK.has(ref) || STEP.has(ref), `Studio item ${x.id}: delivered_by ${JSON.stringify(ref)} is not a work item or site step`) + const ids = WORK.get(ref)?.features ?? STEP.get(ref)!.step.features + check( + ids.some((i) => x.features.includes(i)), + `Studio item ${x.id}: ${JSON.stringify(ref)} shares no feature with it`, + ) + } + } + + // {{item:ID}} in any HTML field becomes a link to that to-do, named by its current title; then + // every #id link becomes a real URL. + const expand = (s: string) => + links( + s.replace(/\{\{item:([a-z0-9-]+)\}\}/g, (_, id: string) => { + check(TITLE.has(id), `{{item:${id}}} names no to-do`) + return `<a href="#${id}">${TITLE.get(id)}</a>` + }), + ) + const html = { + blocker: new Map(TOP.map((b) => [b.id, { today: expand(b.today), plan: expand(b.plan), note: b.delivered_note ? expand(b.delivered_note) : '' }])), + studio: new Map([...STUDIO_NEW, ...STUDIO_LEGACY].map((x) => [x.id, { today: expand(x.today) }])), + work: new Map(CHANGES.map((c) => [c.id, { plan: expand(c.plan) }])), + step: new Map([...STEP.values()].map(({ step }) => [step.id, { text: expand(step.text) }])), + } + const HEADLINE = Object.fromEntries(d.sites.map((s) => [s.id, expand(s.headline)])) + const OV = Object.fromEntries(Object.entries(d.overview).map(([k, v]) => [k, expand(v)])) as RoadmapData['overview'] + /** A to-do's title (HTML: text and <code>). */ + const title = (id: string) => TITLE.get(id)! + + // The intro is a run of paragraphs (the first is the page's lede). + const introParagraphs = [...OV.intro.trim().matchAll(/<p>([\s\S]*?)<\/p>/g)].map((x) => x[1]) + check(introParagraphs.length && OV.intro.trim().replace(/<p>[\s\S]*?<\/p>/g, '').trim() === '', 'overview.intro must be one or more <p> paragraphs') + + // The overview's intro and readiness line are copy; fail if the numbers or links move under them. + OV.intro = OV.intro.trim() + OV.readiness_lead = OV.readiness_lead.trim() + for (const s of [ + ...REPOS.map((r) => `<a href="${hrefOf(SITE_SEC[r])}">${NAME[r]}</a>`), + `The <a href="${hrefOf('roadmap-blockers')}">ten release blockers</a> gate the release`, + `<a href="${hrefOf('roadmap--release-readiness')}">Release readiness</a>`, + ]) + check(OV.intro.includes(s), `overview.intro must contain ${JSON.stringify(s)}`) + for (const s of [ + `None of the ${FIDS.length} features`, + `${TOTALS['to-build']} are still to build`, + `${TOTALS['to-finish']} are to finish`, + `${TOTALS['site-code']} are left to site code`, + `${TOTALS['goes-away']} go away`, + ]) + check(OV.readiness_lead.includes(s), `overview.readiness_lead must contain ${JSON.stringify(s)} (the counts come from the features)`) + + // Docs ids: every one must be a page of the next major. + const docLabel = (id: string): string => { + return opts.docs.get(id)?.label ?? id + } + for (const [fid, f] of Object.entries(FEATS)) for (const x of f.docs) if (!opts.docs.has(x)) docProblems.add(`feature ${fid}: docs id "${x}" has no page (content/next/${x}.mdx)`) + for (const c of CHANGES) for (const x of c.docs) if (!opts.docs.has(x)) docProblems.add(`work item ${c.id}: docs id "${x}" has no page (content/next/${x}.mdx)`) + + // --- derived lists ----------------------------------------------------------------------- + const sitesOf = (ids: string[]) => REPOS.filter((r) => ids.some((x) => FEATS[x].sites.includes(r))) + const allSitesBlockers = TOP.every((g) => JSON.stringify(sitesOf(g.features)) === JSON.stringify(REPOS)) + + /** A group's work items: pinned first (data order), then by how many features need them. */ + const workItems = (key: WorkGroup) => { + const items = CHANGES.filter((c) => c.group === key) + .map((c, i) => ({ c, k: c.pinned ? [0, 0, i] : [1, -c.features.length, i] })) + .sort((a, b) => a.k[0] - b.k[0] || a.k[1] - b.k[1] || a.k[2] - b.k[2]) + .map((x) => x.c) + const nPinned = items.filter((c) => c.pinned).length + check((key === 'docs' && nPinned === 2) || (key !== 'docs' && nPinned === 0), 'exactly two docs work items are pinned (the docs lede says so), and no other') + return items + } + const WORK_BY_GROUP = Object.fromEntries((Object.keys(GROUP_SEC) as WorkGroup[]).map((k) => [k, workItems(k)])) as Record<WorkGroup, WorkItemData[]> + + /** + * Feature -> the release blockers and work items whose chips name it ("Addressed by"), in page + * order: blockers, then the work-item sections (each in its rendered order). + */ + const BACK = new Map<string, BackLink[]>() + const addBack = (fid: string, b: BackLink) => BACK.set(fid, [...(BACK.get(fid) ?? []), b]) + TOP.forEach((b, i) => b.features.forEach((f) => addBack(f, { id: b.id, n: i + 1, title: b.title }))) + for (const key of ['api', 'cli', 'studio', 'docs', 'later'] as const) for (const c of WORK_BY_GROUP[key]) c.features.forEach((f) => addBack(f, { id: c.id, title: c.title })) + + /** Features in a category, in the readiness order: status rank, effort, name. */ + const VRANK = Object.fromEntries(VERDICTS.map((v, i) => [v, i])) + const featuresIn = (cat: string) => + FIDS.filter((fid) => FEATS[fid].category === cat).sort((a, b) => { + const fa = FEATS[a] + const fb = FEATS[b] + return VRANK[fa.status] - VRANK[fb.status] || EFF_RANK[fa.effort] - EFF_RANK[fb.effort] || (fa.name < fb.name ? -1 : fa.name > fb.name ? 1 : 0) + }) + + /** `code` and [label](#id) links in a feature summary (escaped first). */ + const md = (s: string) => + links( + esc(s) + .replace(/`([^`]+)`/g, '<code>$1</code>') + .replace(/\[([^\]]+)\]\(#([a-z0-9-]+)\)/g, '<a href="#$2">$1</a>'), + ) + + const feature = (fid: string) => { + const f = FEATS[fid] + return { id: fid, ...f, v: VC[f.status], summaryHtml: md(f.summary), docLinks: f.docs.map((x) => ({ href: hrefOf(x), label: docLabel(x) })) } + } + + const nSteps = Object.values(PLAN).reduce((n, s) => n + s.length, 0) + const nNotes = Object.values(PLAN).reduce((n, s) => n + s.filter((x) => x.no_action).length, 0) + + return { + data: d, + base, + docProblems, + hrefOf, + links, + expand, + VERDICTS, + VLABEL, + vword, + vq, + status: (v: string) => ST[v], + SEC, + CATS, + FEATS, + FIDS, + REPOS, + TOTALS, + REPO_IDS, + SHORT, + NAME, + SITE_SEC, + REPO_DESC, + HEADLINE, + OV, + introParagraphs, + TOP, + STUDIO_NEW, + STUDIO_LEGACY, + CHANGES, + WORK, + WORK_BY_GROUP, + PLAN, + STEP, + CLEARS, + BACK, + html, + title, + docLabel, + counts, + sitesOf, + allSitesBlockers, + featuresIn, + feature, + nSteps, + nNotes, + } +} + +/** Docs labels from the content manifest: the next major's pages, by file name. */ +export function docLabelsFromManifest(manifest: { + versions: Record<string, { pages: { slug: string; nav: string; kind: string }[] } | undefined> +}): Map<string, DocPageInfo> { + const pages = manifest.versions.next?.pages ?? [] + // The Under-the-hood overview's sidebar label is "Overview"; the Roadmap names it by its tab. + return new Map(pages.filter((p) => p.slug).map((p) => [p.slug, { label: p.kind === 'internals' && p.nav === 'Overview' ? 'Under the hood' : p.nav }])) +} diff --git a/docs/components/roadmap/sections.ts b/docs/components/roadmap/sections.ts new file mode 100644 index 00000000..ef5af2f7 --- /dev/null +++ b/docs/components/roadmap/sections.ts @@ -0,0 +1,137 @@ +/** + * The Roadmap's pages and URLs. Pure TS with no imports, so vite.config.ts (the prerender list) + * and the check scripts can use it as well as the app. + * + * The old single page showed one Roadmap section at a time; each section is now its own page: + * + * roadmap -> /roadmap/ (the overview) + * roadmap-blockers -> /roadmap/blockers + * roadmap-api -> /roadmap/api … and so on: the section id minus "roadmap-". + * + * Every id inside a section keeps its old value and becomes a #fragment of that section's page: + * + * #roadmap-api--add-a-revision-handle -> /roadmap/api#roadmap-api--add-a-revision-handle + * #roadmap--release-readiness -> /roadmap/#roadmap--release-readiness + * #roadmap-f-<feature> -> /roadmap/features#roadmap-f-<feature> + * #roadmap-features--<category> -> /roadmap/features#roadmap-features--<category> + */ + +/** The sections, in reading order. data/roadmap.json's `sections` must list exactly these. */ +export const SECTION_IDS = [ + 'roadmap', + 'roadmap-blockers', + 'roadmap-studio-new', + 'roadmap-studio-legacy', + 'roadmap-api', + 'roadmap-cli', + 'roadmap-platform', + 'roadmap-docs', + 'roadmap-later', + 'roadmap-storefront', + 'roadmap-blog', + 'roadmap-faststore', + 'roadmap-features', +] as const +export type SectionId = (typeof SECTION_IDS)[number] + +/** The sidebar groups (the old app.js `groupsByPage.roadmap`). */ +export const SECTION_GROUPS: readonly { title: string; ids: readonly SectionId[] }[] = [ + { title: 'Overview', ids: ['roadmap'] }, + { title: 'Release blockers', ids: ['roadmap-blockers'] }, + { title: 'Site editor support', ids: ['roadmap-studio-new', 'roadmap-studio-legacy'] }, + { title: 'Work items', ids: ['roadmap-api', 'roadmap-cli', 'roadmap-platform', 'roadmap-docs'] }, + { title: 'After the first release', ids: ['roadmap-later'] }, + { title: 'Site migrations', ids: ['roadmap-storefront', 'roadmap-blog', 'roadmap-faststore'] }, + { title: 'Feature readiness', ids: ['roadmap-features'] }, +] + +/** + * Each section's sidebar label (data/roadmap.json `sections[].nav`). Kept here as well, so a route's + * head() can build the document title without importing the Roadmap data (which would put all of it + * in the entry chunk every page loads). scripts/check-roadmap.ts checks the two agree. + */ +export const SECTION_NAV: Record<SectionId, string> = { + roadmap: 'Overview', + 'roadmap-blockers': 'The ten blockers', + 'roadmap-studio-new': 'On a next-major site', + 'roadmap-studio-legacy': 'With legacy content', + 'roadmap-api': 'API additions', + 'roadmap-cli': 'CLI and manifest', + 'roadmap-platform': 'Site editor and Deco API', + 'roadmap-docs': 'Docs fixes', + 'roadmap-later': 'Follow-ups', + 'roadmap-storefront': 'TanStack storefront', + 'roadmap-blog': 'TanStack blog', + 'roadmap-faststore': 'Next.js storefront', + 'roadmap-features': 'Every feature', +} + +/** "Release blockers — Deco CMS"; the overview is "Roadmap — Deco CMS". */ +export function roadmapDocumentTitle(id: SectionId): string { + const nav = SECTION_NAV[id] + return `${nav === 'Overview' ? 'Roadmap' : nav} — Deco CMS` +} + +export const ROADMAP_ROOT = '/roadmap/' + +/** "roadmap-api" -> "api"; "roadmap" -> "" (the overview). */ +export const sectionSlug = (id: SectionId): string => (id === 'roadmap' ? '' : id.slice('roadmap-'.length)) + +/** The router path of a section's page (no base path): "/roadmap/", "/roadmap/api". */ +export const sectionPath = (id: SectionId): string => ROADMAP_ROOT + sectionSlug(id) + +/** + * "/roadmap/api" or "api" -> "roadmap-api"; "" -> "roadmap". Undefined for an unknown slug. The + * file names GitHub Pages also serves ("api.html", "index.html") name the same pages. + */ +export function sectionForSlug(slug: string): SectionId | undefined { + const s = slug + .replace(/^\/?roadmap\/?/, '') + .replace(/\/$/, '') + .replace(/\.html$/, '') + .replace(/^index$/, '') + return SECTION_IDS.find((id) => sectionSlug(id) === s) +} + +/** Every Roadmap page's router path, for the prerender list. */ +export const ROADMAP_PATHS: string[] = SECTION_IDS.map(sectionPath) + +/** + * The section an element id lives in, from the id alone (all ids on the Roadmap follow the + * pattern above). Undefined if the id isn't a Roadmap id. + */ +export function sectionOfId(id: string): SectionId | undefined { + if (id === 'roadmap' || id.startsWith('roadmap--') || id === 'roadmap-title') return 'roadmap' + if (id.startsWith('roadmap-f-')) return 'roadmap-features' + // Longest match first: "roadmap-studio-new" before a hypothetical "roadmap-studio". + const hits = SECTION_IDS.filter((s) => s !== 'roadmap' && (id === s || id.startsWith(`${s}--`) || id === `${s}-title`)) + return hits.sort((a, b) => b.length - a.length)[0] +} + +/** + * Where a Roadmap id lives: `{ to, hash }` (router path without base; `hash` without "#", omitted + * for a whole section). Undefined if the id isn't a Roadmap id. + */ +export function roadmapTarget(id: string): { to: string; hash?: string } | undefined { + const sec = sectionOfId(id) + if (!sec) return undefined + return id === sec ? { to: sectionPath(sec) } : { to: sectionPath(sec), hash: id } +} + +/** "/roadmap/api#roadmap-api--x" for an id (no base path). */ +export function roadmapHref(id: string): string | undefined { + const t = roadmapTarget(id) + return t && t.to + (t.hash ? `#${t.hash}` : '') +} + +/** The docs version the Roadmap links into (it's about the next major). */ +export const DOCS_VERSION = 'next' + +/** + * An old docs id -> its page in the next major: "studio-compatibility" -> "/next/studio-compatibility", + * "releases-and-deployment--publishing" -> "/next/releases-and-deployment#publishing" (the old ids were "<section>--<slug>"). + */ +export function docsHref(id: string): string { + const i = id.indexOf('--') + return i < 0 ? `/${DOCS_VERSION}/${id}` : `/${DOCS_VERSION}/${id.slice(0, i)}#${id.slice(i + 2)}` +} diff --git a/docs/components/search/index.tsx b/docs/components/search/index.tsx new file mode 100644 index 00000000..002b06d9 --- /dev/null +++ b/docs/components/search/index.tsx @@ -0,0 +1,383 @@ +/** + * ⌘K search: the dialog, on top of Pagefind's index of the prerendered pages. + * + * - Mounted once, globally (src/layout/GlobalUi.tsx picks up this default export). Opens on the + * `docs:search-open` window event (the header's search button, the drawer's, ⌘K / Ctrl+K and + * `/`); ⌘K again, Esc, the Esc button or a click on the backdrop close it. + * - Searches the current docs version, the Roadmap and Home. Each page tags itself with a + * `scope` Pagefind filter (rendered below, in the prerendered HTML): the version id on doc + * pages, `roadmap`, `home`. So /v7/ pages never show up while reading /next/ and vice versa. + * - A result is a page or one of its headings (Pagefind sub-results), shown with its context + * ("Docs › Core concepts", "Docs › Quickstart"), the matched title and an excerpt. Outside the + * Roadmap, docs results come first and Roadmap ones follow (at least three kept), as before. + * - Combobox pattern: focus stays in the input, ↑/↓ move the active option + * (aria-activedescendant), Enter opens it; Tab also reaches the links themselves (and ↑/↓ from + * a link return to the input). + * + * Styled with Tailwind utilities; the ids (#search-dialog, #search-input, #search-results, + * #search-opt-N, #search-status) are the old site's and stay. + */ +import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react' +import { useRouter } from '@tanstack/react-router' +import { Icon } from '~/components/ui/Icon' +import { useChrome } from '~/src/lib/chrome' +import { findPage, getVersion, pageForPath, stripBase, type ManifestPage } from '~/src/lib/content' +import { KIND_LABELS } from '~/src/lib/nav' +import { closeMenu, SEARCH_OPEN_EVENT } from '~/src/lib/ui' +import { excerptParts, loadPagefind, decodeEntities, tidyExcerpt, type Pagefind, type PagefindData, type PagefindSubResult } from './pagefind' + +const BASE = import.meta.env.BASE_URL + +/* Styles shared by more than one element below. */ +const MARK = 'rounded-[3px] bg-mark-bg px-px text-mark-fg' +const LABEL = 'mx-3 mt-2.5 mb-1.5 text-13 leading-4 tracking-ui text-eyebrow uppercase' +const EMPTY = 'px-3 py-7 text-center text-14 text-muted-fg' +const KBD = 'mr-0.5 inline-grid h-5.5 min-w-5.5 place-items-center rounded-full bg-surface px-1.5 inset-ring inset-ring-border' +const MAX_ROWS = 20 +/** Pages whose data is fetched per query (each is one small request). */ +const MAX_PAGES = 24 +/** Headings shown per page, best matches first. */ +const MAX_SUBS = 3 +/** Suggested pages by slug; each version shows the ones it has (next and v7 name some pages differently). */ +const SUGGESTIONS = ['quickstart', 'blocks', 'model', 'routing', 'preview', 'releases-and-drafts', 'api-reference', 'nextjs', 'how-resolution-works', 'walkthrough', 'troubleshooting'] + +type Area = 'docs' | 'roadmap' | 'home' + +interface Row { + key: string + /** Router path without the base path, e.g. /next/quickstart. */ + to: string + hash?: string + /** heading = a section inside a page (hash icon), page = the page itself (file icon). */ + kind: 'page' | 'heading' + path: string + title: string + excerpt?: string + area: Area +} + +const toHref = (to: string, hash?: string) => `${BASE.replace(/\/$/, '')}${to}${hash ? `#${hash}` : ''}` + +/** A page as a result: the pager's title rule, and "Docs › Group" when the group adds anything. */ +function pageRow(page: ManifestPage, excerpt?: string): Row { + const kindLabel = KIND_LABELS[page.kind] + const group = page.group !== kindLabel && page.group !== page.nav ? ` › ${page.group}` : '' + return { + key: page.path, + to: page.path, + kind: 'page', + path: kindLabel + group, + title: page.group === page.nav ? page.title : page.nav, + excerpt, + area: 'docs', + } +} + +/** Heading results ranked by how strongly they match (Pagefind lists them in page order). */ +function bestSubs(subs: PagefindSubResult[]): PagefindSubResult[] { + const weight = (s: PagefindSubResult) => + s.weighted_locations?.reduce((sum, l) => sum + l.balanced_score, 0) ?? s.locations?.length ?? 0 + return subs + .map((s, i) => ({ s, i, w: weight(s) })) + .sort((a, b) => b.w - a.w || a.i - b.i) + .slice(0, MAX_SUBS) + .sort((a, b) => a.i - b.i) + .map((x) => x.s) +} + +function rowsFor(data: PagefindData): Row[] { + const [rawPath] = data.url.split('#') + const to = stripBase(rawPath).replace(/\.html$/, '') || '/' + const page = pageForPath(to) + const area: Area = page ? 'docs' : to === '/roadmap' || to.startsWith('/roadmap/') ? 'roadmap' : 'home' + const label = page ? KIND_LABELS[page.kind] : area === 'roadmap' ? 'Roadmap' : 'Home' + const pageTitle = decodeEntities(data.meta.title ?? '') + const subs = bestSubs(data.sub_results ?? []) + const rows: Row[] = [] + for (const sub of subs.length ? subs : [{ title: pageTitle, url: data.url, excerpt: data.excerpt }]) { + const hash = sub.url.split('#')[1] + if (!hash || sub.anchor?.element === 'h1') { + // The top of the page. + const base: Row = page + ? pageRow(page) + : { key: to, to, kind: 'page', path: label, title: area === 'home' ? 'Overview' : pageTitle, area } + rows.push({ ...base, key: `${to}#`, excerpt: tidyExcerpt(sub.excerpt, sub.anchor?.element === 'h1' ? sub.title : undefined) }) + } else { + rows.push({ + key: `${to}#${hash}`, + to, + hash: decodeURIComponent(hash), + kind: 'heading', + path: page ? `${label} › ${page.nav}` : label, + title: decodeEntities(sub.title), + excerpt: tidyExcerpt(sub.excerpt, sub.title), + area, + }) + } + } + const seen = new Set<string>() + return rows.filter((r) => !seen.has(r.key) && !!seen.add(r.key)) +} + +function suggestionRows(version: string): Row[] { + const picked = SUGGESTIONS.map((slug) => findPage(version, slug)).filter((p): p is ManifestPage => !!p) + const pages = picked.length >= 4 ? picked : (getVersion(version)?.pages ?? []).slice(0, 8) + return pages.map((p) => pageRow(p)) +} + +const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +function Highlight({ text, words }: { text: string; words: string[] }) { + if (!words.length) return <>{text}</> + const re = new RegExp(`(${words.map(escapeRe).join('|')})`, 'gi') + return <>{text.split(re).map((part, i) => (i % 2 ? <mark key={i} className={MARK}>{part}</mark> : part))}</> +} + +type Status = { kind: 'idle' } | { kind: 'loading' } | { kind: 'results'; rows: Row[]; label: string } | { kind: 'empty' } | { kind: 'unavailable' } + +export default function SearchDialog() { + const chrome = useChrome() + const router = useRouter() + const scope = chrome.tab === 'home' ? 'home' : chrome.tab === 'roadmap' ? 'roadmap' : chrome.tab === 'none' ? null : chrome.version + const dialogRef = useRef<HTMLDialogElement>(null) + const inputRef = useRef<HTMLInputElement>(null) + const resultsRef = useRef<HTMLDivElement>(null) + const [query, setQuery] = useState('') + const [status, setStatus] = useState<Status>({ kind: 'idle' }) + const [active, setActive] = useState(0) + const seq = useRef(0) + const pagefind = useRef<Pagefind | null | undefined>(undefined) + + const words = query.toLowerCase().trim().split(/\s+/).filter(Boolean) + const rows = !words.length ? suggestionRows(chrome.version) : status.kind === 'results' ? status.rows : [] + + const run = useCallback( + async (q: string) => { + const id = ++seq.current + if (!q.trim()) { + setStatus({ kind: 'idle' }) + setActive(0) + return + } + if (pagefind.current === undefined) setStatus({ kind: 'loading' }) + const pf = await loadPagefind() + pagefind.current = pf + if (id !== seq.current) return + if (!pf) return setStatus({ kind: 'unavailable' }) + // A short pause first, so a fast typist doesn't fetch results for every letter. + await new Promise((r) => setTimeout(r, 80)) + if (id !== seq.current) return + const res = await pf.search(q, { filters: { scope: { any: [chrome.version, 'roadmap', 'home'] } } }) + if (id !== seq.current) return + const datas = await Promise.all(res.results.slice(0, MAX_PAGES).map((r) => r.data())) + if (id !== seq.current) return + let all = datas.flatMap(rowsFor) + // Home results go last; outside the Roadmap, its many sections follow the docs. + const by = (a: Area) => all.filter((r) => r.area === a) + if (chrome.tab !== 'roadmap') { + const docs = by('docs') + const roadmap = by('roadmap') + const mine = docs.slice(0, MAX_ROWS - Math.min(3, roadmap.length)) + all = [...mine, ...roadmap.slice(0, MAX_ROWS - mine.length), ...by('home')] + } else all = [...by('roadmap'), ...by('docs'), ...by('home')] + all = all.slice(0, MAX_ROWS) + setActive(0) + if (!all.length) return setStatus({ kind: 'empty' }) + const label = `${all.length}${all.length === 1 ? ' result' : ' results'}${all.length === MAX_ROWS ? ', best matches first' : ''}` + setStatus({ kind: 'results', rows: all, label }) + }, + [chrome.version, chrome.tab], + ) + + const close = useCallback(() => { + const d = dialogRef.current + if (d?.open) d.close() + }, []) + + // Open (or, from ⌘K while open, close) on the shell's event. + useEffect(() => { + const onOpen = () => { + const d = dialogRef.current + if (!d) return + if (d.open) return d.close() + closeMenu() + setQuery('') + seq.current++ + setStatus({ kind: 'idle' }) + setActive(0) + d.showModal() + inputRef.current?.focus() + void loadPagefind() // warm it up while the reader types + } + window.addEventListener(SEARCH_OPEN_EVENT, onOpen) + return () => window.removeEventListener(SEARCH_OPEN_EVENT, onOpen) + }, []) + + // Keep the active option in view. + useEffect(() => { + resultsRef.current?.querySelector<HTMLElement>(`#search-opt-${active}`)?.scrollIntoView({ block: 'nearest' }) + }, [active]) + + const go = useCallback( + async (row: Row) => { + close() + closeMenu() + await router.navigate({ to: row.to, hash: row.hash } as never) + // After the page renders: open any <details> around the target, then bring it into view. + requestAnimationFrame(() => { + const target = row.hash ? document.getElementById(row.hash) : null + if (!target) return + for (let d = target.closest('details'); d; d = d.parentElement?.closest('details') ?? null) d.open = true + target.scrollIntoView({ block: 'start' }) + }) + }, + [close, router], + ) + + const onKeyDown = (event: ReactKeyboardEvent<HTMLDialogElement>) => { + const links = [...(resultsRef.current?.querySelectorAll<HTMLAnchorElement>('a[role="option"]') ?? [])] + if (event.key === 'Escape') { + event.preventDefault() + return close() + } + if (!links.length) return + const last = links.length - 1 + const current = Math.min(active, last) + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + setActive(event.key === 'ArrowDown' ? Math.min(last, current + 1) : Math.max(0, current - 1)) + if (document.activeElement !== inputRef.current) inputRef.current?.focus() + } else if (event.key === 'Enter' && document.activeElement === inputRef.current) { + event.preventDefault() + links[current]?.click() + } + } + + const statusText = + status.kind === 'results' ? `${status.label}.` : status.kind === 'empty' ? 'No results.' : status.kind === 'unavailable' ? 'Search is not available.' : '' + const hasOptions = rows.length > 0 + + return ( + <> + {/* The page's search scope, read by Pagefind when it indexes the built HTML. */} + {scope && <span hidden data-pagefind-filter={`scope:${scope}`} />} + <dialog + id="search-dialog" + aria-label="Search documentation" + ref={dialogRef} + className="m-auto mt-[12vh] hidden max-h-[min(580px,calc(100vh-96px))] w-[min(660px,calc(100%-32px))] flex-col overflow-hidden rounded-dialog border border-border bg-surface p-0 text-fg shadow-lg open:not-print:flex open:animate-pop backdrop:bg-backdrop backdrop:backdrop-blur-[3px] max-md:mt-4 max-md:max-h-[calc(100vh-32px)]" + onKeyDown={onKeyDown} + onClick={(event) => { + // A click on the backdrop (outside the dialog box) closes it. + if (event.target !== event.currentTarget) return + const r = event.currentTarget.getBoundingClientRect() + if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close() + }} + > + <div className="flex h-15 flex-none items-center gap-3 border-b border-hairline pr-3.5 pl-5 transition-shadow has-[input:focus-visible]:shadow-[inset_0_-2px_0_var(--ring)]"> + <Icon name="search" className="size-4.5 text-eyebrow" /> + <input + ref={inputRef} + id="search-input" + className="h-10 min-w-0 flex-1 border-0 bg-transparent px-0.5 py-px text-17 text-fg outline-none placeholder:text-muted-fg [&::-webkit-search-cancel-button]:hidden" + type="search" + placeholder="Search concepts, APIs, frameworks…" + aria-label="Search docs" + autoComplete="off" + spellCheck={false} + role="combobox" + aria-autocomplete="list" + aria-expanded={hasOptions} + aria-controls="search-results" + aria-activedescendant={hasOptions ? `search-opt-${Math.min(active, rows.length - 1)}` : undefined} + value={query} + onChange={(e) => { + setQuery(e.target.value) + void run(e.target.value) + }} + /> + <button className="h-6.5 rounded-full border-0 bg-muted px-2.5 font-mono text-11 leading-4 font-medium text-muted-fg" id="search-close" type="button" aria-label="Esc: close search" onClick={close}> + Esc + </button> + </div> + <div className="flex-1 overflow-y-auto overscroll-contain p-2" id="search-results" role="listbox" aria-label="Search results" ref={resultsRef}> + {!words.length && rows.length > 0 && ( + <p className={LABEL} aria-hidden="true"> + Suggested + </p> + )} + {words.length > 0 && status.kind === 'results' && ( + <p className={LABEL} aria-hidden="true"> + {status.label} + </p> + )} + {words.length > 0 && status.kind === 'loading' && ( + <p className={EMPTY} aria-hidden="true"> + Loading the search index… + </p> + )} + {words.length > 0 && status.kind === 'empty' && ( + <p className={EMPTY} aria-hidden="true"> + No matching sections. Try “schema”, “rollback”, or “TanStack”. + </p> + )} + {words.length > 0 && status.kind === 'unavailable' && ( + <p className={EMPTY}> + Search works on the built site. Run <code>bun run build</code>, then <code>bun run preview</code>. + </p> + )} + {rows.map((row, i) => { + const on = i === Math.min(active, rows.length - 1) + return ( + <a + key={row.key} + id={`search-opt-${i}`} + role="option" + aria-selected={on} + className="search-hit group" // search-hit*: src/styles/components/docs.css + href={toHref(row.to, row.hash)} + onMouseMove={() => !on && setActive(i)} + onClick={(event) => { + if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return + event.preventDefault() + void go(row) + }} + > + <span className="search-hit-icon"> + <Icon name={row.kind === 'heading' ? 'hash' : 'file'} className="size-3.5" /> + </span> + <span className="grid min-w-0 flex-1 gap-0.5"> + <span className="search-hit-path">{row.path}</span> + <span className="search-hit-title"> + <Highlight text={row.title} words={words} /> + </span> + {words.length > 0 && row.excerpt && ( + <span className="line-clamp-2 text-13 leading-5 text-muted-fg"> + {excerptParts(row.excerpt).map((p, j) => (p.mark ? <mark key={j} className={MARK}>{p.text}</mark> : p.text))} + </span> + )} + </span> + <span className="search-hit-go"> + <Icon name="corner" className="size-3.25" /> + </span> + </a> + ) + })} + </div> + <p className="sr-only" id="search-status" role="status"> + {statusText} + </p> + <div className="flex flex-none flex-wrap items-center gap-x-4 gap-y-1 border-t border-hairline bg-bg-subtle px-5 py-[11px] text-12 leading-4 text-muted-fg"> + <span> + <kbd className={KBD}>↑</kbd> + <kbd className={KBD}>↓</kbd> to move + </span> + <span> + <kbd className={KBD}>↵</kbd> to open + </span> + <span className="ml-auto max-md:hidden">Searches all documentation, including code examples.</span> + </div> + </dialog> + </> + ) +} diff --git a/docs/components/search/pagefind.ts b/docs/components/search/pagefind.ts new file mode 100644 index 00000000..97669f21 --- /dev/null +++ b/docs/components/search/pagefind.ts @@ -0,0 +1,163 @@ +/** + * The Pagefind client, loaded on demand from the built site (dist/client/pagefind/, written by + * scripts/postbuild.ts). It only exists after `bun run build`; in `bun run dev` loading fails and + * the dialog says so. + * + * Only the parts of Pagefind's JS API the dialog uses are typed here (pagefind 1.5). + */ + +export interface PagefindAnchor { + element: string + id: string + text: string + location: number +} + +export interface PagefindSubResult { + title: string + /** Base path included, with `#id` for a heading. */ + url: string + /** Plain text with <mark> around matches (HTML-escaped). */ + excerpt: string + anchor?: PagefindAnchor + locations?: number[] + weighted_locations?: { weight: number; balanced_score: number; location: number }[] +} + +export interface PagefindData { + url: string + excerpt: string + meta: Record<string, string> + filters?: Record<string, string[]> + sub_results: PagefindSubResult[] +} + +export interface PagefindResult { + id: string + score: number + data: () => Promise<PagefindData> +} + +export interface PagefindSearch { + results: PagefindResult[] +} + +export type PagefindFilters = Record<string, string | string[] | { any?: string[]; all?: string[]; none?: string[]; not?: string[] }> + +export interface Pagefind { + options: (opts: { excerptLength?: number; baseUrl?: string; highlightParam?: string }) => Promise<void> + init: () => Promise<void> + search: (term: string, opts?: { filters?: PagefindFilters }) => Promise<PagefindSearch> + preload: (term: string, opts?: { filters?: PagefindFilters }) => Promise<void> +} + +let loading: Promise<Pagefind | null> | undefined + +/** Loads and initialises Pagefind once; resolves to null where there's no index (dev). */ +export function loadPagefind(): Promise<Pagefind | null> { + loading ??= (async () => { + try { + const pf = (await import(/* @vite-ignore */ `${import.meta.env.BASE_URL}pagefind/pagefind.js`)) as Pagefind + await pf.options({ excerptLength: 24 }) + await pf.init() + return pf + } catch { + return null + } + })() + return loading +} + +const ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' } + +/** Decodes the few HTML entities Pagefind writes into excerpts and titles. */ +export function decodeEntities(text: string): string { + return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e: string) => { + if (e[0] === '#') { + const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10) + return Number.isFinite(code) ? String.fromCodePoint(code) : m + } + return ENTITIES[e.toLowerCase()] ?? m + }) +} + +/** + * Splits a Pagefind excerpt into text and marked runs, without ever setting it as HTML: + * "a <mark>b</mark> c" -> [{ text: 'a ' }, { text: 'b', mark: true }, { text: ' c' }]. + */ +export function excerptParts(excerpt: string): { text: string; mark?: boolean }[] { + const out: { text: string; mark?: boolean }[] = [] + const re = /<mark>([\s\S]*?)<\/mark>/g + let at = 0 + let m: RegExpExecArray | null + const plain = (s: string) => decodeEntities(s.replace(/<[^>]*>/g, '')) + while ((m = re.exec(excerpt))) { + if (m.index > at) out.push({ text: plain(excerpt.slice(at, m.index)) }) + out.push({ text: plain(m[1]), mark: true }) + at = m.index + m[0].length + } + if (at < excerpt.length) out.push({ text: plain(excerpt.slice(at)) }) + // Highlight the word only: punctuation Pagefind keeps on a matched word ("preview).") goes outside. + const trimmed: { text: string; mark?: boolean }[] = [] + for (const p of out) { + if (!p.mark) { + trimmed.push(p) + continue + } + const m = /^([^\p{L}\p{N}]*)([\s\S]*?)([^\p{L}\p{N}]*)$/u.exec(p.text)! + if (!m[2]) { + trimmed.push({ text: p.text }) + continue + } + trimmed.push({ text: m[1] }, { text: m[2], mark: true }, { text: m[3] }) + } + return trimmed.filter((p) => p.text) +} + +/** + * Tidies a sub-result's excerpt the way the old search showed them: Pagefind's excerpt window + * often starts inside the section's heading ("it works. Preview is…" under "How it works"), so the + * heading's tail is dropped; an excerpt that starts mid-sentence gets a leading "…". + */ +export function tidyExcerpt(excerpt: string, heading?: string): string { + let out = excerpt.trimStart() + const plainStart = decodeEntities(out.replace(/<[^>]*>/g, '')) + const h = heading ? decodeEntities(heading).trim() : '' + if (h) { + // The longest tail of the heading (whole words) the excerpt starts with, plus Pagefind's ". ". + const words = h.split(/\s+/) + for (let i = 0; i < words.length; i++) { + const tail = words.slice(i).join(' ') + const re = new RegExp(`^${tail.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[.:]?\\s+`, 'i') + if (re.test(plainStart)) { + out = stripPlainPrefix(out, plainStart.match(re)![0].length) + return out + } + } + } + const first = decodeEntities(out.replace(/<[^>]*>/g, '')).trimStart()[0] ?? '' + return /[\p{Lu}\p{N}`"“(<]/u.test(first) ? out : `…${out}` +} + +/** Drops the first `n` characters of an excerpt's text, keeping its <mark> tags balanced. */ +function stripPlainPrefix(excerpt: string, n: number): string { + let i = 0 + let left = n + let open = false + while (i < excerpt.length && left > 0) { + if (excerpt[i] === '<') { + const end = excerpt.indexOf('>', i) + const tag = excerpt.slice(i, end + 1) + open = tag === '<mark>' ? true : tag === '</mark>' ? false : open + i = end + 1 + continue + } + if (excerpt[i] === '&') { + const end = excerpt.indexOf(';', i) + i = end > i && end - i < 10 ? end + 1 : i + 1 + } else i++ + left-- + } + const rest = excerpt.slice(i) + return open ? `<mark>${rest}` : rest +} diff --git a/docs/components/ui/Brand.tsx b/docs/components/ui/Brand.tsx new file mode 100644 index 00000000..bd23d477 --- /dev/null +++ b/docs/components/ui/Brand.tsx @@ -0,0 +1,53 @@ +/** + * deco's brand marks (the same files as in the public decocms/studio repository), inlined as SVG + * so CSS can pick the variant that matches the theme. + * + * <Wordmark /> both variants: .wm.wm-light and .wm.wm-dark + * <WordmarkLime /> the on-dark (lime) variant alone, class "wm wm-lime" (footer, hero) + * <BrandSymbol /> the "d" symbol, both variants: .sym.sym-light / .sym.sym-dark + * + * No stylesheet picks the variant: the caller does, with utilities (see BrandProps), e.g. + * light="dark:hidden" dark="hidden dark:block". The wm-… and sym-… classes are only names to target. + */ +import wordmarkLight from '~/assets/brand/wordmark-on-light.svg?raw' +import wordmarkDark from '~/assets/brand/wordmark-on-dark.svg?raw' +import symbolLight from '~/assets/brand/symbol-on-light.svg?raw' +import symbolDark from '~/assets/brand/symbol-on-dark.svg?raw' + +/** Same normalisation as the former Python build (removed): one line, the root <svg> rewritten with a class. */ +function brandSvg(raw: string, cls: string): string { + let s = raw.replace(/\s+/g, ' ').trim().replace(/> </g, '><') + s = s.replace( + /<svg[^>]*?viewBox="([^"]+)"[^>]*>/, + (_m, vb: string) => `<svg class="${cls}" viewBox="${vb}" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">`, + ) + return s.replace(/ \/>/g, '/>') +} + +const raw = { wordmarkLight, wordmarkDark, symbolLight, symbolDark } +const cx = (...c: (string | undefined)[]) => c.filter(Boolean).join(' ') + +const html = { + wordmark: brandSvg(wordmarkLight, 'wm wm-light') + brandSvg(wordmarkDark, 'wm wm-dark'), + wordmarkLime: brandSvg(wordmarkDark, 'wm wm-lime'), + symbol: brandSvg(symbolLight, 'sym sym-light') + brandSvg(symbolDark, 'sym sym-dark'), +} + +/** + * Optional utilities: `className` goes on every SVG the component renders; `light` / `dark` go on + * one variant only, e.g. <Wordmark className="h-6 w-auto" light="dark:hidden" dark="hidden dark:block" />. + */ +type BrandProps = { className?: string; light?: string; dark?: string } + +// `display: contents` keeps the wrapper out of layout, so the SVGs behave as direct children. +const Inline = ({ markup }: { markup: string }) => <span style={{ display: 'contents' }} dangerouslySetInnerHTML={{ __html: markup }} /> + +export const Wordmark = ({ className, light, dark }: BrandProps = {}) => + <Inline markup={className || light || dark ? brandSvg(raw.wordmarkLight, cx('wm wm-light', className, light)) + brandSvg(raw.wordmarkDark, cx('wm wm-dark', className, dark)) : html.wordmark} /> +export const WordmarkLime = ({ className }: { className?: string } = {}) => + <Inline markup={className ? brandSvg(raw.wordmarkDark, cx('wm wm-lime', className)) : html.wordmarkLime} /> +export const BrandSymbol = ({ className, light, dark }: BrandProps = {}) => + <Inline markup={className || light || dark ? brandSvg(raw.symbolLight, cx('sym sym-light', className, light)) + brandSvg(raw.symbolDark, cx('sym sym-dark', className, dark)) : html.symbol} /> + +/** The raw strings, for code that needs markup rather than elements. */ +export const brandHtml = html diff --git a/docs/components/ui/Icon.tsx b/docs/components/ui/Icon.tsx new file mode 100644 index 00000000..a952fdec --- /dev/null +++ b/docs/components/ui/Icon.tsx @@ -0,0 +1,106 @@ +/** + * The site's icon set (Lucide-style 24px strokes, from the former Python build, removed) and the stack-strip marks. + * + * <Icon name="search" /> -> <svg class="icon i-search" …> (16px by default via CSS) + * <Icon name="github" /> -> the GitHub mark (16px viewBox, filled) + * <Mark name="nextjs" /> -> <svg class="mark m-nextjs" …> (filled, for the stack strip) + * + * Paths are trusted constants, so they're injected as markup. + */ +import type { SVGProps } from 'react' + +export const ICON_PATHS = { + "search": "<circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/>", + "sun": "<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41\"/>", + "moon": "<path d=\"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z\"/>", + "menu": "<path d=\"M4 8h16M4 16h16\"/>", + "x": "<path d=\"M18 6 6 18M6 6l12 12\"/>", + "copy": "<rect width=\"13\" height=\"13\" x=\"9\" y=\"9\" rx=\"2\"/><path d=\"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1\"/>", + "check": "<path d=\"M20 6 9 17l-5-5\"/>", + "chevron-right": "<path d=\"m9 18 6-6-6-6\"/>", + "chevron-down": "<path d=\"m6 9 6 6 6-6\"/>", + "arrow-right": "<path d=\"M5 12h14M13 6l6 6-6 6\"/>", + "arrow-left": "<path d=\"M19 12H5M11 18l-6-6 6-6\"/>", + "arrow-up": "<path d=\"M12 19V5M6 11l6-6 6 6\"/>", + "file": "<path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z\"/><path d=\"M14 2v6h6\"/>", + "link": "<path d=\"M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71\"/><path d=\"M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71\"/>", + "printer": "<path d=\"M6 9V2h12v7\"/><path d=\"M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2\"/><rect x=\"6\" y=\"14\" width=\"12\" height=\"8\" rx=\"1\"/>", + "list": "<path d=\"M3 6h18M3 12h12M3 18h15\"/>", + "git-commit": "<circle cx=\"12\" cy=\"12\" r=\"3\"/><path d=\"M3 12h6M15 12h6\"/>", + "git-branch": "<path d=\"M6 3v12\"/><circle cx=\"18\" cy=\"6\" r=\"3\"/><circle cx=\"6\" cy=\"18\" r=\"3\"/><path d=\"M18 9a9 9 0 0 1-9 9\"/>", + "pencil": "<path d=\"M17 3a2.85 2.85 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z\"/>", + "play": "<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m10 8.5 5 3.5-5 3.5Z\"/>", + "box": "<path d=\"M21 8 12 3 3 8v8l9 5 9-5Z\"/><path d=\"m3 8 9 5 9-5M12 13v8\"/>", + "sliders": "<path d=\"M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1.5 14h5M9.5 8h5M17.5 16h5\"/>", + "globe": "<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M3 12h18M12 3a14 14 0 0 1 3.6 9A14 14 0 0 1 12 21a14 14 0 0 1-3.6-9A14 14 0 0 1 12 3Z\"/>", + "book": "<path d=\"M3 4.5h6a3 3 0 0 1 3 3V20a2.5 2.5 0 0 0-2.5-2.5H3ZM21 4.5h-6a3 3 0 0 0-3 3V20a2.5 2.5 0 0 1 2.5-2.5H21Z\"/>", + "layers": "<path d=\"m12 2.5 9.5 5-9.5 5-9.5-5Z\"/><path d=\"m2.5 12 9.5 5 9.5-5M2.5 16.5l9.5 5 9.5-5\"/>", + "server": "<rect x=\"3\" y=\"3\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"14\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 6.5h.01M7 17.5h.01\"/>", + "cpu": "<rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" rx=\"2\"/><path d=\"M9.5 9.5h5v5h-5zM9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3\"/>", + "hash": "<path d=\"M4 9h16M4 15h16M10 3 8 21M16 3l-2 18\"/>", + "code": "<path d=\"m16 18 6-6-6-6M8 6l-6 6 6 6\"/>", + "terminal": "<path d=\"m4 17 6-5-6-5M12 19h8\"/>", + "sparkle": "<path d=\"M12 3.5 13.9 10 20.5 12l-6.6 2L12 20.5 10.1 14 3.5 12l6.6-2Z\"/>", + "eye": "<path d=\"M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>", + "lock": "<rect x=\"4\" y=\"11\" width=\"16\" height=\"10\" rx=\"2\"/><path d=\"M8 11V7a4 4 0 0 1 8 0v4\"/>", + "zap": "<path d=\"M13 2 4 14h7l-1 8 9-12h-7l1-8Z\"/>", + "corner": "<path d=\"m9 10-5 5 5 5\"/><path d=\"M20 4v7a4 4 0 0 1-4 4H4\"/>", + "cloud": "<path d=\"M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z\"/>", + "smartphone": "<rect width=\"14\" height=\"20\" x=\"5\" y=\"2\" rx=\"2\"/><path d=\"M12 18h.01\"/>", + "plus": "<path d=\"M12 5v14M5 12h14\"/>" +} as const + +export const MARK_PATHS = { + "nextjs": "<path d=\"M18.665 21.978C16.758 23.255 14.465 24 12 24 5.377 24 0 18.623 0 12S5.377 0 12 0s12 5.377 12 12c0 3.583-1.574 6.801-4.067 9.001L9.219 7.2H7.2v9.596h1.615V9.251l9.85 12.727Zm-3.332-8.533 1.6 2.061V7.2h-1.6v6.245Z\"/>", + "git": "<path d=\"M23.546 10.93L13.067.452c-.604-.603-1.582-.603-2.188 0L8.708 2.627l2.76 2.76c.645-.215 1.379-.07 1.889.441.516.515.658 1.258.438 1.9l2.658 2.66c.645-.223 1.387-.078 1.9.435.721.72.721 1.884 0 2.604-.719.719-1.881.719-2.6 0-.539-.541-.674-1.337-.404-1.996L12.86 8.955v6.525c.176.086.342.203.488.348.713.721.713 1.883 0 2.6-.719.721-1.889.721-2.609 0-.719-.719-.719-1.879 0-2.598.182-.18.387-.316.605-.406V8.835c-.217-.091-.424-.222-.6-.401-.545-.545-.676-1.342-.396-2.009L7.636 3.7.45 10.881c-.6.605-.6 1.584 0 2.189l10.48 10.477c.604.604 1.582.604 2.186 0l10.43-10.43c.605-.603.605-1.582 0-2.187\"/>", + "cloudflare": "<circle cx=\"6.6\" cy=\"15\" r=\"5\"/><circle cx=\"13.2\" cy=\"10.6\" r=\"7\"/><circle cx=\"19\" cy=\"15.5\" r=\"4.5\"/><rect x=\"6.6\" y=\"14\" width=\"12.4\" height=\"6\"/>", + "tanstack": "<path d=\"M12 1.8 22.2 7 12 12.2 1.8 7Z\"/><path d=\"M1.8 11.1 12 16.3l10.2-5.2v2.6L12 18.9 1.8 13.7Z\"/><path d=\"M1.8 16.2 12 21.4l10.2-5.2v2.6L12 24 1.8 18.8Z\"/>", + "react": "<circle cx=\"12\" cy=\"12\" r=\"2.2\"/><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><ellipse cx=\"12\" cy=\"12\" rx=\"10.5\" ry=\"4.1\"/><ellipse cx=\"12\" cy=\"12\" rx=\"10.5\" ry=\"4.1\" transform=\"rotate(60 12 12)\"/><ellipse cx=\"12\" cy=\"12\" rx=\"10.5\" ry=\"4.1\" transform=\"rotate(120 12 12)\"/></g>", + "node": "<path d=\"M12 .8 21.7 6.4v11.2L12 23.2 2.3 17.6V6.4Z\" stroke=\"currentColor\" stroke-width=\"1.2\" stroke-linejoin=\"round\"/>" +} as const + +const GITHUB_PATH = "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8Z" + +export type IconName = keyof typeof ICON_PATHS | 'github' +export type MarkName = keyof typeof MARK_PATHS + +type SvgProps = Omit<SVGProps<SVGSVGElement>, 'name'> + +export function Icon({ name, strokeWidth = 1.75, className, ...rest }: { name: IconName; strokeWidth?: number } & SvgProps) { + const cls = `icon i-${name}${className ? ` ${className}` : ''}` + if (name === 'github') + return ( + <svg className={cls} viewBox="0 0 16 16" aria-hidden="true" focusable="false" {...rest}> + <path fill="currentColor" d={GITHUB_PATH} /> + </svg> + ) + return ( + <svg + className={cls} + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + strokeWidth={strokeWidth} + strokeLinecap="round" + strokeLinejoin="round" + aria-hidden="true" + focusable="false" + dangerouslySetInnerHTML={{ __html: ICON_PATHS[name] }} + {...rest} + /> + ) +} + +export function Mark({ name, className, ...rest }: { name: MarkName } & SvgProps) { + return ( + <svg + className={`mark m-${name}${className ? ` ${className}` : ''}`} + viewBox="0 0 24 24" + fill="currentColor" + aria-hidden="true" + focusable="false" + dangerouslySetInnerHTML={{ __html: MARK_PATHS[name] }} + {...rest} + /> + ) +} diff --git a/docs/components/widgets/Walkthrough.tsx b/docs/components/widgets/Walkthrough.tsx new file mode 100644 index 00000000..604bdea9 --- /dev/null +++ b/docs/components/widgets/Walkthrough.tsx @@ -0,0 +1,182 @@ +import { createContext, useContext, useState, type ComponentProps, type ReactNode } from 'react' +import TraceCodeBlocks from './walkthrough-code.mdx' + +/** + * "How resolution works": one saved entry (SummerCard), resolved step by step. + * + * <Walkthrough /> + * + * Two toggles (the CMS call: `resolve("SummerCard")` or `{ run: false }`; the function output: + * descriptor or React tree), four step pills, the code at that step, a caption and a call count. + * It's an illustration: nothing runs. The code for each state is in walkthrough-code.mdx, so it + * is highlighted at build time like every other code block. + * + * Port of the old app.js "Walkthrough" block, styled with Tailwind utilities. It sits inside the + * article (.doc-section), so the root is `not-prose`; the #trace-* ids are kept. + */ + +type Operation = 'resolve' | 'get' +type Mode = 'data' | 'rsc' +type CodeState = 'stored' | 'expanded' | 'children' | 'descriptor' | 'element' + +const CAPTIONS: Record<Operation, string[]> = { + resolve: [ + 'The saved entry. Its product input names CurrentProduct, another saved entry.', + "CurrentProduct is a saved entry, so it's replaced with its JSON, and the rule runs again on what came back.", + 'catalog-product is a function. Its inputs hold no more blocks, so it runs, and the product data takes its place.', + 'product-card is a function. Its inputs are all values now, so it runs. The CMS never looks inside what it returns.', + ], + get: [ + 'The saved entry. Its product input names CurrentProduct, another saved entry.', + 'Reading without running: saved entries still expand, so CurrentProduct is replaced with its JSON. catalog-product and product-card name functions, so they come back as JSON, untouched. No code runs.', + ], +} + +const STATS: Record<Operation, string[]> = { + resolve: ['0 function calls', '0 function calls · entry expanded', '1 function call · catalog-product', '2 function calls · result returned as is'], + get: ['0 function calls', '0 function calls · final value'], +} + +const STEPS = ['1 · Stored', '2 · CurrentProduct: saved entry', '3 · catalog-product: function', '4 · product-card: function'] + +const LANG_LABELS: Record<string, string> = { json: 'JSON', tsx: 'TSX' } + +function codeStateFor(step: number, mode: Mode): CodeState { + if (step === 3) return mode === 'data' ? 'descriptor' : 'element' + return (['stored', 'expanded', 'children'] as const)[step] +} + +const ActiveState = createContext<CodeState>('stored') + +/** One state's code block in walkthrough-code.mdx; rendered only while it's the current one. */ +function TraceCode({ state, children }: { state: CodeState; children?: ReactNode }) { + return useContext(ActiveState) === state ? <>{children}</> : null +} + +/** The explorer's bare <pre> (no code-panel chrome); its ::after badge shows data-lang. */ +function TracePre({ children, ...props }: ComponentProps<'pre'> & { 'data-lang'?: string }) { + const lang = props['data-lang'] ?? 'json' + return ( + <pre + aria-label="Resolution example" + data-lang={LANG_LABELS[lang] ?? lang} + className="relative m-0 min-h-54 overflow-auto rounded-xl border border-code-border bg-code-bg px-5.5 py-4.5 after:absolute after:top-3 after:right-3 after:inline-flex after:h-5.5 after:items-center after:rounded-full after:bg-surface after:px-[9px] after:font-sans after:text-11 after:leading-4 after:font-normal after:tracking-pill after:text-muted-fg after:inset-ring after:inset-ring-code-border after:content-[attr(data-lang)] print:min-h-0" + > + {children} + </pre> + ) +} + +function TraceCodeEl(props: ComponentProps<'code'>) { + return <code {...props} id="trace-code" /> +} + +const codeComponents = { TraceCode, pre: TracePre, code: TraceCodeEl } + +/** The pill shapes: the CMS-call/output toggles, and the step pills. */ +const PILL = 'rounded-full border border-border text-muted-fg transition-colors' +const ROW = 'flex flex-wrap items-center justify-between gap-3 border-b border-hairline px-4 max-sm:px-3' +const SEGMENTED = 'inline-flex max-w-full flex-wrap gap-1.5 print:hidden' + +function Toggle({ pressed, onClick, children }: { pressed: boolean; onClick: () => void; children: ReactNode }) { + return ( + <button + type="button" + aria-pressed={pressed} + onClick={onClick} + className={`${PILL} h-7.5 bg-transparent px-[13px] font-mono whitespace-nowrap text-12 leading-4 hover:border-olive-ring hover:text-fg aria-pressed:pill-on`} + > + {children} + </button> + ) +} + +export function Walkthrough() { + const [operation, setOperation] = useState<Operation>('resolve') + const [mode, setMode] = useState<Mode>('data') + const [rawStep, setStep] = useState(0) + const last = CAPTIONS[operation].length - 1 + const step = Math.min(rawStep, last) + + return ( + <div className="not-prose my-9 overflow-hidden rounded-2xl border border-hairline bg-surface shadow-win max-sm:rounded-box print:break-inside-avoid-page print:shadow-none"> + {/* Window bar: three dots (one ::before and its two box-shadow copies), then the title. */} + <div className={`${ROW} bg-win-bar py-3 max-sm:py-2.5`}> + <strong className="inline-flex items-center text-14 leading-5 font-medium tracking-ui text-fg before:mr-13 before:size-2.5 before:flex-none before:rounded-full before:bg-win-dot before:shadow-[16px_0_0_var(--win-dot),32px_0_0_var(--win-dot)] before:content-[''] max-sm:before:mr-11"> + SummerCard · one saved entry + </strong> + <div className={`${SEGMENTED} max-sm:w-full`} role="group" aria-label="CMS call"> + <Toggle + pressed={operation === 'get'} + onClick={() => { + setOperation('get') + setStep(0) + }} + > + resolve("SummerCard", {'{'} run: false {'}'}) + </Toggle> + <Toggle + pressed={operation === 'resolve'} + onClick={() => { + setOperation('resolve') + setStep(0) + }} + > + resolve("SummerCard") + </Toggle> + </div> + </div> + <div className={`${ROW} bg-surface py-2.5`}> + <span className="text-13 leading-5.5 text-muted-fg">Function output</span> + <div className={SEGMENTED} role="group" aria-label="Output representation"> + <Toggle pressed={mode === 'data'} onClick={() => setMode('data')}> + Descriptor + </Toggle> + <Toggle pressed={mode === 'rsc'} onClick={() => setMode('rsc')}> + React tree + </Toggle> + </div> + </div> + <div className="p-4.5 max-sm:p-3"> + <div className="mb-4 flex flex-wrap gap-1.5 print:hidden" role="group" aria-label="Resolution steps"> + {STEPS.map((label, i) => ( + <button + key={label} + type="button" + aria-current={i === step ? 'step' : 'false'} + disabled={i > last} + onClick={() => setStep(i)} + className={`${PILL} h-8 bg-surface px-3.5 text-13 leading-5 enabled:hover:border-olive-ring enabled:hover:text-fg disabled:cursor-default disabled:opacity-45 aria-[current=step]:pill-on`} + > + {label} + </button> + ))} + </div> + <ActiveState.Provider value={codeStateFor(step, mode)}> + <TraceCodeBlocks components={codeComponents} /> + </ActiveState.Provider> + <p className="my-4 min-h-14 text-15 leading-6 text-muted-fg" id="trace-caption" aria-live="polite"> + {CAPTIONS[operation][step]} + </p> + <div className="flex items-center justify-between gap-3 border-t border-hairline pt-4"> + <span + className="inline-flex items-center gap-2 font-mono text-12 leading-4 text-muted-fg before:size-[7px] before:flex-none before:rounded-full before:bg-brand before:shadow-[0_0_0_1px_var(--indicator-ring)] before:content-['']" + id="trace-stat" + > + {STATS[operation][step]} + </span> + {/* aria-disabled, not disabled: a disabled button drops keyboard focus to <body> on the last step. */} + <button + type="button" + className="inline-flex h-10 items-center gap-2 rounded-full bg-brand px-5 text-14 leading-5 font-medium text-brand-ink transition-[background-color,scale] ease-out-quart not-aria-disabled:hover:bg-brand-hover not-aria-disabled:active:scale-97 aria-disabled:cursor-default aria-disabled:opacity-45 print:hidden" + id="trace-next" + aria-disabled={step === last} + onClick={() => step < last && setStep(step + 1)} + > + Next step → + </button> + </div> + </div> + </div> + ) +} diff --git a/docs/components/widgets/index.tsx b/docs/components/widgets/index.tsx new file mode 100644 index 00000000..77903ba8 --- /dev/null +++ b/docs/components/widgets/index.tsx @@ -0,0 +1,7 @@ +/** + * Interactive widgets. Every capitalized export here is available to MDX pages by name, with no + * import (components/mdx/index.tsx spreads this module into the MDX component map). + * + * <Walkthrough /> "How resolution works": one saved entry, expanded and run step by step. + */ +export { Walkthrough } from './Walkthrough' diff --git a/docs/components/widgets/walkthrough-code.mdx b/docs/components/widgets/walkthrough-code.mdx new file mode 100644 index 00000000..89c0b8f1 --- /dev/null +++ b/docs/components/widgets/walkthrough-code.mdx @@ -0,0 +1,78 @@ +{/* + The code the Walkthrough widget shows, one block per state. It is MDX only so the blocks go + through the same build-time Shiki highlighting as every other code block on the site + (build/rehype-docs.ts); Walkthrough.tsx picks one <TraceCode> to render. + Generated from the old app.js data: JSON.stringify(value, null, 2). +*/} + +<TraceCode state="stored"> + +```json +{ + "__resolveType": "product-card", + "title": "Summer collection", + "product": { + "__resolveType": "CurrentProduct" + } +} +``` + +</TraceCode> + +<TraceCode state="expanded"> + +```json +{ + "__resolveType": "product-card", + "title": "Summer collection", + "product": { + "__resolveType": "catalog-product", + "slug": "summer-shirt" + } +} +``` + +</TraceCode> + +<TraceCode state="children"> + +```json +{ + "__resolveType": "product-card", + "title": "Summer collection", + "product": { + "name": "Summer shirt", + "price": 49 + } +} +``` + +</TraceCode> + +<TraceCode state="descriptor"> + +```json +{ + "component": "ProductCard", + "props": { + "title": "Summer collection", + "product": { + "name": "Summer shirt", + "price": 49 + } + } +} +``` + +</TraceCode> + +<TraceCode state="element"> + +```tsx +<ProductCard + title="Summer collection" + product={{ name: "Summer shirt", price: 49 }} +/> +``` + +</TraceCode> diff --git a/docs/content/next/analytics.mdx b/docs/content/next/analytics.mdx new file mode 100644 index 00000000..53b7460a --- /dev/null +++ b/docs/content/next/analytics.mdx @@ -0,0 +1,108 @@ +--- +title: Analytics +nav: Analytics +group: Monitoring +order: 22 +description: Privacy-friendly page views, with no cookies, compatible with One Dollar Stats, with settings kept in the CMS settings block. +--- + +# Analytics + +Marketing wants to know which landing page the Black Friday banner sends people to, without a cookie banner or a third-party tag manager. + +Deco CMS includes small, open-source web analytics compatible with [One Dollar Stats](https://onedollarstats.com). It counts page views without cookies, and its settings are content: the `analytics` section of your site's [CMS settings](/next/built-in-blocks#cms-settings), so editors can change them in [the site editor](/next/site-editor#settings). This page shows how to add it, where page views go, how to send your own events and how to turn it off. + +Analytics has nothing to do with [telemetry](/next/telemetry): telemetry is errors, metrics and traces from your servers, while analytics is page views from the browser. They only share the settings block, each in its own section. + +## Add analytics to your site + +### 1. Render the script + +Read the settings with [`cms.settings()`](/next/api-reference#cms-settings) in your root layout and pass the `analytics` section to `AnalyticsScript`, from `@decocms/blocks/analytics`, so every page gets it: + +```tsx title="app/layout.tsx" +import { AnalyticsScript } from "@decocms/blocks/analytics"; +import { cms } from "../cms"; + +export default async function RootLayout({ children }: { children: React.ReactNode }) { + const { analytics } = await cms.settings(); // the release's settings, defaults filled in + + return ( + <html lang="en"> + <body> + {children} + <AnalyticsScript {...analytics} /> + </body> + </html> + ); +} +``` + +That's all it takes: with no settings saved, the defaults apply, and page views go to the hosted Deco CMS collector (which counts them for [connected](/next/hosted#connect-your-site) sites; otherwise, set `collector` below). It works the same on every framework, since `cms.settings()` returns plain values. In the browser, the script sends a page view on every navigation: the path without its query string, the referrer and the hostname. + +### 2. Choose where page views go (optional) + +To send page views to your own collector, set `collector` in the `analytics` section, in the site editor's **Settings** or by hand: + +```json title=".deco/blocks/CMS.json" +{ + "__resolveType": "cms-settings", + "analytics": { "collector": "https://stats.example.com/events" } +} +``` + +The settings come from the release your servers serve, never from a draft: a change ships like any other content, and previewing a draft doesn't change where page views go. + +## Settings + +The `analytics` section's fields, which are also `AnalyticsScript`'s props: + +| Field | Default | What it does | +|---|---|---| +| `collector` | The hosted Deco CMS collector | The endpoint page views go to: One Dollar Stats' collector or any endpoint that accepts its format. | +| `enabled` | `true` | `AnalyticsScript` renders nothing when `false`. | + +There's no site ID: like One Dollar Stats, the collector tells sites apart by the page's hostname. The exact type is in the [API reference](/next/api-reference#types). + +<Hosted to="/next/hosted" label="The hosted Deco CMS">**Analytics with nothing to run.** Leave `collector` out, and the hosted Deco CMS collects your page views and shows them for your site. It only counts page views from sites [connected](/next/hosted#connect-your-site) to the hosted Deco CMS, so without it, set `collector` or turn analytics off.</Hosted> + +## Send your own events + +Call `track` from `@decocms/blocks/analytics`, with an event name and optional properties: + +```tsx +import { track } from "@decocms/blocks/analytics"; + +<button onClick={() => { addToCart(sku); track("add_to_cart", { sku }); }}>Add to cart</button> +``` + +`track` sends through the script `AnalyticsScript` renders. On a page without it, `track` does nothing. + +Ecommerce events, such as a product view or a purchase, belong to your [platform template](/next/how-it-works#key-terms): it calls `track` next to its own tag manager push, so Deco CMS itself knows nothing about carts or orders. + +## Turn it off + +Set `enabled` to `false` in the `analytics` section, in the site editor or by hand: + +```json title=".deco/blocks/CMS.json" +{ + "__resolveType": "cms-settings", + "analytics": { "enabled": false } +} +``` + +It's a content edit, so it ships like any other, and switching it back on is the same one-field commit. Like any field, the section can have [variants](/next/matchers-and-variants), to switch analytics on only during a campaign, for example. `cms.settings()` picks the variant when your layout calls it, so its rules see the request the way your other matchers do. + +## One Dollar Stats + +Page views use the wire format of the One Dollar Stats tracker, so the two halves mix and match: + +- point `collector` at One Dollar Stats' own collector, or +- use their tracker script on your site and send to a collector that accepts the format. + +The format isn't versioned, so Deco CMS tests against a pinned tracker version; the details are [under the hood](/next/telemetry-internals#analytics-events). + +## Privacy + +- No cookies, nothing stored in the browser and no fingerprinting: a page view is a path, a referrer and a hostname. +- Query strings are dropped, so search terms and tracking parameters stay out of your data. Keep personal data out of `track` properties. diff --git a/docs/content/next/api-reference.mdx b/docs/content/next/api-reference.mdx new file mode 100644 index 00000000..259bbde8 --- /dev/null +++ b/docs/content/next/api-reference.mdx @@ -0,0 +1,440 @@ +--- +title: API reference +nav: API reference +group: Reference +order: 25 +--- + +# API reference + +You're writing the request handler and need the exact signature of `list` or `matchRoute`. This page lists everything `@decocms/blocks` exports. + +Everything here is imported from `@decocms/blocks`, except the instrumented fetch for [upstream clients](/next/upstream-clients#what-a-client-is), in `@decocms/blocks/fetch`, [`track` and `AnalyticsScript`](#analytics), in `@decocms/blocks/analytics`, and [`encryptSecret`](#secrets), in `@decocms/blocks/secrets`. The [built-in blocks](/next/built-in-blocks), such as the [matchers](/next/matchers-and-variants#matchers), `multivariate` and `lazy`, need no import. What creating the CMS means for your app is in [Content and loaders](/next/content#create-the-cms). + +## `createCMS(config)` + +`createCMS` returns a **CMS**: your [block map](/next/blocks#the-block-map) plus your content. Create it once, at module scope, and ask it for a client per request: `forRelease()` for visitors, who see the current [release](/next/releases-and-drafts#releases), and `forDraft(pointer)` for [drafts](/next/releases-and-drafts#drafts), where the pointer names the draft (see [Draft pointers](#draft-pointers)). + +- **One revision per client.** A client loads one [revision](/next/releases-and-deployment#what-a-revision-is) on first use and reads only that revision afterwards; every later `list` or `resolve` reuses it. Create one client per request (why: [One revision per response](/next/releases-and-deployment#one-revision-per-response)). `client.revision()` tells you which revision a client reads, and `cms.forRevision(revision)` returns a client pinned to it. +- **Results are memoized per client and per block-map object**: build the map at module scope. An entry referenced three times runs its function once per client. Results are kept per function of the map you passed, so a map rebuilt inside your handler starts with an empty cache. +- **Clients are cheap**: the content cache lives in the CMS, shared by every client. +- **Same config, same instance**: see [One instance per process](#one-instance-per-process). + +```ts +function createCMS(config: { + blocks: Blocks; // your block functions; the CMS uses { ...builtIns, ...blocks }, so a key here overrides a built-in + content: Snapshot | Loader; // the content module (.deco/blocks.gen.ts), or any Loader + interval?: number; // ms between update() checks of a content source that has one; default DECO_CONTENT_INTERVAL or 60_000; minimum 60_000 + + telemetry?: false | TelemetryConfig; // where telemetry goes; see /next/telemetry + preview?: { hosts?: string[] }; // the hosts content may allow previews on; see /next/releases-and-drafts#allow-previews-per-host + + secrets?: { key?: string }; // the private key that decrypts secret blocks, usually DECO_SECRETS_KEY; see /next/built-in-blocks#secrets + + // Hosted releases and drafts (optional), see /next/hosted + site?: string; // your site's ID, usually DECO_SITE + token?: string; // your site token (secret), usually DECO_SITE_TOKEN +}): CMS; + +type TelemetryConfig = ( + | { site: string; token: string } // the hosted Deco CMS collector + | { endpoint: string; headers?: Record<string, string> } // any OpenTelemetry (OTLP/HTTP) collector +) & { + limits?: { errorSampleRate?: number; traceSampleRate?: number }; // caps on the CMS block's telemetry section; defaults 0.1 and 0 +}; + +interface CMS { + forRelease(): Client; // a client reading the current release + forDraft(pointer: string): Client; // a client reading the draft a pointer names, with the variants it forces; a source without drafts behaves like the release (forced variants still apply); a draft that can't load makes every call return [null, error] + forRevision(revision: string): Client; // a client pinned to a revision it has served; an unknown revision behaves like the release + update(): Promise<{ updated: boolean }>; // ask the content source for newer content now; never throws + settings(): Promise<EffectiveSettings>; // the release's CMS settings, defaults filled in and caps applied; never fetches, never rejects + draftPointer(request: RequestLike): Promise<string | null>; // the request's draft pointer; null on a host previews aren't allowed on + draftCookie(request: RequestLike): Promise<string | null>; // the Set-Cookie value that starts or ends a preview; see Draft pointers +} + +interface Client { + resolve<T = unknown>(target: unknown, options?: { run?: boolean }): Promise<Result<T>>; // run: false reads without running + list<T = Block>(type: string, options?: ListOptions<T>): Promise<Result<T[]>>; + revision(): Promise<string>; // the revision this client reads (loads it on first use); rejects with LOADER_FAILED if the content can't load +} +``` + +`content` takes the content module or a `Loader` (see [Configuring content](#configuring-content)). `interval` paces a content source that can change while the process runs (see [Loaders](#loaders)); the content module never changes, so it ignores it. `telemetry` says where measurements go: `false` sends nothing, an object sends there, and when it's left out the CMS uses `OTEL_EXPORTER_OTLP_ENDPOINT` (and `OTEL_EXPORTER_OTLP_HEADERS`) if set, otherwise nothing (see [Choose where telemetry goes](/next/telemetry#choose-where-telemetry-goes)). The top-level `site` and `token` load [hosted releases and drafts](/next/hosted) only; with either of them unset, the CMS reads `content` only. They never send telemetry by themselves. `secrets.key` is the PEM private key that [`secret` blocks](/next/built-in-blocks#secrets) decrypt with; without it, a `secret` block fails. `preview.hosts` is the most content may allow previews on, in the [host pattern](#host-patterns) format; without it, content may allow any host, and without content either, every host may preview. `createCMS` throws on a pattern it can't read, since that's a bug in your code, not in content. The [rule from Blocks](/next/blocks#the-lookup-rule) is how a client resolves. + +<Hosted to="/next/hosted#connect-your-site" label="Connect your site">**Hosted options.** With `site` and `token` set, the CMS serves releases published without a deploy and loads drafts for `forDraft`.</Hosted> + +### Configuring content + +`content` is the content module, `.deco/blocks.gen.ts`, in almost every app (see [The content module](/next/content#the-content-module)). It also accepts any object with a `load()` method that returns a snapshot, for cases the content module doesn't cover; the `Loader` interface is in [Loaders](#loaders). + +## Loaders + +One interface. `load()` with no argument is production; `load(pointer)` is a draft. `update()` checks for newer content. Most sites never write one: they pass the content module. When and how to write one is in [Write a loader](/next/content#write-a-loader). Here a loader is a source of content; it isn't [the site editor](/next/site-editor)'s "loaders" or a router's route loader. + +```ts +interface Loader { + load(pointer?: string | null): Promise<Snapshot>; // Snapshot = { revision, blocks }; see Types + update?(): Promise<{ updated: boolean }>; +} +``` + +| Content | Loads | Use it for | +|---|---|---| +| A snapshot `{ revision, blocks }` (the content module) | The content that ships with the app, generated by `deco content`. Ignores the pointer: there are no drafts in it. No `update()`. | Every site: the content your build ships. With the hosted Deco CMS, also the fallback before the first release | +| Any object with `load()` | The snapshot its `load()` returns, or the draft a pointer names if it has drafts. If it can change while the process runs, give it `update()`. | Content kept somewhere other than the build, such as your own storage or Workers KV (see [Example: Workers KV](#example-workers-kv)) | +| `remoteLoader(fallback: Snapshot \| Loader, { site?, token?, interval? })` | Hosted: releases and drafts from the [Deco API](/next/hosted#terms), over the fallback. `createCMS` builds it for you when `site` and `token` are set. Without `site` or `token`, it's a loader over the fallback alone. A release or draft over 64 MB is refused while it downloads. See [Publishing without a deploy](/next/hosted-publishing). | The hosted Deco CMS | + +Hosted `remoteLoader` uses complete snapshots for releases and private overlay assets for drafts. It captures the local production snapshot once, loads the overlay manifest and missing changed-block blobs, and exposes the combined view through the existing `Snapshot` interface. It does not request a matching base release. The draft client's revision is an opaque identity derived from the captured production revision and overlay version; cache composed results with both. Custom loaders may still return self-contained draft snapshots. See [Draft overlays for fast previews](/next/content-delivery#exact-draft-previews). + +### Example: Workers KV + +Large sites on Cloudflare Workers can keep their content out of the bundle (see [Large content on Workers](/next/hosted-publishing#large-content-on-workers)). The loader is a few lines of your own code: + +```ts title="src/kv-loader.ts" +import type { Loader, Snapshot } from "@decocms/blocks"; + +// Loads the content module your deploy wrote to Workers KV under `key`. +export function kvLoader(kv: KVNamespace, key: string): Loader { + return { + async load() { + const snapshot = await kv.get<Snapshot>(key, "json"); + if (!snapshot) throw new Error(`no content in KV under "${key}"`); + return snapshot; + }, + }; +} +``` + +```ts title="src/cms.ts" +import { env } from "cloudflare:workers"; +import { createCMS } from "@decocms/blocks"; +import blocks from "../.deco"; +import { kvLoader } from "./kv-loader"; + +// CONTENT is a KV binding; CONTENT_KEY is a variable your deploy sets, one key per deploy. +export const cms = createCMS({ blocks, content: kvLoader(env.CONTENT, env.CONTENT_KEY) }); +``` + +Your deploy writes the content to that key before the new Worker takes traffic, for example with `wrangler kv key put`. Use a new key for every deploy, such as one named after the commit, so Workers still running the old code keep reading the old content during a rollout. It ignores the pointer, so it serves no drafts, and has no `update()`, because a deploy's key never changes. + +A loader with `update()` is checked on its own: every `interval` (default `DECO_CONTENT_INTERVAL` or 60 000 ms, never below 60 000), at the next idle moment, so a request never waits on it. On Cloudflare Workers, which run no timers between requests, a due check runs after the response inside `ctx.waitUntil`. `cms.update()` checks at once, for a webhook or an admin "refresh now" button; it refreshes only the server that runs it, and never throws. A snapshot, like the content module, never changes, and a loader without `update()` is always current by construction. + +If `load(pointer)` fails, or the pointer doesn't parse, every call on that client returns `[null, error]` with `LOADER_FAILED`: no silent fallback to published content, and never a mix of draft and published entries. Your app decides what to show. + +A pointer comes from request input, so a loader that serves drafts must treat it as untrusted (see [Write a loader](/next/content#write-a-loader)). + +Caching, ETags, and fallbacks live inside the CMS, which any number of clients share. The client only ever sees `{ revision, blocks }`. + +## One instance per process + +`createCMS` instances (and hosted `remoteLoader` instances) are process-wide singletons. Each is stored on `globalThis` under a `Symbol.for("decocms.blocks…")` key derived from its configuration: the content's identity (for the content module, the `.deco` folder it was generated from; for a loader you write, the loader object; never the revision, so a hot reload that hands in new content keeps the same instance), and, with the hosted Deco CMS, also the site ID and token. So a package loaded twice, by two bundles or by a dev reload (hot module replacement, HMR), still shares one content cache and one schedule of `update()` checks. Different configurations, such as several sites in one app, get different instances. Calling it again with the same key but different options keeps the first instance and logs a warning that names the conflicting options. + +Each call returns a handle on that shared instance holding the call's own `blocks`: the content, caps, telemetry and update checks are shared, and each handle resolves with the block map it was created with. So a second bundle in the same process, such as Next.js's `proxy.ts`, can import your `cms` without replacing the app's block map, and a hot reload's new map is used by the `cms` it returns. Calling it again with the same block map object returns the same handle. Tests can start clean: + +```ts +function resetForTests(): void; // clears every stored instance +``` + +## `cms.settings()` + +Returns your site's [CMS settings](/next/built-in-blocks#cms-settings): the saved block named `CMS`, of the built-in type `cms-settings`, from the release, with every default filled in and the caps from `createCMS` applied. Telemetry, analytics and the draft helpers read the settings the same way. + +```ts +interface CMS { + settings(): Promise<EffectiveSettings>; +} + +interface EffectiveSettings { + preview: { hosts: string[] }; // the hosts previews are allowed on, within preview.hosts from code; ["*"] means every host + telemetry: Required<Telemetry>; // sample rates already capped by telemetry.limits + analytics: Required<Analytics>; +} +``` + +- **Always the release.** It reads the current release this server already has in memory, never a draft, so no draft can allow its own preview host or change what telemetry sends. It never fetches: at boot it reads the content module, and a newer release arrives with the next [background check](/next/hosted-releases-internals). With a [loader you write](/next/content#write-a-loader) that has no content in memory yet, it returns the defaults until the first release loads; after that, an `update()` keeps the previous release's settings until the next release loads. +- **Read-only.** The settings are frozen, since every caller gets the same object; copy them to change anything. +- **Never rejects.** A missing `CMS` block, a block of another type, or a section that fails to resolve gives that section's defaults (still capped by code). +- **Variants are picked per call.** The block resolves on each call, so a field with [variants](/next/matchers-and-variants#variants) is decided by its rules where you call it, in your request scope. Telemetry reads its section outside any request. + +How each cap applies: + +| Section | Without the block or field | With it | +| --- | --- | --- | +| `preview.hosts` | `preview.hosts` from code, or `["*"]` when code sets none | The entries that fall entirely [within](#host-patterns) code's `preview.hosts`; the others are left out. An empty list allows no host. | +| `telemetry` | `enabled: true`, `metrics: true`, `errorSampleRate: 0.05`, `traceSampleRate: 0`, then capped | Each sample rate is the lower of content's and `telemetry.limits` | +| `analytics` | `enabled: true`, `collector`: the hosted Deco CMS collector | As saved; there's no cap | + +### Host patterns + +`preview.hosts`, in code and in content, is a list of host patterns. A request's host is the hostname and port of its URL (`new URL(request.url)`), so it's whatever your framework puts there, usually from the `Host` header. + +| Pattern | Matches | Never matches | +| --- | --- | --- | +| `"*"` | Every host | | +| `"staging.example.com"` | `staging.example.com`, on any port | `example.com`, `www.staging.example.com` | +| `"*.example.com"` | `a.example.com`, `a.b.example.com`, on any port | `example.com`, `badexample.com`, `a.example.com.attacker.com` | +| `"localhost:3000"` | `localhost` on port `3000` only | `localhost`, `localhost:3001` | + +The rule in full: + +- **Compared in a normal form.** Hostnames and patterns are compared lowercase, with one trailing dot removed, so `Staging.Example.com.` is `staging.example.com`. Names outside ASCII are compared in their punycode form (`xn--…`), which is how URLs carry them. +- **Exact names match only themselves.** A pattern without `*` matches that one hostname. +- **A wildcard is a whole leading label.** `*.example.com` matches a hostname that ends in `.example.com` and has at least one more label in front, compared label by label from the right. `*` can't appear anywhere else (`a*.example.com`, `*.*.com`), and at least two labels must follow it, so `*.com` isn't a pattern. `"*"` on its own matches every host. +- **Ports are optional.** A pattern without a port matches any port. A pattern with one matches only a URL that names that port; URLs never name a default port, so write `example.com`, not `example.com:443`. +- **IP addresses match exactly.** An IPv4 address, or an IPv6 address in brackets, can be a pattern, but never with a wildcard. +- **Anything else isn't a pattern**, such as a scheme, a path, a space or an empty label. In code, `createCMS` throws; in content, the entry is left out. +- **An unreadable URL matches only `"*"`.** That includes a URL with a username or password (`https://public.com@staging.example.com/`, which a forged `Host` header can produce in a URL built from it) and a hostname with an empty label (`a..example.com`). + +A content entry is **within** code's list when every host it matches is also matched by a code entry: `staging.example.com` is within `*.example.com`, `*.a.example.com` is within `*.example.com`, but `*.example.com` isn't within `*.a.example.com`. A content entry without a port is within only a code entry without a port, and `"*"` is within only `"*"`. An entry outside code's list is left out, so content can narrow where previews are allowed but never widen it. + +## Draft pointers + +A pointer is a string, `<host[:port]><path[?query]>@<version>`, that names where a draft lives and which version it is. `forDraft` passes it to your content source's `load(pointer)`; the content module ignores it (see [Releases and drafts](/next/releases-and-drafts#drafts)). With the hosted Deco CMS, the site editor makes them ([Previewing drafts](/next/hosted-drafts)). Two helpers cover everything in between: + +```ts +interface DraftPointer { + host: string; // host[:port] of the content source that holds the draft (the Deco API, when hosted), e.g. "api.deco.example" + path: string; // starts with "/"; opaque to your app (hosted drafts put a token signed by the site editor in its query) + version: string; // opaque, immutable: the branch head or ETag + variants?: { block: string; path: string; index: number }[]; // the variants a preview forces; absent when none +} + +function parseDraftPointer(raw: string | null | undefined): DraftPointer | null; +function formatDraftPointer(pointer: DraftPointer): string; +``` + +`parseDraftPointer` is strict and returns `null` on anything unexpected: a scheme, a stray `@`, an unrooted path, an odd character in the host or version. It's what `forDraft` calls, so an app only needs it to look inside a pointer, for example to show which version is being previewed, to key a cache on `version`, or to reject a pointer before storing it in a cookie. `formatDraftPointer` is the inverse, for building one from parts, as a mobile app might from a deep link. It throws on parts that wouldn't parse back, such as a path with a space or a `…` in it. + +`__variant` is a reserved parameter of the pointer's query: each one is a [forced variant](/next/releases-and-drafts#preview-a-variant), `<block>@<path>=<index>` URL-encoded, where `block` is the saved block the `multivariate` is saved in, `path` its JSON path inside that block (dot-separated keys and array indexes, empty for the saved block itself) and `index` the variant to show. `parseDraftPointer` moves them out of `path` into `variants` and rejects the pointer if one is malformed; `formatDraftPointer` appends them. `forDraft` applies them to the content it reads, even from a source with no drafts, and hands `load(pointer)` the pointer without them, so every variant of one draft shares one load. + +For websites, two more helpers carry a pointer from a URL into a cookie, so the parameter and cookie names never appear in app code. They're methods of the CMS because they check the request's host against your [preview hosts](#cms-settings). The hosted draft flow uses them; see [Wire drafts into your app](/next/hosted-drafts#wire-drafts-into-your-app): + +```ts +type RequestLike = Request | { url: string; headers: { get(name: string): string | null } }; + +interface CMS { + draftPointer(request: RequestLike): Promise<string | null>; + // ?__draft= from the URL first, then the deco-draft cookie. Null when neither is present, when the URL says ?__draft=off, + // and on a host outside settings().preview.hosts, where the request gets the release. + + draftCookie(request: RequestLike): Promise<string | null>; + // When the URL carries a valid ?__draft= on an allowed host, the Set-Cookie value that stores it + // (HttpOnly; Secure; SameSite=None; Partitioned; Path=/). When it says ?__draft=off, one that expires the cookie, + // on any host (how the site editor ends a preview). Null on every other request, and for a ?__draft= that doesn't parse. +} +``` + +Both take anything with a `url` and `headers`, so they work with a fetch `Request`, a Next.js `NextRequest`, a framework's request wrapper, or, in a Next.js Server Component, the result of `headers()` with the page's URL (see [Next.js](/next/hosted-drafts#next-js)). The `url` must be absolute: a host that can't be read is treated as outside the list, unless every host is allowed. + +**On a host outside the list, the request gets published content, never an error.** `draftPointer` returns `null`, ignoring both the parameter and the cookie, and `draftCookie` sets nothing. The check isn't access control, which is the signed, expiring grant inside the pointer (see [Who may preview](/next/hosted-drafts#who-may-preview)): it keeps drafts off your public domains, out of the caches in front of them and out of search engines. The list comes from [`cms.settings()`](#cms-settings), so it's always the release's, never the draft's. + +`forDraft` itself checks no host, since a pointer doesn't always come from a request (see [Not a website](/next/hosted-drafts#not-a-website)). If your app reads a pointer some other way, deciding where previews are allowed is up to that code. + +The cookie is `SameSite=None; Secure; Partitioned` because the site editor shows your site in an iframe on another site, and a `Lax` cookie wouldn't be sent there, so the preview would fall back to the release. `Partitioned` keeps it inside the site editor's frame, so it doesn't follow you to normal visits, and `HttpOnly` keeps page scripts from reading it. + +```ts +const pointer = parseDraftPointer(cookie); +if (pointer) console.log(`previewing ${pointer.version} from ${pointer.host}`); + +// the path is opaque: copy it, don't build it (a custom loader's pointer; hosted +// pointers are delivery.decocms.com/sites/<site>/drafts?token=…@<overlay version>) +formatDraftPointer({ host: "api.deco.example", path: "/drafts/acme/main?token=abc123", version: "9f3c1a" }); +// "api.deco.example/drafts/acme/main?token=abc123@9f3c1a" + +// show variant 1 of the multivariate at sections.3 of the saved block Home +formatDraftPointer({ host: "localhost:4547", path: "/", version: "local", variants: [{ block: "Home", path: "sections.3", index: 1 }] }); +// "localhost:4547/?__variant=Home%40sections.3%3D1@local" +``` + +## `client.resolve(target, options?)` + +Loads the content once and applies the [lookup rule](/next/blocks#the-lookup-rule) to the target: [saved blocks](/next/saved-blocks) expand, and each function the target names in your block map runs with its resolved inputs. [Built-in blocks](/next/built-in-blocks) are always defined. + +| Call | Returns | +|---|---| +| `resolve("SummerSEO")` | The result of running the entry's function: for `SummerSEO`, `seo({ title: "Sunny!", description: "Light layers for long days." })` | +| `resolve("SummerSEO", { run: false })` | The saved block, with references expanded and nothing run | +| `resolve("SummerPage")` | The built-in page block's result: the page with `seo` and every block in `sections` resolved | +| `resolve({ __resolveType: "seo", title: "Sale" })` | The result for an inline block: `seo({ title: "Sale" })` | +| `resolve(page.sections)` | An array of results, one per block in the list; a block that resolves to `undefined`, such as a [hidden](/next/matchers-and-variants#hide-a-block) one, is left out | +| `resolve({ title: "Store" })` | The same object: it contains no blocks | + +A string target is always a saved block's name. Any other value is walked as is, so you can pass a page field without checking whether it's a block or a literal. `T` describes the result you expect, because a saved block's name can't carry a static type; it isn't validated at runtime. + +**Reading without running.** With `{ run: false }`, `resolve` stops after expanding saved blocks: references are replaced and merged, and no function runs. Use it for previews, tooling and debugging, and to get a page's blocks without running them, so you can resolve each one with its own call; the [Next.js guide](/next/nextjs#7-stream-each-block-optional) streams a page that way. `client.list` returns entries this way by default. + +## `client.list(type, options?)` + +Every saved block whose [`__resolveType`](/next/blocks#functions-as-blocks) is `type` or an [alias](/next/renames-and-migrations) of it (another key for the same type, used when renaming). By default nothing runs: each entry comes back as `resolve(name, { run: false })` would return it. With `run: true` each entry is resolved, so every block inside it, and the entry's own type, must be in the block map (built-ins included) or a saved block, or it fails with [`UNKNOWN_BLOCK`](#errors). `list("page", { run: true })` returns ready pages. Entries come back sorted by name, so the order is the same on every server. + +```ts +interface ListOptions<T> { + where?: (entry: T) => boolean; + sort?: (a: T, b: T) => number; + limit?: number; + run?: boolean; +} + +const today = new Date().toISOString().slice(0, 10); // "YYYY-MM-DD", comparable as a string +const [posts] = await client.list<Post>("post", { + where: (p) => p.date <= today, + sort: (a, b) => b.date.localeCompare(a.date), + limit: 20, +}); +``` + +Filters run in memory over the loaded content map, the cost of content arriving as the whole map at once (see [Snapshots and revisions](/next/internals#snapshots-and-revisions)). Sites with tens of thousands of entries of one type should keep an index elsewhere. + +## `matchRoute(url, items)` + +A pure function. Given a URL and the entries you want to route, it returns the one that matches. The CMS doesn't know about URLs; this is the companion that does. See [Pages and routing](/next/routing). + +```ts +function matchRoute<T extends Route>( + url: string | URL | Request, + items: { routes: T[]; redirects?: Redirect[] }, +): Match<T>; + +type Match<T> = + | { kind: "match"; entry: T; params: Record<string, string> } + | { kind: "redirect"; location: string; status: 301 | 302 | 307 | 308 } + | { kind: "not-found" }; +``` + +Paths take literal segments, `:name` parameters and a trailing `/*` splat that matches one or more remaining segments into `params["*"]` (see [Match order](/next/routing#path-templates-and-match-order)). Redirects win over routes, exact paths win over parameters, parameters win over a splat, and when two entries can match the same URL, the one earlier in the array wins. It never throws. Entries are compiled into a segment trie, cached per `routes` array object, so a lookup costs the URL's depth, not the number of routes, and passing the same array again skips the build. See [how it matches](/next/router-internals). + +## `createInstrumentedFetch(options)` + +From `@decocms/blocks/fetch`. The fetch every [upstream client](/next/upstream-clients) uses: it times each request until its response headers arrive and labels the measurement. The CMS in the same process sends those measurements wherever its [`telemetry` option](/next/telemetry#choose-where-telemetry-goes) points; with no destination, nothing leaves your servers. + +```ts +function createInstrumentedFetch(options: { + provider: string; // the provider label, e.g. "vtex", "acme-search" + fetch?: typeof fetch; // the fetch underneath; defaults to globalThis.fetch + retry?: { attempts: number; backoffMs?: number }; // off unless set; a retried request is measured once + circuitBreaker?: { failures: number; cooldownMs: number }; // off unless set +}): (input: string | URL | Request, init?: RequestInit & { operation?: string }) => Promise<Response>; +``` + +Each request is measured with `provider`, `operation`, `status_class`, `cached` and `retries` labels (see [What's sent](/next/telemetry#whats-sent)). A response with an `x-cache: HIT` header is labeled `cached`, so a cache you put underneath (see [Upstream data](/next/caching#upstream-data)) shows up in its measurements. It never logs request or response bodies, tokens or cookies. Writing a client with it is in [Write a client](/next/upstream-clients#write-a-client). + +## Analytics + +Page view settings are the `analytics` section of [`cms.settings()`](#cms-settings), the `Analytics` type in [Types](#types); see [Analytics](/next/analytics). From `@decocms/blocks/analytics`: + +```ts +function AnalyticsScript(props: Analytics): ReactNode | null; // the tracking script; render it in your root layout with (await cms.settings()).analytics; null when enabled is false +function track(name: string, props?: Record<string, string | number | boolean>): void; // send your own event from the browser, through the script AnalyticsScript renders; does nothing without it +``` + +## Secrets + +From `@decocms/blocks/secrets`. Encrypts a value with your public key, for a script or an AI agent that writes content; the site editor does the same for editors. How secrets work is in [Secrets](/next/built-in-blocks#secrets). + +```ts +function encryptSecret(publicKey: string, value: string): Promise<{ __resolveType: "secret"; ciphertext: string }>; +// publicKey: the contents of .deco/secrets.pub +``` + +## Types + +```ts +// A block as stored: the JSON in .deco/blocks, and what client.list and { run: false } return. +// Props never use it: a field's type is the value the function receives (see Schema). +type Block = { __resolveType: string; [input: string]: unknown }; + +// A block map: what .deco/index.ts exports and the CLI reads. +type BlockFunction = (inputs: any) => unknown | Promise<unknown>; +type Blocks = Record<string, BlockFunction>; + +// One fixed copy of the content: what the content module exports and load() returns. +type Snapshot = { + revision: string; + blocks: Record<string, unknown>; // the content map: entry name → saved JSON + aliases?: Record<string, string>; // the alias table deco content writes: old type name → your type +}; + +interface Loader { + load(pointer?: string | null): Promise<Snapshot>; + update?(): Promise<{ updated: boolean }>; +} + +interface DraftPointer { host: string; path: string; version: string; variants?: { block: string; path: string; index: number }[] } +type RequestLike = Request | { url: string; headers: { get(name: string): string | null } }; // what draftPointer and draftCookie take + +interface Route { name: string; path: string } // extend it to give a type a URL + +// The built-in types. Their blocks are always in the registry and the schema: override one by declaring +// the key in your block map, e.g. page: (props: StorePage) => props with an interface that extends Page. +interface Seo { title: string; description: string } +interface Page extends Route { seo?: Seo; sections: ReactNode[] } // resolved types (see Schema); without seo, your site's defaults apply +interface Redirect { + from: string; + to: string; + permanent: boolean; // 301 or 302 + status?: 301 | 302 | 307 | 308; // wins over permanent + discardQueryParameters?: boolean; // drop the request's query string instead of carrying it over +} +type Secret = string & { readonly __secret: true }; // a field the site editor saves encrypted, as a secret block; see /next/built-in-blocks#secrets +type Lazy<T> = () => Promise<T>; // a prop the built-in lazy block fills; resolves its value when called, at most once +interface Variant<T> { rule: boolean; value: Lazy<T> } // one entry of variants: a rule and the variant it shows. Built-in multivariate takes { variants: Variant<T>[] }, runs the first variant whose rule is true, and returns Promise<T | undefined> + +// The built-in cms-settings block's props: the type of the well-known saved block CMS (always in the schema). +// Every field is optional and any field can have variants. The block returns its input with the telemetry and analytics +// defaults filled in; read it through cms.settings(), which also applies code's caps. See /next/built-in-blocks#cms-settings. +interface CMSSettings { + preview?: { hosts?: string[] }; // host patterns previews are allowed on, within createCMS preview.hosts; default: code's list, or every host + telemetry?: Telemetry; + analytics?: Analytics; +} + +// What cms.settings() returns. +interface EffectiveSettings { + preview: { hosts: string[] }; // ["*"] means every host + telemetry: Required<Telemetry>; + analytics: Required<Analytics>; +} + +// The telemetry section. See /next/telemetry#telemetry-settings-are-content. +interface Telemetry { + enabled?: boolean; // default true; false switches telemetry off + metrics?: boolean; // default true + errorSampleRate?: number; // default 0.05, capped by telemetry.limits + traceSampleRate?: number; // default 0, capped by telemetry.limits +} + +// The analytics section, and AnalyticsScript's props. See /next/analytics. +interface Analytics { + collector?: string; // an endpoint that accepts the One Dollar Stats format; default: the hosted Deco CMS collector + enabled?: boolean; // default true; false makes AnalyticsScript render nothing +} + +type Match<T> = + | { kind: "match"; entry: T; params: Record<string, string> } + | { kind: "redirect"; location: string; status: 301 | 302 | 307 | 308 } + | { kind: "not-found" }; + +type Result<T> = [T, null] | [null, CMSError]; + +interface CMSError { + code: "NOT_FOUND" | "UNKNOWN_BLOCK" | "CYCLE" | "BLOCK_FAILED" | "LOADER_FAILED"; + message: string; + path: (string | number)[]; // where in the tree it happened + cause?: unknown; // the original error, for BLOCK_FAILED and LOADER_FAILED +} +``` + +## Errors + +Nothing in the SDK throws at request time: `resolve` and `list` return `[value, null]` or `[null, error]`, and `matchRoute` returns a `Match`. The one exception is `client.revision()`: it returns a plain promise, which rejects with a `LOADER_FAILED` error when the content can't load. Two entries that can match the same URL are a content bug [`deco check`](/next/checking#what-deco-check-checks) reports (see [Backward compatibility](/next/checking#backward-compatibility)). Check the error, not the value, because a block function can legitimately return `null`. + +| Code | When | +|---|---| +| `NOT_FOUND` | A string target names no saved block. | +| `UNKNOWN_BLOCK` | A `__resolveType` names neither a type in the block map nor a saved block. [Built-in blocks](/next/built-in-blocks) never cause it. | +| `CYCLE` | References lead back to an entry that's already being expanded (see [The rule in full](/next/how-resolution-works#the-rule-in-full)). | +| `BLOCK_FAILED` | A block function threw. The original error is in `cause`. | +| `LOADER_FAILED` | The loader couldn't load the content, or a draft pointer is invalid. The original error, if any, is in `cause`. | + +A `CMSError` has a `code`, a `message`, a `path` (where in the tree it happened, such as `["sections", 2, "product"]`), and an optional `cause`. Log it on the server, and show visitors a generic message. diff --git a/docs/content/next/blocks.mdx b/docs/content/next/blocks.mdx new file mode 100644 index 00000000..1a14364d --- /dev/null +++ b/docs/content/next/blocks.mdx @@ -0,0 +1,151 @@ +--- +title: Blocks +nav: Blocks +group: Blocks +kind: docs +order: 4 +description: A block is a function call written as JSON. The function is code; the call is data, so editors, agents and tools can change it without touching code. +--- + +# Blocks + +Here's the free-shipping bar at the top of a store, with its headline written straight into the page: + +```tsx +<PromoBanner title="Free shipping over $50" href="/summer" /> +// which is the call +PromoBanner({ title: "Free shipping over $50", href: "/summer" }); +``` + +Blocks is a way to write that same call as JSON, so the headline can change without a code change: + +```json +{ "__resolveType": "promo-banner", "title": "Free shipping over $50", "href": "/summer" } +``` + +The function is code; the call is data. Everything else in [Deco CMS](/next/how-it-works), the forms, the checks, [the site editor](/next/site-editor), variants and releases, reads and writes this format. This page shows what a block looks like, how blocks nest, the block map that decides which functions they can call, and the rule that finds the function for a name. + +## Functions as blocks + +`PromoBanner` is an ordinary React component: + +```tsx title="src/promo-banner.tsx" +export interface PromoBannerProps { + title: string; + href: string; +} + +export function PromoBanner({ title, href }: PromoBannerProps) { + return <a href={href}>{title}</a>; +} +``` + +And here's a block that calls it. `"promo-banner"` is the name your site gives `PromoBanner`; [The block map](#the-block-map) shows where names come from. These docs show every block in two parts: **The call** is the TypeScript you'd write, and **Saved as JSON** is how Blocks stores it. + +```jsonc +// The call +PromoBanner({ title: "Free shipping over $50", href: "/summer" }); + +// Saved as JSON +{ + "__resolveType": "promo-banner", + "title": "Free shipping over $50", + "href": "/summer" +} +``` + +Changing the headline or the link touches no code, and the same works for anything your site needs: UI, product data, SEO, navigation, campaign settings. + +What a block can contain: + +- **`__resolveType`**, a string: the name of the function to call. It's a reserved key; the underscores keep it from clashing with your props. +- **Any JSON value as an argument**: strings, numbers, booleans, `null`, arrays and objects. Anything JSON can't hold, such as a function or a `Date`, can't be an argument. +- **Other blocks**, anywhere inside an argument. That's [Composing blocks](#composing-blocks). + +### Reading the request + +A block function gets only its arguments: no request, no context object. When it needs the current request, a cookie or a route parameter, it reads it the way the rest of your app does, through your framework's request-scoped storage (`next/headers`, TanStack Start's `getRequest()`, or Node's `AsyncLocalStorage`). That keeps block functions pure, so you can call them in a test. + +## Composing blocks + +A block inside an argument is a call inside a call. Here a product card gets its product from a second function, `catalogProduct`, which fetches it from your catalog: + +```jsonc +// The call +productCard({ + title: "Summer collection", + product: catalogProduct({ slug: "summer-shirt" }), +}); + +// Saved as JSON +{ + "__resolveType": "product-card", + "title": "Summer collection", + "product": { "__resolveType": "catalog-product", "slug": "summer-shirt" } +} +``` + +Resolving a block evaluates it from the inside out, like the TypeScript you'd write. `catalog-product` has plain arguments, so it runs first. Its result becomes the `product` argument of `product-card`, so `productCard` receives the resolved `Product`, never a pending call. If a function is `async`, its result is awaited first. Every argument resolves this way, before the function runs, with one exception: a [lazy block](/next/lazy-blocks), which runs only when the function asks for it. + +So type the prop as what the function receives, `product: Product`, not as a block. [Forms from types](/next/schema#interchangeable-blocks) shows how editors then get to pick any function that returns a `Product`, such as `catalogProduct`. + +To resolve a block from your code, pass it to [`client.resolve`](/next/api-reference#client-resolve-target-options). Get a client with `cms.forRelease()`, where `cms` is the object your app creates once (see [Create the CMS](/next/content#create-the-cms)), one client per request ([why](/next/releases-and-deployment#one-revision-per-response)): + +```tsx +import { cms } from "./cms"; + +const client = cms.forRelease(); +const [card, error] = await client.resolve({ + "__resolveType": "product-card", + "title": "Summer collection", + "product": { "__resolveType": "catalog-product", "slug": "summer-shirt" }, +}); +// card = <ProductCard title="Summer collection" product={{ name: "Summer shirt", … }} /> +``` + +`resolve` never throws: it returns `[value, null]`, or `[null, error]` if something failed. It accepts any JSON value, not only a block: blocks anywhere inside are resolved, and everything else comes back as it is. Pass an array of blocks and you get an array of results. A block that resolves to `undefined`, such as one an editor [hid](/next/matchers-and-variants#hide-a-block), is left out of the array. + +## The block map + +So far, names like `"product-card"` simply worked. They come from your **block map**: the object that decides which functions blocks can call. Each key is a **block type**, the name a block's `__resolveType` uses; each value is a function: + +```ts title=".deco/index.ts" +import type { Blocks } from "@decocms/blocks"; +import { catalogProduct } from "../src/catalog-product"; +import { productCard } from "../src/product-card"; +import { PromoBanner } from "../src/promo-banner"; + +export default { + "catalog-product": catalogProduct, + "product-card": productCard, + "promo-banner": PromoBanner, +} satisfies Blocks; +``` + +The block map is the default export of `.deco/index.ts` (or `.deco/index.tsx`), next to your content, and imports your components from `../src/`. You pass it to [`createCMS`](/next/content#create-the-cms) as `blocks`. + +Content chooses the arguments; code chooses which functions exist. A block can only call what's in this map. `satisfies Blocks` checks the map's shape while keeping each function's exact argument and return types (see [`Blocks`](/next/api-reference#types)). + +You don't list pages, redirects or the if/else blocks: ten [built-in blocks](/next/built-in-blocks) are added for you. + +## The lookup rule + +Every `__resolveType` is looked up in one registry, where later keys win. `savedBlocks` are named calls stored as files (see [Saved blocks](/next/saved-blocks)): + +```ts +{ ...savedBlocks, ...builtIns, ...blocks } +``` + +- **A function** (from your block map or a built-in) is called with its arguments, after they're resolved. +- **A [saved block](/next/saved-blocks)** is replaced by its contents, merged with any arguments the reference adds, and that result is looked up the same way. +- **Any other name** fails with [`UNKNOWN_BLOCK`](/next/api-reference#errors). + +Saved blocks and functions need different names (see [Names](/next/saved-blocks#names)). What happens when a block fails, or when saved blocks refer to each other in a loop, is in [The rule in full](/next/how-resolution-works#the-rule-in-full). + +## What's built on this + +- [Saved blocks](/next/saved-blocks): give a call a name and reuse it. +- [Lazy blocks](/next/lazy-blocks): the one argument that runs only when it's asked for. +- [Built-in blocks](/next/built-in-blocks): pages, redirects, matchers and the rest, added to every block map. +- [Forms from types](/next/schema): your functions' types become the forms editors fill in. +- [Matchers and variants](/next/matchers-and-variants): if/else and scheduled campaigns, written as calls. diff --git a/docs/content/next/built-in-blocks.mdx b/docs/content/next/built-in-blocks.mdx new file mode 100644 index 00000000..08ec5382 --- /dev/null +++ b/docs/content/next/built-in-blocks.mdx @@ -0,0 +1,160 @@ +--- +title: Built-in blocks +nav: Built-in blocks +group: Blocks +kind: docs +order: 6 +description: Nine functions every block map gets for free (lazy, multivariate, always, never, date, page, redirect, cms-settings and secret), and how to replace or change one. +--- + +# Built-in blocks + +Almost every site needs pages, redirects and an if/else. Instead of writing those functions yourself, Deco CMS adds nine of them to your [block map](/next/blocks#the-block-map). You don't import or list them, and they're always in the schema and the registry, so a built-in never fails with `UNKNOWN_BLOCK`. + +This page lists the built-ins, what each one takes and returns, and how to replace one or change the fields editors see. It's a quick list: each built-in is explained on the page where you'll use it, linked in the last column. + +| Name | Takes | Returns | More in | +| --- | --- | --- | --- | +| `lazy` | `value` | A function that resolves `value` when called | [Lazy blocks](/next/lazy-blocks) | +| `multivariate` | `variants`: a list of `{ rule, value }` | The first variant whose rule is `true` | [Matchers and variants](/next/matchers-and-variants#variants) | +| `always` | nothing | `true` | [Matchers and variants](/next/matchers-and-variants#built-in-matchers) | +| `never` | nothing | `false` | [Matchers and variants](/next/matchers-and-variants#built-in-matchers) | +| `date` | `start`, `end` | `true` from `start` until `end` | [Matchers and variants](/next/matchers-and-variants#built-in-matchers) | +| `page` | `name`, `path`, `seo`, `sections` | The page, fully resolved | [Pages and routing](/next/routing) | +| `redirect` | `from`, `to`, `permanent`, `status`, `discardQueryParameters` | Its arguments, as saved | [Pages and routing](/next/routing) | +| `cms-settings` | `preview`, `telemetry`, `analytics` | Its arguments, with defaults filled in | [CMS settings](#cms-settings) below | +| `secret` | `ciphertext` | The decrypted string, on the server only | [Secrets](#secrets) below | + +## Language built-ins + +These five are part of how blocks evaluate, so every site gets the same ones: + +- **`lazy`** takes `value` and returns a [`Lazy<T>`](/next/lazy-blocks#lazyt-props) function that resolves it when called, at most once. It's the one block whose argument doesn't resolve first (see [Lazy blocks](/next/lazy-blocks)). +- **`multivariate`** takes `variants`, a list of entries that each pair a `rule` (a `boolean`) with a `value` (a `Lazy<T>`), and returns the first variant whose rule is `true`, or `undefined` if none is. Only that variant runs. It also takes an optional `experiment`, the stable ID of an [A/B test](/next/matchers-and-variants#run-an-a-b-test). +- **`always`** returns `true`, so the variant paired with it is the fallback. +- **`never`** returns `false`, so the variant paired with it is never picked. That's how the site editor hides a block: it resolves to `undefined`, and a list leaves it out (see [Hide a block](/next/matchers-and-variants#hide-a-block)). +- **`date`** takes `start` and `end`, both optional ISO 8601 strings, and returns `true` from `start` until `end`. + +[Matchers and variants](/next/matchers-and-variants) shows how they work together, and how to write matchers of your own. + +## Pages and redirects + +- **`page`** takes `name`, `path`, an optional `seo` and `sections` (the page's blocks, in order), and returns the page with `seo` and every block in `sections` resolved. Its [type](/next/api-reference#types) is `interface Page extends Route { seo?: Seo; sections: ReactNode[] }`. A page without `seo` uses your site's defaults. +- **`redirect`** takes `from`, `to`, `permanent` (`true` for a 301, `false` for a 302), and two optional fields: `status`, one of `301`, `302`, `307` or `308`, which wins over `permanent`, and `discardQueryParameters`, which drops the request's query string instead of carrying it over. It returns them as saved. + +[Pages and routing](/next/routing) shows how to find the page for a URL and turn redirects into responses. + +## CMS settings + +Marketing wants page views to go to their own collector, and the team wants drafts to open only on the staging host. Both are site-wide settings editors can see and change, so they live in one place: a [saved block](/next/saved-blocks) named `CMS`, whose type is the built-in `cms-settings`. + +```json title=".deco/blocks/CMS.json" +{ + "__resolveType": "cms-settings", + "preview": { "hosts": ["staging.example.com"] }, + "telemetry": { "enabled": true, "metrics": true, "errorSampleRate": 0.05, "traceSampleRate": 0 }, + "analytics": { "enabled": true, "collector": "https://stats.example.com/events" } +} +``` + +Settings are split between code and content: + +- **Code says where things go, and how far content may go.** [`createCMS`](/next/api-reference#createcms-config) holds destinations and secrets, such as your telemetry collector and its token, and the caps: the highest sample rates content may set, and the hosts content may allow previews on. Code changes with a deploy. +- **Content says what's on, and how much.** The `CMS` block holds the switches, the sample rates, the analytics collector and the preview hosts, within those caps. It ships like any other content, and editors change it in [the site editor](/next/site-editor#settings). + +The block has three sections, each with its own page: + +| Section | Fields | Defaults | More in | +| --- | --- | --- | --- | +| `preview` | `hosts` | Every host, or the hosts code allows | [Allow previews per host](/next/releases-and-drafts#allow-previews-per-host) | +| `telemetry` | `enabled`, `metrics`, `errorSampleRate`, `traceSampleRate` | `true`, `true`, `0.05`, `0` | [Telemetry settings are content](/next/telemetry#telemetry-settings-are-content) | +| `analytics` | `enabled`, `collector` | `true`, the hosted Deco CMS collector | [Analytics](/next/analytics) | + +Every field is optional, and so is the block: without it, every default applies. `deco content` doesn't create it; save it from the site editor, or add the file, when you want to change something. Like any field, a section or a single setting can have [variants](/next/matchers-and-variants#variants), such as analytics switched on only during a campaign. + +Your code reads the settings with [`cms.settings()`](/next/api-reference#cms-settings), which returns every section with the defaults filled in and the caps applied. It always reads the release, never a draft, so a draft can't allow its own preview host or change what telemetry sends. Telemetry, analytics and the draft helpers all read the settings this way. + +## Secrets + +A newsletter form needs its email service's API key, and editors should be able to change it without a deploy. As plain JSON, the key would sit in your repository for anyone who can read it. A `secret` block keeps only an encrypted copy there: + +```json +{ "__resolveType": "secret", "ciphertext": "v1.AbX3…" } +``` + +On the server, it resolves to the decrypted string. + +### Type a field as a secret + +Type the field as `Secret`, from `@decocms/blocks`. It's a `string` with a type tag, so your function uses it like any string: + +```ts title="src/newsletter.tsx" +import type { Secret } from "@decocms/blocks"; + +export interface NewsletterProps { + listId: string; + /** @title API key */ + apiKey: Secret; +} +``` + +[The site editor](/next/site-editor) shows a `Secret` field as a write-only password box: editors can set or replace the value, never read it back. It encrypts the value in the browser and saves a `secret` block, so the plain text never reaches your repository. + +### Create the keys + +Secrets use a key pair, so encrypting needs no secret at all: + +- **The public key**, committed at `.deco/secrets.pub`, encrypts. The site editor gets it from [`deco serve`](/next/cli#deco-serve) or the hosted backend, and an AI agent reads the file. In a script, `encryptSecret(publicKey, value)` from `@decocms/blocks/secrets` returns a ready `secret` block. +- **The private key** decrypts. It stays on your servers, in an environment variable, and you pass it to [`createCMS`](/next/api-reference#createcms-config): + +```ts title="cms.ts" +export const cms = createCMS({ blocks, content, secrets: { key: process.env.DECO_SECRETS_KEY } }); +``` + +Create the pair once, with OpenSSL, from your app root: + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out deco-secrets.key +openssl pkey -in deco-secrets.key -pubout -out .deco/secrets.pub +``` + +Commit `.deco/secrets.pub`. Store the whole of `deco-secrets.key`, line breaks included, as `DECO_SECRETS_KEY` wherever you keep server secrets (on Workers, `npx wrangler secret put DECO_SECRETS_KEY < deco-secrets.key`; in `.env.local` or `.dev.vars`, wrap it in double quotes). Then delete the file, or keep it in a password manager. Never commit it. + +Each value is encrypted with a fresh AES-256-GCM key, which is wrapped with the public key using RSA-OAEP and SHA-256. Both are in the Web Crypto API, so it works the same in browsers, on Node and on Workers. + +To rotate keys by hand, create a new pair, replace `.deco/secrets.pub`, save each secret again on a branch, and deploy that branch with the new `DECO_SECRETS_KEY`. + +<Hosted to="/next/hosted" label="The hosted Deco CMS">**Keys managed for you.** With the hosted Deco CMS, you create and rotate the key pair from your site's settings, and a rotation re-encrypts every saved secret in one commit.</Hosted> + +### Guardrails + +- **Server only.** A `secret` block fails if it's resolved in the browser, so its value only exists where your server code runs. It's a plain string there: use it in server code, and don't pass it to a Client Component as a prop, or it ends up in the page. +- **Reads stay encrypted.** [`{ run: false }`](/next/api-reference#client-resolve-target-options) and `client.list` return the block as saved, with its `ciphertext`. +- **No plain text in content.** The [content protocol](/next/content-protocol#errors-and-limits) refuses a plain string in a `Secret` field, so the site editor and agents can't save one by mistake. +- **A missing key fails that block.** Without a key, or with the wrong one, a `secret` block fails with [`BLOCK_FAILED`](/next/api-reference#errors). Resolve the page's blocks one by one (see [Errors, streaming, and cancellation](/next/rendering#errors-streaming-and-cancellation)) and the rest of the page still renders. +- **Telemetry redacts.** Decrypted values never appear in spans or logs. +- **`deco check` needs no key.** It checks that each `ciphertext` is well formed, but can't decrypt it. + +A v7 site re-encrypts its secrets once when it migrates (see [Secrets](/next/renames-and-migrations#secrets)). + +## Change a built-in + +Your block map is spread over the built-ins (see [the lookup rule](/next/blocks#the-lookup-rule)), so declaring a built-in's key in your block map replaces it. For example, declare `page` to wrap every page in your layout, or declare `multivariate` to pick variants your way (type each `value` as `Lazy<T>` to keep running only the chosen one). + +To change only the fields editors see, declare the key with a function that returns its input. [`Page`](/next/api-reference#types) is exported from `@decocms/blocks`; extend it to add fields: + +```ts title=".deco/index.ts" +import type { Blocks, Page } from "@decocms/blocks"; +import seo from "../src/seo"; +import hero from "../src/hero"; + +interface StorePage extends Page { theme: "light" | "dark" } + +const page = (props: StorePage) => props; + +export default { seo, hero, page } satisfies Blocks; +``` + +Nothing is lost: arguments resolve before the function runs, so returning `props` gives the same resolved page as the built-in, `seo` and `sections` included, plus `theme`. You read it in the result of `client.resolve`, as `page.theme`, and [`deco schema`](/next/schema) gives editors a *theme* select on every page. + +No saved block can be called `page`, `redirect`, `cms-settings`, `always`, `never`, `date`, `multivariate`, `lazy` or `secret` (see [Names](/next/saved-blocks#names)). diff --git a/docs/content/next/caching.mdx b/docs/content/next/caching.mdx new file mode 100644 index 00000000..e903666c --- /dev/null +++ b/docs/content/next/caching.mdx @@ -0,0 +1,86 @@ +--- +title: Caching +nav: Caching +group: Production +order: 23 +description: The caches a request passes through, who owns each one, and what to key it by. +--- + +# Caching + +A wrong cache key is how a site shows one customer's cart to another or serves last week's prices. This page shows each cache a request passes through, who owns it, and what you key it by. + +The table shows who owns each cache and what you're responsible for. + +| Layer | Lifetime | Your responsibility | +|---|---|---| +| Content map | The snapshot, held in memory by the CMS and cached by [revision](/next/releases-and-deployment#what-a-revision-is). One per process, even if the package is loaded twice (see [One instance per process](/next/api-reference#one-instance-per-process)). It comes from the [content module](/next/content#the-content-module) or a [loader](/next/content#write-a-loader). | Nothing. | +| Resolved blocks | For one client (the object `cms.forRelease()` returns, see [`createCMS`](/next/api-reference#createcms-config)). A client runs each block once and reuses the result for the rest of the request. | Make [a client per request](/next/releases-and-deployment#one-revision-per-response) and resolve everything the response renders through it. | +| Dynamically imported modules | Block functions you load lazily with `import()` are cached by the JavaScript runtime for the life of the process, so anything a module stores in a top-level variable is shared by every request. | Keep per-request state out of module globals. | +| Upstream data | Responses from the APIs your code calls (a commerce platform, a search service), cached by code in your app, if you add it. See [Upstream data](#upstream-data). | Key by every input the response depends on; see [Upstream data](#upstream-data). | +| Cross-request results | Anything you cache across requests yourself (rendered HTML at a CDN, a resolved page in KV). Your policy. | Key by revision, app version (new code can render the same content differently), inputs, and relevant request context. Define TTL and invalidation. | + +A [block type](/next/blocks#the-block-map) alone is never a cache key. Include every input that can change the result. A cache hit doesn't bypass authorization. + +Block functions should only read; see [The rule in full](/next/how-resolution-works#the-rule-in-full). + +## Upstream data + +Where an API response can be cached depends on where your site runs, so caching lives in your app, not in Deco CMS or the [upstream clients](/next/upstream-clients#what-a-client-is). Clients accept a `fetch` option, the fetch under [`createInstrumentedFetch`](/next/api-reference#createinstrumentedfetch-options) (see [Write a client](/next/upstream-clients#write-a-client)), so a cache is a fetch you pass in. Two short recipes follow. + +Include everything a response depends on in its cache key (tenant, locale, currency, region), and never cache a shopper's private data as public. Cache only the calls that are the same for every visitor, and only `GET` requests. + +### Cloudflare Workers + +The Cloudflare Cache API keys a response by its URL only, so use this for requests whose URL holds every input (headers such as `authorization` aren't part of the key): + +```ts title="src/cached-fetch.ts" +// A fetch that serves GET responses from the Cloudflare Cache API for `seconds`. +export function cachedFetch(seconds: number): typeof fetch { + return async (input, init) => { + const request = new Request(input, init); + if (request.method !== "GET") return fetch(request); + + const cache = caches.default; + const hit = await cache.match(request); + if (hit) { + const response = new Response(hit.body, hit); + response.headers.set("x-cache", "HIT"); // the instrumented fetch labels it cached + return response; + } + + const response = await fetch(request); + if (response.ok) { + const copy = new Response(response.clone().body, response); + copy.headers.set("cache-control", `public, max-age=${seconds}`); + await cache.put(request, copy); + } + return response; + }; +} +``` + +```ts title="src/search.ts" +export const search = createAcmeSearch(config, { fetch: cachedFetch(60) }); +``` + +### Next.js + +Next.js caches a `fetch` in its data cache when you pass `next: { revalidate }`, so the wrapper only adds that option: + +```ts title="src/cached-fetch.ts" +// A fetch whose responses Next.js keeps in its data cache for `seconds`. +export function cachedFetch(seconds: number): typeof fetch { + return (input, init) => fetch(input, { ...init, next: { revalidate: seconds } }); +} +``` + +Pass it the same way, `createAcmeSearch(config, { fetch: cachedFetch(60) })`. Next's data cache doesn't set `x-cache`, so these hits aren't labeled `cached`. See the Next.js docs on [caching data](https://nextjs.org/docs/app/guides/caching). + +Where the instrumented fetch's measurements go, including the `cached` label, is in [Telemetry](/next/telemetry#whats-sent). + +<Hosted to="/next/hosted-drafts" label="Previewing drafts">**Skip the cache for drafts.** When the hosted Deco CMS renders a draft on your site (a request you serve with `cms.forDraft`), use a client without the cached fetch, so editors see fresh data.</Hosted> + +## Pages with variants + +A [matcher](/next/matchers-and-variants#matchers) runs only when a page is rendered, so a cached page keeps the [variant](/next/matchers-and-variants#variants) it was rendered with, up to one cache lifetime past the switch. Around a scheduled switch, keep cache lifetimes shorter than the delay you can accept, or don't cache pages whose variants switch by date. On Next.js, a page rendered at build time never switches; see the [Next.js caching note](/next/nextjs#caching). diff --git a/docs/content/next/checking.mdx b/docs/content/next/checking.mdx new file mode 100644 index 00000000..a7334608 --- /dev/null +++ b/docs/content/next/checking.mdx @@ -0,0 +1,88 @@ +--- +title: Checking content +nav: Checking content +group: Built on Blocks +kind: docs +order: 9 +--- + +# Checking content + +A developer renames the banner's `title` prop to `headline`. TypeScript is happy, the tests pass, and three saved pages in `.deco/blocks` still say `title`. TypeScript can't see JSON files, so nothing fails until a visitor opens one of those pages. `deco check` closes that gap: it type-checks every saved call against your functions' [schema](/next/schema), the same way TypeScript checks a call in code. + +This page shows what `deco check` checks, how to run it before every build, and how it keeps code and content compatible when either one changes. + +## What deco check checks + +`deco check` reads `.deco/schema.gen.json` and the [saved blocks](/next/saved-blocks) in `.deco/blocks` as they are, writes nothing, and validates every saved block against that schema: + +- Each saved block's props match its block type's schema: required fields are present, and values respect enums and limits. +- Every `__resolveType` (the key that names the function a block calls; see [Functions as blocks](/next/blocks#functions-as-blocks)) names a block type, [built-ins](/next/built-in-blocks) and [aliases](/next/renames-and-migrations#rename-a-type-with-an-alias) included, or a saved block that exists. +- A reference in a typed field points to a block that returns that type (see [Interchangeable blocks](/next/schema#interchangeable-blocks)). +- A [`Lazy<T>` field](/next/lazy-blocks#lazyt-props) holds a `lazy` block, and a `lazy` block appears only in a `Lazy<T>` field. +- No saved block has the name of a block type, a built-in or an alias (see [Names](/next/saved-blocks#names)). +- No two entries can match the same URL (see [Match order](/next/routing#path-templates-and-match-order)). +- Each [`secret` block](/next/built-in-blocks#secrets) holds a well-formed `ciphertext`. It can't decrypt it, so it needs no key. + +It also warns about content that works but is probably a mistake: a [variant](/next/matchers-and-variants#variants) after an `always` rule, which can never be picked. Warnings are listed but don't fail the check. + +It doesn't generate the schema or load your TypeScript. Run `deco schema` first, so the schema matches your code: + +```bash +npx @decocms/blocks schema && npx @decocms/blocks check +``` + +Because it checks the content itself, it catches both directions: a code change that breaks saved content, and content that uses a block type or field your code doesn't have. It runs on content-only changes too (from you, an AI agent or [the site editor](/next/site-editor)), and works the same on your machine as in CI. + +When everything fits (warnings aside), it exits with 0. Otherwise it lists the problems per file and exits with 1: + +```text +.deco/blocks/HomePage.json + sections[2].title: required +.deco/blocks/Promo.json + unknown block type "promo-banner" +``` + +Flags, such as `--root` for a monorepo, are in the [CLI reference](/next/cli#deco-check). + +## Run it before every build + +Add `deco check` to your `prebuild` script, after `deco schema` and `deco content` (which builds the [content module](/next/content#the-content-module)); the scripts are in [Run it before dev and build](/next/cli#run-it-before-dev-and-build). A broken mix of code and content then fails the build and never deploys: the live site keeps serving the previous deploy. Dev skips the check, so a content error doesn't stop your dev server. + +## Backward compatibility + +Code and content merge independently: a developer's pull request changes a type while an editor's pull request changes a saved block. Saved content must keep working when your code changes, and content must only use the block types and fields your code has. `deco check` makes sure of both, whichever of the two changed. + +Run it on every pull request, and make the job a required check so content that doesn't fit can't merge: + +```yaml title=".github/workflows/check.yml" +name: Check +on: pull_request + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npx @decocms/blocks schema && npx @decocms/blocks check + - run: git diff --exit-code -- .deco/schema.gen.json +``` + +The last step fails if the committed `schema.gen.json` is out of date, which matters because the site editor reads the schema committed on the branch it edits when it edits a draft on GitHub. + +In a monorepo, pass `--root` to both commands (`npx @decocms/blocks schema --root apps/storefront && npx @decocms/blocks check --root apps/storefront`) and diff `apps/storefront/.deco/schema.gen.json`. + +Two pull requests can each pass on their own and break together. To catch that at pull-request time instead of at build time, turn on "Require branches to be up to date before merging" in your branch protection, or use a merge queue. + +To rename a field, add the new one, move the content over, then remove the old one. The site editor keeps only the fields the schema has, so saving a block drops any field your types no longer declare. + +<Hosted to="/next/hosted-publishing#keep-running-code-compatible" label="Keep running code compatible">**Publishing without a deploy adds a direction.** With the hosted Deco CMS, new content reaches code that's already running without an application deploy, so that code must also accept content published after it shipped.</Hosted> + +## Renaming a type + +To rename a block type, keep the old name as an alias, so saved content that uses it still resolves; see [Rename a type with an alias](/next/renames-and-migrations#rename-a-type-with-an-alias). diff --git a/docs/content/next/cli.mdx b/docs/content/next/cli.mdx new file mode 100644 index 00000000..c54b9ba6 --- /dev/null +++ b/docs/content/next/cli.mdx @@ -0,0 +1,106 @@ +--- +title: CLI +nav: CLI +group: Reference +order: 26 +kind: docs +--- + +# CLI + +You changed a block's props and [the site editor](/next/site-editor) still shows the old form: the schema needs regenerating. This page lists every `deco` command and its flags. + +The Deco CLI is the `deco` command. It ships inside `@decocms/blocks`, so there's nothing extra to install, and it always matches your runtime version. Run it once with `npx` (`bunx` works the same): + +```bash +npx @decocms/blocks schema +``` + +Once `@decocms/blocks` is installed, your `package.json` scripts call it by its short name, `deco` (for example `deco schema`). To call it from your own code, import it from `@decocms/blocks/cli`. + +Every command works on one folder, `.deco/`, in your app root (the folder with your app's `package.json`). Which file in it is which, and which to commit, is in [The .deco folder](/next/saved-blocks#the-deco-folder). + +It has four commands, and each does one job: + +- **`deco schema`** turns the types in `.deco/index.ts` into `.deco/schema.gen.json`. Commit this file. See [Forms from types](/next/schema). +- **`deco content`** turns the files in `.deco/blocks` into the content module, `.deco/blocks.gen.ts`. It's generated, so gitignore it. See [The content module](/next/content#the-content-module). +- **`deco check`** makes sure your [saved blocks](/next/saved-blocks) fit your code, and fails if one doesn't. It writes nothing. See [Checking content](/next/checking). +- **`deco serve`** is the local server [the site editor](/next/site-editor) uses to edit the files on your machine. See [Edit on your machine](/next/site-editor#edit-on-your-machine). + +There's no `deco publish`: publishing is committing. Running it prints that and exits with code 1 (see [Design decisions](/next/design-decisions#delivery-and-telemetry)). + +## Run it before dev and build + +There's no separate dev command: run `deco schema` and `deco content` before your dev server and your build. The build also runs `deco check`, so content that doesn't fit the code fails the deploy instead of shipping; dev skips it, so a content error doesn't stop your dev server. + +```json title="package.json" +{ + "scripts": { + "predev": "deco schema && deco content", + "prebuild": "deco schema && deco content && deco check" + } +} +``` + +## Finding the `.deco` folder + +Every command takes `--root <dir>`: the folder that contains `.deco/`, relative to the current folder. You rarely pass it. By default, the command walks up from the current folder to the first folder with a `.deco/`, so it works from any subfolder of your app. + +If no folder on the way up has a `.deco/`, the command stops with an error: + +```text +no .deco/ found from /path/you/ran/it/in; run inside your app or pass --root +``` + +In a monorepo, walking up from the repository root never reaches `apps/storefront/.deco`, so the command fails there (or, if the root has a stray `.deco/`, it uses that one). Run the commands inside the app instead, for example as scripts in `apps/storefront/package.json`. Then `@decocms/blocks` must be a dependency of that app, so its scripts can find `deco`. Or pass the app from the repository root: + +```bash +deco schema --root apps/storefront +deco content --root apps/storefront +``` + +[The site editor](/next/site-editor)'s "app root" setting points at that same folder, both on your machine and when the site editor reads your repository on GitHub in the [hosted version](/next/hosted-site-editor). + +## `deco schema` and `deco content` + +```bash +deco schema [--root <dir>] [--watch] +deco content [--root <dir>] [--watch] +``` + +| Command | Reads | Writes | +| --- | --- | --- | +| `deco schema` | `.deco/index.ts` (or `.deco/index.tsx`) | `.deco/schema.gen.json` | +| `deco content` | `.deco/blocks/` | `.deco/blocks.gen.ts` | + +`deco content` only bundles the JSON files in `.deco/blocks`. It never reads your block map. + +The options: + +- `--root <dir>`: the folder that contains `.deco/` (see [above](#finding-the-deco-folder)). +- `--watch`: regenerate as files change. + +## `deco check` + +```bash +deco check [--root <dir>] +``` + +`deco check` reads `.deco/schema.gen.json` and `.deco/blocks` as they are, writes nothing, and validates every saved block against that schema. It doesn't generate the schema or load your TypeScript, so run `deco schema` first: `deco schema && deco check`. It exits with 0 when everything fits, and with 1 otherwise, listing the problems per file. Warnings are listed but don't change the exit code. What it checks, and how to run it on every pull request, is in [Checking content](/next/checking). + +## `deco serve` + +```bash +deco serve [--root <dir>] [--port <n>] [--host <addr>] [--preview <host:port|url>] [--assets <dir>] [--read-only] +``` + +`deco serve` is the local server the site editor uses to edit the files on your machine, over the [content protocol](/next/content-protocol): it reads and writes `.deco/blocks`, reads `.deco/schema.gen.json`, and accepts uploads into `public/assets/` (or `--assets`). It tells the site editor which root it serves through the content protocol's `describe`. The flow is in [Edit on your machine](/next/site-editor#edit-on-your-machine). It takes: + +- `--root <dir>`: the folder that contains `.deco/`, such as `apps/storefront` in a monorepo. By default, it walks up from the current folder to the first folder with a `.deco/`, like every command. +- `--port <n>`: the port to listen on (default 4545). +- `--host <addr>`: the address to listen on. By default the server listens on loopback, both 127.0.0.1 and ::1 on the same port, so `http://localhost:<port>` reaches it whichever address your system gives `localhost`; it prints its address as `localhost`. With `--host` it listens on that one address only. An address beyond loopback prints a warning: the server has no authentication, so other machines on your network can then read and write your content. The site editor still connects only through `localhost`: with `0.0.0.0` the printed link uses `localhost`, and with a specific network address the link doesn't work. How the server is protected is in [The local server](/next/content-protocol#the-local-server). +- `--preview <host:port|url>`: your running dev app, which the site editor shows in its Preview tab, as `localhost:8001` or a full URL such as `http://127.0.0.1:3000/en/`. It must be on your machine (`localhost`, `127.0.0.1` or `::1`). By default, the `server.port` from your Vite config (read as text, never run), else `http://localhost:5173`. The server prints it as `Preview` and reports it in `describe` (see [The four methods](/next/content-protocol#the-four-methods)). +- `--assets <dir>`: the folder the site editor's uploads are written to, relative to the folder that contains `.deco` (default `public/assets`). The field always stores `/assets/<name>`, whatever this folder is, so a folder you pass must be served at `/assets/`; see [Images and other uploads](/next/site-editor#images-and-other-uploads). +- `--read-only`: serve the content without accepting writes or uploads. + +<Callout type="warning">**`deco serve` has no authentication and answers any website.** Any page open in your browser can read and write your content through it, so run it only while you're editing and stop it when you're done. `--host` also exposes it to your network.</Callout> diff --git a/docs/content/next/content-delivery.mdx b/docs/content/next/content-delivery.mdx new file mode 100644 index 00000000..c9760ab3 --- /dev/null +++ b/docs/content/next/content-delivery.mdx @@ -0,0 +1,126 @@ +--- +title: Production delivery and rollback +nav: Delivery and rollback +group: Under the hood +kind: internals +order: 9 +description: Immutable JSON assets, a small channel manifest, and fast content rollback without builds or GitHub reads. +--- + +# Production delivery and rollback + +Fast publishing needs a fast way back. If a new banner breaks a page, returning to a known-good release should take a channel update, not a rebuild or a GitHub repair. + +This is the proposed hosted delivery architecture for the next major. The [roadmap](/roadmap#roadmap-platform--build-the-deco-api-release-service) tracks its implementation. The server-side loader is described in [How hosted releases stay current](/next/hosted-releases-internals). + +## Two separate paths + +The **control plane** owns editing, GitHub access, release preparation and promotion. The **data plane** serves already prepared content. Production servers never call GitHub, including on a cache miss, a cold start or an error. + +<Flow label="Preparing a release"> + <FlowNode title="Git commit">An editor publishes, or a developer pushes to production</FlowNode> + <FlowNode title="Durable ingestion job">Read GitHub, build and hash the snapshot</FlowNode> + <FlowNode title="Object storage">Upload the immutable JSON asset, then promote its manifest</FlowNode> +</Flow> + +<Flow label="Delivering content"> + <FlowNode title="Production SDK">Background check of the channel manifest</FlowNode> + <FlowNode title="CDN and object storage">Serve the manifest and the exact revision asset</FlowNode> + <FlowNode title="Server memory">Verify and swap; requests keep reading one revision</FlowNode> +</Flow> + +R2 with Cloudflare's CDN, or S3 with CloudFront, can implement this shape. Provider details stay behind a stable, versioned asset contract. An optional small authorization gateway checks site credentials before serving private assets; it reads storage only, has no GitHub credentials, and deploys independently of the editing API. A content hash identifies bytes; it is not permission to read them. Private responses must not enter a shared cache before authorization. + +## Immutable snapshots and mutable channels + +A revision is the hash of the content map, computed by the same canonical serialization in the CLI, ingestion worker and SDK. Define and version that serialization in shared code; hashing raw files or insertion-order-dependent maps would make identical content produce different revisions. Store each full snapshot under its revision and never overwrite it. + +The delivery contract has two resources, scoped to a site: + +```text +/sites/acme/revisions/<revision>.json +/sites/acme/channels/production.json +``` + +A snapshot is the SDK's existing `{ revision, blocks }` shape. The proposed channel manifest is small: + +```json +{ + "format": 1, + "generation": 184, + "revision": "sha256-opaque-content-hash", + "snapshot": "/sites/acme/revisions/sha256-opaque-content-hash.json" +} +``` + +The snapshot path stays on the configured delivery origin and within the site namespace. The SDK refuses unknown formats and unexpected paths. Other environments get their own channels. The manifest's generation orders promotions; the revision identifies content. Neither a content hash nor a Git commit hash expresses publication order. + +Give immutable assets a long cache lifetime. Give channel manifests an explicit, short cache lifetime and an ETag. Configure caching for JSON explicitly, and avoid caching missing revision assets. Object-store consistency does not make CDN caches immediately current. Include the manifest cache lifetime, the SDK's poll interval and any rendered-page cache in the propagation budget. + +## Prepare before promoting + +1. Accept and authenticate a GitHub push event, durably enqueue it and deduplicate deliveries. Also reconcile repository heads periodically: an event can be missed. +2. Resolve the production commit and assemble its content. Preparation doesn't check content: route conflicts and fit with the schema are [`deco check`](/next/checking)'s job in CI, and ingestion does not run site code. +3. Upload the snapshot, verify its bytes and record the release as ready. Preserve its source commit in release history. +4. Only then update the channel manifest. A failed preparation leaves the previous release live; it never points readers at an unfinished object. + +Use one durable promotion coordinator per site and channel. Every publish and rollback goes through it, with an expected generation. Jobs carry the generation against which they were requested; an older job cannot overwrite a newer promotion. Serialize the actual manifest writes too: checking a database generation and then independently uploading a manifest is not an atomic operation. Recover an interrupted write by reconciling the manifest against the coordinator's durable desired state. + +GitHub push events may arrive out of order. Reconcile the production head in the control plane rather than treating arrival order as commit order. No reader asks GitHub to rebuild a missing snapshot. A missing or unavailable asset is an error, and the SDK retains its last good content or build fallback. + +Expose preparation and promotion as separate statuses. "Saved" means committed; "published" means promoted for delivery. Neither means every server has refreshed yet. + +## Fast rollback + +Rollback selects a retained, previously prepared revision that fits the deployed code. The coordinator advances the generation while pointing at the older immutable asset: + +```text +publish: generation 184 → revision B +rollback: generation 185 → revision A +``` + +No snapshot rebuild, application deploy or GitHub API request is required for the delivery change. Record who requested it, why, the previous revision and the target. Keep snapshots and their referenced uploads for the documented rollback window; garbage collection must preserve channel targets, pinned revisions and active preview grants. + +An older in-flight job cannot undo generation 185. Rollback also places the channel in an explicit hold: new Git commits may prepare releases, but automatic promotion stays paused until an operator resumes it or deliberately publishes. Otherwise a webhook replay or reconciliation could immediately restore the release being rolled back. + +The repository still contains its current production content. Offer a separate Git revert to reconcile it later; that operation can wait for GitHub to recover. Draft synchronization continues against repository production, and the editor must show when delivered content differs from Git. A code rollback is separate and must select content compatible with the older build. + +Rollback uses the same propagation path as publishing. An active process sees it on its next background check plus the manifest cache delay; an idle Worker checks after a subsequent response. A cold process first serves its build fallback. There is no promise that every visitor changes at once. Notify the rendered-page caching integration on a generation change, including when the content revision is older, so HTML caches cannot hide the rollback indefinitely. + +## Exact draft previews + +A draft is delivered as an immutable **overlay**, not a complete release snapshot. Its version fixes the replacements and deletions; it does not fix the inherited production content. There is no `baseRevision` in the draft asset or pointer. The base is whatever production snapshot the server already has when this client first loads. There is no separate kind of preview server: a server renders a draft the same way it renders production, with the draft's overlay on top. + +```json +{ + "format": 1, + "set": { "HomeHero": "sha256-changed-block-hash" }, + "delete": ["OldPromotion"] +} +``` + +Store the manifest at `/sites/<site>/drafts/<overlay-version>.json` and changed JSON blocks at `/sites/<site>/draft-blocks/<block-hash>.json`. Each manifest contains the complete cumulative set of draft overrides and tombstones, not a patch that requires replaying earlier draft versions. Generate disjoint `set` and `delete` sets. The overlay version hashes the canonical manifest; block hashes identify the canonical changed JSON. Production releases still use complete `{ revision, blocks }` assets. + +The SDK downloads the small manifest and only referenced block blobs missing from its bounded, site-scoped cache. A new save can reuse previously downloaded blobs. It never fetches a production revision just to align a draft, and never crawls GitHub. A cold server uses its normal production fallback, loading that fallback by its existing mechanism if necessary; there is no extra draft base fetch or release-refresh barrier. + +Capture that local production snapshot once for each draft client. Look up draft replacements first, treat tombstones as absent, and inherit all other blocks from the captured production map. Enumeration unions the names and filters deletions. Use a read-through view or persistent map sharing unchanged objects instead of deep-copying the full release. Do not mutate the production snapshot when applying an overlay or resolving its entries. + +A subsequent request may inherit a newer local release, and another server may inherit a different one. That is intentional: a draft link identifies exact draft changes, not a reproducible full-state preview or a promise to match publication exactly. Rollback of production also changes inherited preview content on later requests. In-flight clients keep their captured base and overlay. + +Cache overlay assets by site and overlay version, and changed blobs by site and block hash. If caching composed views or rendered previews, key them by both the captured local production revision and overlay version, plus the normal access/request scope. An overlay version alone cannot identify the effective content. The draft client's opaque revision identifies this pair; it is not the release's full-map hash. Do not hash or serialize the entire merged map simply to construct that identity. + +A signed grant scopes access to site, draft overlay version and expiry. Enforce the expiry on every read, including warm servers: an authorized manifest response is cacheable only privately and only until its grant expires (`Cache-Control: private, max-age=<seconds left>`), and the SDK keeps an authorized manifest no longer than that; a composed draft is reused for at most a minute before that check runs again. Changed blobs stay immutable; each read of one is authorized through a live manifest. Authorize changed-blob reads against membership in that authorized manifest; knowing a block hash alone grants no access. Never substitute newer branch changes for an older overlay version. Missing, invalid or unauthorized overlay assets fail the draft client as documented; they do not silently display published content. + +Saving and preview readiness are separate: a save is committed before its overlay and referenced blobs necessarily exist. Upload changed blobs first, then the immutable manifest; show "preparing preview" until both are ready. Control-plane preparation uses saved write bodies and cached Git blob IDs, reads bodies only for changed files, and does not reconstruct a complete draft snapshot. The production commit incorporated by [draft synchronization](/next/draft-synchronization) remains internal Git metadata, not a preview base constraint. The same file-level rule applies during synchronization and publication: an edited draft file wins in full; production changes in that same file are intentionally not merged by property. Untouched files inherit production. + +Publishing, rollback, preview grants and draft lifecycle remain control-plane routes outside the [content protocol](/next/content-protocol). Publishing prepares a complete production snapshot; draft overlays do not change release or rollback semantics. + +## Bound preparation memory + +Build assets in a worker with bounded concurrency and a memory budget, independently of the editing API. Stream downloads and snapshot output to temporary storage or the object store instead of holding every file body and the final serialized map simultaneously. Enforce per-entry and aggregate limits during reads, including for existing repository files; compressed byte size is not a bound on parsed JSON memory. + +The SDK still needs a full parsed snapshot for synchronous resolution. Bound accepted snapshot sizes separately, retain only a bounded set of versions, and account for the old revision, new revision, fallback and in-flight readers during a swap. Streaming preparation does not remove that runtime limit. + +## Studio implementation + +The [Studio implementation handoff](/next/studio-implementation) maps this design to existing Fast Preview modules and specifies persisted state, operation contracts, sequencing, initial limits, recovery scenarios and rollout phases. diff --git a/docs/content/next/content-protocol.mdx b/docs/content/next/content-protocol.mdx new file mode 100644 index 00000000..a0d12a08 --- /dev/null +++ b/docs/content/next/content-protocol.mdx @@ -0,0 +1,206 @@ +--- +title: Content protocol +nav: Content protocol +group: Under the hood +kind: internals +order: 5 +description: How the site editor reads and writes saved blocks without running your code. Four JSON-RPC methods over one endpoint, served by deco serve and by the site editor's GitHub backend. +--- + +# Content protocol + +[The site editor](/next/site-editor) never runs your site's code. It reads and writes content through a small protocol, the **content protocol**, so any storage that implements it can back the site editor. This page is for contributors and for anyone writing another backend. + +## What it reads and writes + +- **The [schema](/next/schema#from-types-to-forms)**, `<root>/.deco/schema.gen.json`. It's read-only: no method writes it. +- **The saved blocks**, one JSON file per block in `<root>/.deco/blocks`. This is the only thing it writes. +- **The public key for [secrets](/next/built-in-blocks#secrets)**, `<root>/.deco/secrets.pub`, if there is one. `describe` returns it. + +The root is the app root, the folder that contains `.deco/`, such as `apps/storefront` in a monorepo. `describe` reports it. + +## Where edits go + +<Flow label="The content protocol"> + <FlowNode title="Site editor">Forms built from the schema; autosaves each edit</FlowNode> + <FlowNode title="Content protocol">Four methods over one HTTP endpoint</FlowNode> + <FlowNode title="Storage">Your working tree, or your GitHub repository</FlowNode> +</Flow> + +Two implementations serve the protocol: + +| Backend | Edits | The site editor checks for changes | +|---|---|---| +| **[`deco serve`](/next/site-editor#edit-on-your-machine)**, from the Deco CLI | The files in your working tree. Your app hot-reloads after each save, and you commit when you're ready. | About every 2 seconds | +| **The site editor's GitHub backend**, part of the [hosted Deco CMS](/next/hosted) | Your repository on GitHub, with [draft branches](/next/hosted-site-editor#drafts-are-branches) and publishing. | About every 30 seconds | + +The portable protocol has no event stream. The site editor polls with one batched request, and when nothing changed the answer is a short "not modified". Saving is last-writer-wins: if two people edit the same saved block at once, the later save replaces the earlier one. + +<Hosted to="/next/hosted-site-editor" label="Editing in the site editor on GitHub">**Edit without a local copy.** With the hosted Deco CMS, the site editor's GitHub backend commits each save to a draft branch, and publishes by bringing the draft to your production branch, so editors need no copy of the code, dev server or Git.</Hosted> + +## In brief + +The content protocol is JSON-RPC 2.0 (a small standard for calling named methods with JSON) over one HTTP endpoint, and a request can batch several calls. It has four methods: + +| Method | Does | +|---|---| +| `describe` | What this endpoint is: whether it writes git commits or a working tree, its app root, its limits, and how often to poll | +| `schema.get` | Reads the schema | +| `blocks.list` | Reads every saved block at once, with a revision for the whole set | +| `blocks.apply` | The only write: sets and deletes saved blocks together, all or nothing, optionally only if a block is still at a given version | + +Both reads take the version they already have and answer "not modified" when it's current, which is what makes polling cheap. + +## The wire format + +One HTTP endpoint takes JSON-RPC 2.0 requests: `POST <endpoint>` with `Content-Type: application/json` and a body that's one request object or a batch array. The local server's path is `/rpc`; the site editor mounts its own per project. + +- `params` is always an object. A method without parameters accepts `{}` or no `params`. +- Every request has an `id`. A request without one is rejected rather than run silently, because a dropped write is worse than an error. +- Unknown parameters are rejected, so a guard the server doesn't understand never turns into an unguarded write. +- Errors come back in the JSON-RPC `error` object with HTTP 200, except a missing or invalid bearer token on an endpoint that requires one, such as the site editor's GitHub backend (401), and a body over the size limit (413), which apply to the whole batch. +- A batch runs in order and returns results in order, up to 10 calls. It isn't atomic: atomicity exists only inside one `blocks.apply`. +- Responses are gzip-compressed when the request accepts it; schemas are often over a megabyte. + +### The four methods + +```ts +function describe(): { + protocol: "deco-content"; + version: { major: 1; minor: number }; // minors only add; a client refuses an unknown major + server: { name: string; version: string }; // e.g. "deco-cli", "studio-github" + kind: "working-tree" | "git"; // The site editor hides publish and draft UI for a working tree + readOnly: boolean; + root: string; // the app root: the folder that contains .deco/, relative to the repository root + schemaFormat: "deco-meta@1"; + refs: null | { default: string; autoCreate: boolean }; // branches; null on the local server + writes: { idempotency: null | { retentionMs: number }; schemaPreconditions: boolean }; + pollIntervalMs: number; // local: 2000; git: 30000, plus on window focus + limits: { maxOpsPerApply: number; maxBlockBytes: number; maxRequestBytes: number; maxListBytes: number; maxSchemaBytes: number; maxBatchResponseBytes: number }; + preview: null | { url: string }; // the app the Preview tab loads; deco serve: --preview + assets: null | { dir: string; urlPrefix: "/assets/"; maxBytes: number }; // dir relative to the repository root; null when read-only or uploads go to hosted storage + secrets: null | { publicKey: string }; // the contents of <root>/.deco/secrets.pub, which the site editor encrypts Secret fields with; null without one +}; + +function schemaGet(params?: { ref?: string; ifNoneMatch?: string }): + | { notModified: true; version: string } + | { notModified: false; version: string; resolvedRef: string | null; schema: DecoMeta } + | { notModified: false; version: null; resolvedRef: string | null; schema: null }; // no schema yet + +function blocksList(params?: { ref?: string; ifNoneMatch?: string }): + | { notModified: true; revision: string; resolvedRef: string | null } + | { notModified: false; revision: string; resolvedRef: string | null; + blocks: Record<string, object>; // every saved block, by name + versions: Record<string, string>; // one opaque version per entry + diagnostics: Diagnostic[] }; // files skipped or shadowed + +function blocksApply(params: { + ref?: string; + requestKey?: string; // retry the same logical write; only when advertised + ifSchemaMatch?: string; // reject if the schema changed; only when advertised + set?: Record<string, object>; // whole-entry replace (create or update) + delete?: string[]; // a missing name counts as deleted + ifMatch?: Record<string, string | null>; // a version the entry must have; null = must not exist +}): { revision: string; versions: Record<string, string | null> }; +``` + +Versions and revisions are opaque strings defined by each storage (a git blob hash on GitHub and on disk), compared only for equality and never across servers. Everything else is fixed inside the root: `blocks.list` and `blocks.apply` read and write `<root>/.deco/blocks`, and `schema.get` reads `<root>/.deco/schema.gen.json`, falling back to `<root>/.deco/meta.gen.json`, and parses it before serving, so a file caught mid-write is never served torn. With branches, a read of a branch that doesn't exist yet returns the default branch's content (reported in `resolvedRef`), and the first write creates the branch. + +### No schema yet + +A site whose schema isn't generated yet (`deco schema` hasn't run) is a normal state, not an error. `schema.get` returns `schema: null` with `version: null`, and `blocks.list` and `blocks.apply` work as usual: without a schema there's no secret guard to apply and no `ifSchemaMatch` to send (one that's sent conflicts, with `actual: null`). There's no version to poll with, so the client polls `schema.get` without `ifNoneMatch` until a schema appears; the answer is a few bytes. The site editor lists every saved block meanwhile, grouped by `__resolveType`, and opens each one as plain fields inferred from its JSON, with a banner that says how to generate the forms. A site with no `.deco` folder at all is still NotFound. + +`describe` doesn't report whether a schema exists: answering would mean reading the whole schema on every `describe`, and `schema.get` already says so in a few bytes. + +`blocks.apply` is the only write: + +1. **All or nothing.** Every name in `set` and `delete` lands together (one commit on git) or none does. +2. **Durable when it returns.** It resolves only after the commit or the file renames landed, which is what the site editor's autosave indicator relies on. +3. **`set` wins** when a name is in both `set` and `delete`. +4. **Validated first.** Names, value shapes, sizes and the secret guard are checked before anything is written, and every violation is reported at once. +5. **Preconditions are optional.** A failed `ifMatch` writes nothing and returns a conflict with each entry's expected and actual version. Without `ifMatch`, the last writer wins, as with the site editor's autosave. + +Renaming, duplicating and moving entries are recipes over one `blocks.apply` (a `set` plus a `delete`, with `ifMatch: { [newName]: null }` for create-only), not methods of their own. Creating and synchronizing hosted drafts, publishing, rollback, discarding a draft and the [draft pointer](/next/api-reference#draft-pointers) stay site editor routes outside the protocol. The hosted editor binds its endpoint to the chosen internal draft; an editor does not need to supply or understand a Git branch. See [Keeping drafts current](/next/draft-synchronization). + +### Retry-safe writes and schema changes + +A dropped HTTP response does not tell the client whether its save committed. `describe.writes.idempotency` advertises durable request-key receipts and their retention window; hosted Git storage must support them. A client creates one `requestKey` for a logical `blocks.apply` and reuses it only for retries of the identical request. A JSON-RPC `id` only correlates responses and is not an idempotency key. + +Scope receipts to the authenticated tenant, principal, app root and resolved write target. Bind each key to a canonical digest of all supplied parameters. Return the original result when the same request is retried within the retention window, even if the branch has since advanced; reject reuse for different parameters with Invalid params (-32602). Recheck authorization before returning a receipt. After that window, the client rereads and reconciles; it must not blindly retry an uncertain old write as new work. + +Persist the receipt with the mutation, or recover it from commit metadata. An in-memory map or a database insert after moving a Git ref cannot guarantee deduplication after a crash. Reserve concurrent requests with the same key, reconcile uncertain provider responses before retrying, and keep receipts for deleted entries too. Coalescing autosaves must preserve each request's result and guards; it cannot weaken the atomicity of an individual apply. + +When `schemaPreconditions` is advertised, `ifSchemaMatch` guards against the form's schema changing before its save. A mismatch uses the Conflict error, with expected and actual schema versions. Recheck this and every per-entry `ifMatch` on the storage snapshot used for each atomic commit attempt, including after automatic synchronization. Receipts for completed identical requests are resolved before reevaluating those guards. Unsupported guards and request keys are rejected rather than ignored. + +`blocks.apply` still replaces whole entries. Hosted [draft synchronization](/next/draft-synchronization#file-level-draft-wins) uses the same whole-entry granularity: edited files and deletions favor the draft; untouched files adopt production. There is no implicit property patch behavior in this method. The protocol does not execute site code or claim that shape validation proves compatibility with deployed code. + +### Polling instead of events + +There's no event stream in the portable protocol. Hosted Studio may invalidate reads through its existing events, but a client must also work by polling. A client polls with one batched request that carries the versions it has; when nothing changed, both answers are a few bytes: + +```json title="A poll when nothing changed" +→ [{"jsonrpc":"2.0","id":4,"method":"schema.get","params":{"ifNoneMatch":"9f2c…"}}, + {"jsonrpc":"2.0","id":5,"method":"blocks.list","params":{"ifNoneMatch":"4be1…"}}] +← [{"jsonrpc":"2.0","id":4,"result":{"notModified":true,"version":"9f2c…"}}, + {"jsonrpc":"2.0","id":5,"result":{"notModified":true,"revision":"4be1…","resolvedRef":null}}] +``` + +After its own write, a client adopts the returned revision, so its next poll is "not modified" unless someone else wrote. When the map did change, the client replaces its copy except for entries it's still saving. + +### Errors and limits + +| Code | Name | When | +|---|---|---| +| -32001 | NotFound | No `.deco` folder at all (the site editor's "not a Deco site"), or a branch that can't be created. No schema isn't an error: see [No schema yet](#no-schema-yet) | +| -32002 | Conflict | An `ifMatch` or `ifSchemaMatch` precondition failed | +| -32003 | InvalidBlock | A name or value breaks the rules below, or a `Secret` field holds anything but a `secret` block with a well-formed `ciphertext` (the secret guard) | +| -32005 | ReadOnly | A write to a read-only endpoint | +| -32006 | Unsupported | A feature the endpoint doesn't offer, such as branches on the local server | +| -32007 | LimitExceeded | Too many operations, batch entries or bytes | +| -32008 | Unavailable | The storage failed or is rate-limited, or retries ran out; carries `retryAfterMs` when known | +| -32010 | Unauthorized | Missing or invalid bearer token on an endpoint that requires one (HTTP 401); `deco serve` never returns it | +| -32011 | Forbidden | Authenticated, but not allowed for this project | + +Plus the standard JSON-RPC codes (-32700, -32600, -32601, -32602, -32603). The defaults: at most 500 names per `blocks.apply`, 1 MiB per entry and 8 MiB per request. Reads are bounded too: `describe` reports maximum uncompressed schema, block-list and aggregate batch-response bytes, as well as the write limits. A storage can lower these limits. Existing files and conditional misses count against them; gzip does not bypass them. A list that is too large returns LimitExceeded, never a partial map presented as the whole snapshot. Choose and document hosted read defaults before release, measuring parsed-object memory as well as wire bytes. + +### File names + +The site editor, `deco serve` and [`deco content`](/next/content#the-content-module) import one rule from the protocol subpath, so an entry the site editor edits is the entry your app renders: + +- **Name to file:** `encodeURIComponent(name) + ".json"`, directly in `.deco/blocks` (no subfolders). A page named `pages-Home%20Page-6f1e` is stored as `pages-Home%2520Page-6f1e.json`; `collections/blog/posts/abc` as `collections%2Fblog%2Fposts%2Fabc.json`. +- **File to name:** decode the file name, without `.json`, exactly once. If decoding fails, the raw name is the entry name. +- **Two spellings of one name** (files that decode to the same name after repeated decoding): one wins, the file whose entry has a `path`, then the one that took more decoding, then the lowest file name. The others are reported as diagnostics. A write overwrites the winning spelling and deletes the others in the same commit. +- **Names the site editor can't save:** empty names; names containing `\`, `..` or NUL; names whose encoded form is over 250 bytes, so the file fits a 255-byte file-name limit; a new name that differs from another entry's only in letter case; Windows device names such as `CON`; `__proto__`; and names ending in a source extension such as `.ts` or `.tsx`, which would shadow a module (those can still be deleted). +- **File content:** `JSON.stringify(entry, null, 2)` plus a newline, UTF-8. + +### The local server + +`deco serve` knows nothing about accounts: it prints its local address, inside the site editor link, and the site editor connects to it. + +[`deco serve`](/next/cli#deco-serve) has no token or password, and it answers browser requests from any origin (CORS, with the request's `Origin` reflected), along with Chrome's local-network preflight. It listens on loopback only (127.0.0.1 and ::1, printed as `localhost`) unless you pass `--host`. Any website open in your browser can therefore read and write your content through it, so run it only while editing and stop it when you're done. The server rejects any `Content-Type` other than JSON on `/rpc` (uploads are the one exception, below). With `--host` set to an address other machines can reach, anyone on your network can read and write your content too, so the server prints a warning. + +The site editor link is `https://studio.decocms.com/site-editor#endpoint=` followed by the encoded `http://localhost:<port>/rpc`. The site editor remembers the last endpoint in your browser, so opening `/site-editor` again reconnects to it; while the server is down or restarting, it shows that it's waiting for it and reconnects on its own. `describe` reports the app to preview as `preview: { url }`, the address given by `--preview` (by default your Vite config's port, else `http://localhost:5173`); the site editor's Preview tab loads only `http` or `https` URLs on a loopback host and shows no preview for anything else. To show one variant, it adds a `?__draft=` pointer to this server's endpoint that [forces that variant](/next/releases-and-drafts#preview-a-variant). The flow from the editor's side is in [Edit on your machine](/next/site-editor#edit-on-your-machine). + +Uploads aren't one of the four methods. The site editor sends each file to `PUT /assets/<name>` on the same server; on that path the server accepts the file's own image, video, font or PDF content type instead of JSON. It writes the file to the asset folder (`public/assets`, or `--assets`), never overwrites an existing file (a taken name gets a short suffix), and answers with the path the site editor stores in the field. `describe` reports where uploads go and the largest file it accepts in its `assets` field; with `--read-only`, `assets` is `null` and uploads are refused. The site editor's GitHub backend stores uploads in [Deco's asset storage](/next/hosted-site-editor#uploads) instead, so they never become commits. How to serve local uploads is in [Images and other uploads](/next/site-editor#images-and-other-uploads). + +### The package + +The protocol ships inside `@decocms/blocks`, under the `protocol` subpath, with `zod` as its only runtime dependency. The CLI (the `cli` subpath) and the site editor import it; the SDK's runtime never does, so it never reaches an app bundle. + +```text +@decocms/blocks/protocol method types, errors, client, keys (browser-safe) +@decocms/blocks/protocol/keys the file-name rule above +@decocms/blocks/protocol/server createContentHandler(storage): (Request) => Response +@decocms/blocks/protocol/storage/fs the filesystem storage (Node only) +@decocms/blocks/protocol/conformance a black-box test suite over HTTP +``` + +A storage implements a small interface: a snapshot of names and versions, reading file bodies, reading the schema, and one atomic commit attempt. The core owns everything else: the JSON-RPC layer, validation, the file-name rule, the secret guard, limits and retries. The site editor's GitHub backend is one storage; the CLI's filesystem storage is another. The conformance suite runs against any endpoint, which is how both are kept to the same contract. + +### Conformance for hosted storage + +The black-box suite must exercise atomic set/delete, set precedence, stale entry and schema guards, repeat requests after lost responses, simultaneous duplicate requests, receipt recovery after a simulated restart, tenant isolation and oversized reads and writes. A storage retry revalidates against the new head; a rate-limit response carries retry timing rather than triggering a burst of immediate retries. Versioned draft assets are served by the separate [delivery contract](/next/content-delivery#exact-draft-previews), so a moving branch read is never mistaken for an immutable preview read. + +## Studio implementation + +The [Studio implementation handoff](/next/studio-implementation) maps this design to existing Fast Preview modules and specifies persisted state, operation contracts, sequencing, initial limits, recovery scenarios and rollout phases. diff --git a/docs/content/next/content.mdx b/docs/content/next/content.mdx new file mode 100644 index 00000000..fa2d3939 --- /dev/null +++ b/docs/content/next/content.mdx @@ -0,0 +1,92 @@ +--- +title: Content and loaders +nav: Content & loaders +group: Content and data +order: 12 +kind: docs +description: deco content turns your saved blocks into a module your bundler packs. createCMS reads it, or a loader you write fetches content from somewhere else. +--- + +# Content and loaders + +Your [saved blocks](/next/saved-blocks) are JSON files in `.deco/blocks`. If your app runs on Cloudflare Workers, or any host without a filesystem, there's nothing to read them from at runtime. `deco content` turns those files into a module your bundler packs into the build, like any other import. When the build isn't the right place for your content, a loader you write fetches it instead. + +This page shows how saved blocks reach your running app: the content module, the `cms` object that reads it, and how to write a loader. + +## The content module + +The `deco` CLI, which comes with `@decocms/blocks`, turns `.deco/blocks` into a module your app imports. So the SDK never touches the filesystem and runs anywhere: Node, Cloudflare Workers, Deno, Bun or a browser. + +```bash +npx @decocms/blocks content +``` + +It writes `.deco/blocks.gen.ts`: one JSON import per file, exported together, so your bundler packs your content into the build: + +```ts title=".deco/blocks.gen.ts (excerpt)" +// Generated by deco content; don't edit. +import HomePage from "./blocks/HomePage.json" with { type: "json" }; +import PromoBanner from "./blocks/PromoBanner.json" with { type: "json" }; +// … one import per file, exported together +``` + +- **Gitignore it.** The generated file belongs in `.gitignore`, like TanStack Router's `routeTree.gen.ts`. Run the command before your dev server and your build, after [`deco schema`](/next/schema#from-types-to-forms) (see [Run it before dev and build](/next/cli#run-it-before-dev-and-build)). +- **Editing a file needs no rerun.** It reloads through your framework's hot module replacement (HMR). +- **Adding or removing a file does.** Rerun `npx @decocms/blocks content`, or leave `npx @decocms/blocks content --watch` running beside your dev server. +- **It reads only `.deco/blocks`.** It bundles the JSON files there and never reads your [block map](/next/blocks#the-block-map). + +Like every command, it finds `.deco/` by walking up from the current folder, or takes `--root`. Every option is in the [CLI reference](/next/cli#deco-schema-and-deco-content). + +## Create the CMS + +Pass the content module to [`createCMS`](/next/api-reference#createcms-config), next to your block map. Create it once, at module scope: + +```ts title="cms.ts" +import { createCMS } from "@decocms/blocks"; +import blocks from "./.deco"; +import content from "./.deco/blocks.gen"; + +export const cms = createCMS({ blocks, content }); +``` + +Then ask it for a client per request. `cms.forRelease()` returns a client that reads the content everyone sees, the [release](/next/releases-and-drafts#releases): + +```ts +const client = cms.forRelease(); +const [page, error] = await client.resolve("HomePage"); +``` + +One client per request keeps a whole response on one content [revision](/next/releases-and-deployment#what-a-revision-is) (see [One revision per response](/next/releases-and-deployment#one-revision-per-response)). For a [draft](/next/releases-and-drafts#drafts), use `cms.forDraft(pointer)` instead. + +`createCMS` also takes a `telemetry` option, which says where measurements from your app go; see [Telemetry](/next/telemetry). + +## Write a loader + +The content module covers almost every site: the content of the commit you deployed ships in the build. Write a loader when your content lives somewhere else, such as your own storage, or when you want to serve drafts. + +A loader is any object with a `load()` method that returns a snapshot, `{ revision, blocks }`. `load()` with no argument is the release; `load(pointer)` is the draft a [draft pointer](/next/api-reference#draft-pointers) names. If the content can change while the process runs, add `update()`: + +```ts title="cms.ts" +import { createCMS, type Loader } from "@decocms/blocks"; +import blocks from "./.deco"; +import fallback from "./.deco/blocks.gen"; + +const myLoader: Loader = { + async load(pointer) { + if (!pointer) return fallback; // the release: what this build shipped + return fetchDraftFromMyStorage(pointer); // your code: validate the pointer first + }, +}; + +export const cms = createCMS({ blocks, content: myLoader }); +``` + +- **Treat the pointer as untrusted.** It comes from request input. Allow-list the hosts your loader may fetch from, so a crafted pointer can't make your server fetch arbitrary URLs, and require a credential you can verify, such as a signed token, before serving unpublished content. +- **`update()` never blocks a request.** A loader with `update()` is checked every `interval` (default 60 000 ms, never less), at the next idle moment. `cms.update()` checks at once, for a webhook or a "refresh now" button. +- **A failed draft is an error.** A content source with no drafts, like the content module, ignores the pointer. Otherwise, if `load(pointer)` fails, or the pointer doesn't parse, every `resolve` and `list` on that client returns `[null, error]` with [`LOADER_FAILED`](/next/api-reference#errors). The CMS never silently substitutes published content after a failed draft load; your app decides what to show. Hosted draft loading deliberately composes a valid overlay with a captured local production snapshot, including explicit deletion tombstones (see [Draft overlays](/next/content-delivery#exact-draft-previews)). + +The exact `Loader` interface, `interval`, the loaders the framework ships, and a loader for Workers KV you can copy are in [Loaders](/next/api-reference#loaders). + +## One instance per process + +`createCMS` shares one instance for the same configuration, even if your bundle loads the package twice, and each call resolves with its own block map; see [One instance per process](/next/api-reference#one-instance-per-process). diff --git a/docs/content/next/design-decisions.mdx b/docs/content/next/design-decisions.mdx new file mode 100644 index 00000000..a965e18d --- /dev/null +++ b/docs/content/next/design-decisions.mdx @@ -0,0 +1,52 @@ +--- +title: Design decisions +nav: Design decisions +group: Under the hood +kind: internals +order: 8 +--- + +# Design decisions + +You're about to add a feature and wonder why Deco CMS doesn't just fetch your data or route your URLs for you. Each of these choices was argued over. + +This page lists each decision in one line, and why it won, grouped by the part of Deco CMS it shapes: the Blocks syntax, the tools built on it, and how content and telemetry leave your servers. + +## The syntax + +| Decision | Why | +|---|---| +| **A block is either a function (code) or saved content (JSON).** One namespace: a [saved block](/next/saved-blocks) is merged in and looked up again; a function gets its inputs resolved, then runs, and its result is returned as is. Built-in functions such as `page` share the same namespace, under yours. | Saved blocks are named calls, not a second concept. A function that returns its input, like `seo`, can't loop, because results are never looked up again. The only recursion is over saved blocks, and a cycle check stops it. | +| **Block functions are pure.** A block function gets its inputs and nothing else. | No context object means block functions are ordinary functions you can call and test, and the same code runs in a website, a mobile app, or a background job. Request state comes from your framework's own request scope, where it already lives. | +| **Laziness is written in the data, one special case.** Every input resolves first, from the inside out, except a [`lazy` block](/next/lazy-blocks), which becomes a `Lazy<T>` function. | One exception is easy to keep in your head, and the saved JSON shows exactly where evaluation is deferred. A function asks for it in its types, so `deco check` catches a mismatch, and `multivariate` stays an ordinary function that runs only the chosen variant. | +| **`page`, `redirect`, `cms-settings`, `always`, `never`, `date`, `multivariate`, `lazy` and `secret` are [built-in blocks](/next/built-in-blocks); everything else, data included, is a function in your [block map](/next/blocks#the-block-map).** [`deco schema`](/next/schema#from-types-to-forms) reads only the default export, and every key gets its schema from its function's first parameter. | One list of names for the CLI and the runtime, so a type that has a form always resolves. Data-only types such as posts use a function that returns its input (`(props: Post) => props`), so there's no second, type-level map to keep in sync. The framework spreads its built-ins under your block map, so pages and redirects are always in the schema and always resolve. Almost every website needs them, and [the site editor](/next/site-editor)'s page list and redirects screen look for them. A key in your map [replaces one](/next/built-in-blocks#change-a-built-in). | +| **Three [matchers](/next/matchers-and-variants#matchers) and `multivariate` are built in; the rest is app code.** | `always`, `never` and `date` depend on nothing but the clock, and `multivariate` only runs the first variant whose rule is `true`. A device or cookie matcher is an opinion about the request, and the app owns those. Declare `multivariate` yourself to change how it picks. | +| **Lazy loading code belongs to your web framework, not to Blocks.** | What's heavy is the component, and React and Next.js already load components lazily. The block map stays a plain object the CLI can read. | +| **Short type names; [aliases](/next/renames-and-migrations#rename-a-type-with-an-alias) for the old file-path names.** | A type name is a column name, not a file path. Content saved under the Fresh and Deno and v7 names, like `website/pages/Page.tsx`, keeps working through aliases, while new code uses names that survive a refactor. | + +## The tools + +| Decision | Why | +|---|---| +| **The CLI ships inside `@decocms/blocks`.** It's the package's single bin, `deco`. | One install, and the CLI's version can't drift from the runtime's. The CLI never reaches an app bundle: apps don't import its subpath, and `typescript` loads only when a command runs (see [How the CLI ships](/next/internals#how-the-cli-ships)). | +| **Four CLI commands, no dev wrapper.** | `deco schema` and `deco content` each do one job and run from `predev` and `prebuild`; [`deco check`](/next/checking) runs in CI and `prebuild`. Every command works on one folder, [`.deco/`](/next/saved-blocks#the-deco-folder), in your app root, found by walking up from the current folder (or passed as `--root`), so there are no paths to configure; `deco schema` and `deco content` take `--watch`. Generated files carry `.gen.` in their names (`schema.gen.json`, `blocks.gen.ts`), so it's clear which files you don't edit. [`deco serve`](/next/cli#deco-serve) is the only server, and you run it only while you want the site editor on your working tree. | +| **The site editor needs only the schema and the files.** It reads the schema, reads and writes `.deco/blocks` through a four-method [content protocol](/next/content-protocol), and never runs site code. | Editing doesn't depend on a deployed, reachable site, an app with its `.deco` folder can live anywhere in a monorepo (the site editor's "app root" setting points at it), and one editor works on GitHub and on a developer's machine. Features that ran site code (previews in the site editor's gallery, dynamic pickers, Run) degrade, while secrets are encrypted in the browser with the committed public key; the running site remains the preview: your dev server, a preview deploy, or, with the hosted Deco CMS, a [`?__draft=` link](/next/hosted-drafts#the-draft-link). See [What works without your code](/next/site-editor#what-works-without-your-code). | +| **Compatibility is a CI check on the content, not a runtime gate.** | Content and code merge independently, and with the hosted Deco CMS content goes live without a deploy, so code that's already live must render new content; holding content back until a deploy would undo that. `deco check` validates every saved block against the schema the code generates, so a code change that breaks saved content and content that uses something the code lacks both fail before they merge. On GitHub, the site editor reads the schema committed on the branch it edits, falling back to your default branch when that branch has none. See [Backward compatibility](/next/checking#backward-compatibility). | +| **Poll with conditional reads; no event stream.** | One batched request that answers "not modified" when nothing changed costs a few bytes, needs no reconnect logic, and works through any proxy. Two seconds locally and thirty on GitHub is fast enough for an editor watching their own edits. | + +## Delivery and telemetry + +| Decision | Why | +|---|---| +| **Git is the source of truth for content.** Publishing is committing: a deploy ships the commit, or the [hosted Deco CMS](/next/hosted) serves it as a [release](/next/hosted-publishing#how-a-commit-becomes-a-release) without one. | History, review, branches, and rollback come for free, and agents can edit content with the tools they already have. There is no publish command because there is nothing to publish that a commit doesn't already express. `deco publish` exists only as a signpost for AI agents: it prints this explanation and exits with code 1, so an agent that tries it learns to commit instead. It isn't listed in the CLI's help. | +| **The CMS at module scope, a client per request, [pinned to one revision](/next/releases-and-deployment#one-revision-per-response).** | Everything a response renders agrees with itself, a page's blocks can be resolved and streamed to the browser one at a time, and the next request sees the next revision. Clients are cheap because the content cache lives in the CMS, shared by every client. | +| **[`content`](/next/content#create-the-cms) is a snapshot or one [`Loader`](/next/api-reference#loaders) interface**: `load(pointer?)`, plus an optional `update()`. | Production, drafts, and staying current are uses of the same interface, so the [content module](/next/content#the-content-module) and `remoteLoader` replace each other in one option, and the rest of your code never knows which one it has. | +| **A production request never waits for the network.** | Requests read content from memory. A content source that can change is checked with `update()` at idle moments, never in front of a request. | +| **Deco CMS doesn't route.** [`matchRoute`](/next/api-reference#matchroute-url-items) is a helper over entries you list. | Which types have URLs is an app decision, a non-website has none, and the framework's file-system router still owns code routes. Editors get pages at arbitrary paths because the path is a field. | +| **The framework doesn't fetch data.** Platform integrations are thin, instrumented [API clients](/next/upstream-clients); there's no `/deco/invoke` and no `cachedLoader`. | Data fetching is the part of a site that differs most between platforms and frameworks, and frameworks already have server functions and route handlers for it. Thin clients keep every call measured the same way while converters, hooks and flows live in templates the site owns. Caching depends on the platform, so it lives in your app, as a fetch you pass to a client (see [Upstream data](/next/caching#upstream-data)). | +| **Packages export TypeScript source and depend on each other in one direction.** | Bundling one package into another can leave two copies of a module that must be one, like the block registry. Importing source keeps one copy, and the one-way graph keeps the core free of framework code. Instances also live on `globalThis`, so a module loaded twice still shares one cache and one poller. | +| **The SDK never touches the filesystem.** | One setup for every runtime: Node, Workers, Deno, Bun, React Native and browsers. The CLI turns files into a module, and your framework's hot module replacement gives hot reload. | +| **Hosted releases: poll a channel manifest once a minute, at idle moments; no push.** | A small manifest per server per interval, no infrastructure to reach every server, and never in front of rendering. Content lives in immutable storage/CDN assets; production never reads GitHub. New promotion generations support rollback to older revisions. Until the first release arrives, or if the Deco API is unreachable, a server keeps serving the content the build shipped with or the newest release it has. Eventual consistency for visitors. Draft pointers fix overlays; each preview inherits its server's local production, captured once per client. | +| **Telemetry is open source; code says where it goes, content says how much.** Destination and credentials live in [`createCMS`](/next/telemetry#choose-where-telemetry-goes); the `telemetry` section of the [`CMS` block](/next/telemetry#telemetry-settings-are-content) holds switches and sample rates, [capped by code](/next/telemetry#sampling). | Secrets never go into content, which editors change and releases ship. Switching telemetry off is a content edit anyone can see in Git, while an editor can't raise what leaves your servers past the limits code sets. Speaking OTLP means any backend works; the hosted Deco CMS collector is one destination. Sampling and aggregation keep the cost flat as traffic grows. | +| **One settings block; code holds destinations, secrets and caps.** Site-wide settings are one saved block, `CMS`, of the built-in type [`cms-settings`](/next/built-in-blocks#cms-settings), with a section per feature (`preview`, `telemetry`, `analytics`). Code caps what each section may allow, and [`cms.settings()`](/next/api-reference#cms-settings) reads it from the release in memory, never from a draft. | One form for editors and one file to review, with no list of special blocks to keep in sync: the schema already describes the type, and the site editor finds it by its one well-known name. Reading the release keeps a draft from allowing its own preview host or changing what's sent, and reading memory keeps a server able to start and serve with no network. | +| **Analytics is separate from telemetry and speaks the One Dollar Stats wire format.** Its settings are the `analytics` section of the `CMS` block, and the site renders `AnalyticsScript` with them from [`cms.settings()`](/next/analytics), the same way on every framework. | Page views are a browser concern that editors switch and configure, so they belong in content, not in the server-side `telemetry` option. An existing, simple, cookie-free format means it works with [One Dollar Stats](/next/analytics#one-dollar-stats)' collector and their tracker works with yours, with no new format to learn or maintain. | diff --git a/docs/content/next/draft-synchronization.mdx b/docs/content/next/draft-synchronization.mdx new file mode 100644 index 00000000..e4d6c622 --- /dev/null +++ b/docs/content/next/draft-synchronization.mdx @@ -0,0 +1,77 @@ +--- +title: Keeping drafts current +nav: Draft synchronization +group: Under the hood +kind: internals +order: 10 +description: Automatically incorporate published Git content into editor drafts, with file-level draft-wins selection and no JSON merge downloads. +--- + +# Keeping drafts current + +An editor changes a homepage title while someone else publishes a new footer. Their draft should keep the title and pick up the footer, without asking them to create a branch or rebase. + +The hosted site editor manages an internal branch for each draft, created on its first save. Editors work with drafts, previews and publishing; Git remains the durable history. This is the proposed synchronization design, tracked in the [roadmap](/roadmap#roadmap-platform--synchronize-editor-drafts). + +## Synchronize active drafts, not every branch + +The existing GitHub webhook intake can enqueue production changes. Record the latest repository production commit once, and the production commit each draft last incorporated. Comparing those stored identifiers needs no GitHub request. + +- On a production push, mark drafts stale and schedule background synchronization for drafts currently open. +- When an inactive draft is reopened, synchronize it before returning its current editable snapshot. +- Before a save or publish, reconcile against the latest production head. Serialize this work with other operations on the same draft. +- Coalesce several production updates into one synchronization to the newest head. Leave inactive drafts alone until needed. + +Keep periodic control-plane reconciliation as a fallback for missed events. Local `deco serve` keeps editing the developer's working tree and does not perform hosted branch synchronization. Production delivery reads only [stored assets](/next/content-delivery), never GitHub. + +Synchronizing a draft requires GitHub reads and usually a commit. The cost follows active drafts and actual changes, not visitor traffic. A publication arriving after a synchronization began is incorporated by the next pass; drafts are not promised to match a perpetually moving head instantly. + +## File-level draft wins + +Compare the previous incorporated production tree (**B**), the current draft tree (**D**), and the latest production tree (**M**) by file path and Git blob identity. A missing file is a deletion, distinct from a JSON entry whose value is `null`. + +For each owned saved-content path, if D equals B, take M. Otherwise take D, including deletion. When D equals M there is no remaining override after advancing the incorporated base. This is a whole-file rule: no property-level merge, textual merge or conflict-marker parsing. + +| File change relative to B | Result | +|---|---| +| Only production changed it | Adopt production's blob | +| Only the draft changed it | Keep the draft's blob | +| Both changed it, even in different JSON properties | Keep the entire draft file | +| Draft deleted a file that production edited | Keep the deletion | +| Draft edited a file that production deleted | Keep the draft file | +| Both created the same path differently | Keep the draft file | +| Both now contain the same blob | Use that blob and clear the override | + +For example, if the draft changes a hero title and production changes the image in that same file, the whole draft hero wins, including its previous image. If production changes a separate footer file, the draft inherits that footer. This is intentional and matches the whole-block preview overlay, not an exceptional fallback for large files. + +After incorporating production, advance the recorded base with the draft commit. Keep enough metadata on the commit to recover the incorporated production SHA if the database update is interrupted. Retain append-only history for managed drafts; automatic synchronization never asks editors to rebase or rewrites their saved commits. + +Only retain overrides in the app root's saved-content paths. Code and generated schemas follow production; unrelated production repository and monorepo paths are preserved. Publishing reconciles again using this same rule and writes a content-only diff onto current production, respecting branch protection and required checks. + +## Reuse blobs without reading JSON + +The whole-file decision uses tree metadata only. Reference existing blob IDs in the new tree; synchronization does not download or parse base, draft or production file bodies, even when they are huge. Traverse only owned subtrees, bound metadata concurrency, and detect truncated provider comparisons instead of silently omitting paths. + +There is no property-merge size threshold or oversized-conflict fallback to implement. Content reads, editor writes and [asset preparation](/next/content-delivery#bound-preparation-memory) still enforce their own limits; they cannot infer safe heap usage from this cheaper synchronization path. + +## Saves racing with synchronization + +Coordinate saves, synchronization, discard and publication using durable per-draft serialization. External Git pushes still race with Studio: a branch update must verify its expected head, and if it moved, rebuild from the new head and recheck every precondition. Never resolve that race with an unconditional force update. + +This policy is separate from two editors saving the same block. The first version may keep last-writer-wins autosave; clients that send `ifMatch` get an explicit conflict if synchronization or another editor changed the block. Preserve unsaved form state and show that newer stored content is available instead of silently replacing what the editor is typing. + +Parse the merged content before committing: every file must still be valid JSON. Synchronization doesn't check content against the schema, and neither does publishing; references and routes are checked by `deco check` in CI. A failed synchronization leaves the draft intact and blocks publishing with a useful error. + +## What the editor sees + +The draft lifecycle routes expose the latest incorporated production commit, synchronization state and a bounded summary of automatic resolutions. For example: "Updated published content; kept your hero file." Report when the draft file replaced a production change, without requiring manual conflict resolution. Keep the full audit record outside content snapshots. + +Emit changes through Studio's existing event delivery for open editors, with a slow fallback poll. The portable [content protocol](/next/content-protocol) remains polling-based and works without events. Branch creation, synchronization and rebasing are never tasks the business user has to perform. + +## Studio implementation + +The [Studio implementation handoff](/next/studio-implementation) maps this design to existing Fast Preview modules and specifies persisted state, operation contracts, sequencing, initial limits, recovery scenarios and rollout phases. + +## Preview transport + +Synchronization maintains Git content and its incorporated production metadata. Preview delivery is separately optimized: [draft overlays](/next/content-delivery#exact-draft-previews) contain only cumulative changed blocks and deletion tombstones, applied over whatever production the rendering server already has. The internal Git base is never a required preview `baseRevision`. diff --git a/docs/content/next/hosted-drafts.mdx b/docs/content/next/hosted-drafts.mdx new file mode 100644 index 00000000..986b3b8b --- /dev/null +++ b/docs/content/next/hosted-drafts.mdx @@ -0,0 +1,148 @@ +--- +title: Previewing drafts +nav: Previewing drafts +group: Hosted Deco CMS +order: 29 +description: With the hosted Deco CMS, the site editor opens your real site with a ?__draft= link and the draft renders in place, with no preview deploy. +--- + +# Previewing drafts + +An editor changes the summer banner and wants to see it on the real product page before publishing. With the [hosted Deco CMS](/next/hosted), [the site editor](/next/site-editor) opens your real site with a link that names the draft, and your app renders that draft in place: same code, different content, no build. + +This page shows the `?__draft=` link, the few lines that wire it into your app, and who may preview. + +## The `?__draft=` link + +With the hosted Deco CMS, a [draft](/next/releases-and-drafts#drafts) is an editor's saved changes on their draft branch, not published yet (see [Editing in the site editor on GitHub](/next/hosted-site-editor)). To show it, the site editor opens your site with a **draft pointer** in the URL: + +```bash +https://store.example.com/summer?__draft=delivery.decocms.com/sites/acme/drafts%3Ftoken%3D…@9f3c1a… +``` + +The part after `__draft=` is URL-encoded, which is why `?` and `=` show as `%3F` and `%3D`. Decoded, `delivery.decocms.com/sites/acme/drafts?token=…` is where this site's drafts live, plus a grant signed by the site editor. The grant covers one overlay version of one site and expires after an hour; the editor mints a fresh one while it's open. `9f3c1a…`, after the `@`, is the overlay version: the SHA-256 of the overlay manifest, 64 hex characters. The hosted loader fetches only pointers of this shape, `delivery.decocms.com/sites/<your site>/drafts?<grant>@<version>`, and refuses any other host, site path or version, so your code never builds or checks one. + +The pointer is a string, `<host><path>@<version>`. [`cms.forDraft(pointer)`](/next/api-reference#draft-pointers) fetches the exact draft overlay that version names. The overlay contains only replacements for changed saved blocks and tombstones for deletions. Its small manifest references immutable changed-block assets, so repeated saves download only blobs the server does not already have. + +The preview inherits every other block from whatever production content that server already has. There is no `baseRevision` and no download of a matching production snapshot. One client captures its local production content once, so the whole response uses that content plus one overlay. A later request can inherit newer production content without changing the pointer. Different servers can inherit different releases; the pointer fixes draft changes, not the full preview state. + +If the overlay can't be fetched, the client's calls return an error instead of silently displaying published content (see [A failed draft is an error](/next/content#write-a-loader)). The wire format, caching and deletion behavior are described in [Draft overlays for fast previews](/next/content-delivery#exact-draft-previews). + +## Wire drafts into your app + +Picking the client from [`cms.draftPointer(request)`](/next/api-reference#draft-pointers) is the two-line pattern in [Preview](/next/releases-and-drafts#preview). The hosted flow adds a cookie: the pointer is only in the URL of the first page the site editor opens, so that response sets a cookie and every link the editor clicks stays in preview. `cms.draftCookie(request)` returns the `Set-Cookie` value for that response, and `null` on every other request; `cms.draftPointer` reads the query parameter or the cookie: + +```ts title="A plain request handler" +import { cms } from "./cms"; + +export async function handle(request: Request) { + const pointer = await cms.draftPointer(request); // from ?__draft= or the cookie; null on an ordinary request + const client = pointer ? cms.forDraft(pointer) : cms.forRelease(); + const response = await render(client, request); // your own function: list, matchRoute and resolve as usual + + const cookie = await cms.draftCookie(request); + if (cookie) response.headers.append("Set-Cookie", cookie); + return response; +} +``` + +The site editor ends a preview by opening the site with `?__draft=off`; `draftCookie` returns an expiring cookie for that, so the same lines cover leaving preview. The parameter and cookie names never appear in your code, and both helpers apply your [preview hosts](/next/releases-and-drafts#allow-previews-per-host): on any other host, the request gets the release. + +Without a [site ID and token](/next/hosted#connect-your-site), the CMS reads the [content module](/next/content#the-content-module), which has no drafts, so it ignores the pointer: you're already looking at your files. With them, your dev server still serves your local files to ordinary requests, and a `?__draft=` link loads the draft from the Deco API (see [Local files win in development](/next/hosted#connect-your-site)). That's why the same code serves previews and visitors everywhere. A draft is just another set of calls run by the same code; to serve drafts from somewhere other than the hosted Deco CMS, see [Write a loader](/next/content#write-a-loader). + +### Next.js + +In the [Next.js guide](/next/nextjs), the cookie and the client split across two files. `proxy.ts` (Next.js 16's name for `middleware.ts`) runs before every request and appends the cookie: + +```ts title="src/proxy.ts" +import { NextResponse, type NextRequest } from "next/server"; +import { cms } from "./cms"; + +export async function proxy(request: NextRequest) { + const response = NextResponse.next(); + const cookie = await cms.draftCookie(request); // only set on the request the site editor opens; expiring when leaving preview + if (cookie) response.headers.append("Set-Cookie", cookie); + return response; +} +``` + +And `client()` reads the pointer from the request headers, since a Server Component has no request. `headers()` carries the cookie and the host, which is all `draftPointer` needs: + +```ts title="src/client.server.ts" +import "server-only"; +import { headers } from "next/headers"; +import { cms } from "./cms"; + +// The client for this request: a draft when the cookie holds a valid pointer on an allowed host, production otherwise. +export async function client() { + const h = await headers(); + const pointer = await cms.draftPointer({ url: `https://${h.get("host")}/`, headers: h }); + return pointer ? cms.forDraft(pointer) : cms.forRelease(); +} +``` + +Nothing else in the guide changes: every page already gets its client from `client()`. + +### TanStack Start + +In the [TanStack Start guide](/next/tanstack-start-descriptors), `client(request)` in `src/cms.ts` reads the pointer from the URL or the cookie: + +```ts title="src/cms.ts (changes)" +// The client for this request: the draft it points at, or the current release. +export const client = async (request: Request) => { + const pointer = await cms.draftPointer(request); + return pointer ? cms.forDraft(pointer) : cms.forRelease(); +}; +``` + +Start's request middleware runs around every request, so it appends the cookie to the document response: + +```ts title="src/start.ts" +import { createMiddleware, createStart } from "@tanstack/react-start"; +import { cms } from "./cms"; + +const draftCookieMiddleware = createMiddleware().server(async ({ request, next }) => { + const result = await next(); + const cookie = await cms.draftCookie(request); // only set on the request the site editor opens; expiring when leaving preview + if (cookie) result.response.headers.append("Set-Cookie", cookie); + return result; +}); + +export const startInstance = createStart(() => ({ requestMiddleware: [draftCookieMiddleware] })); +``` + +## Not a website + +`forDraft` takes the pointer string and nothing else, so nothing here assumes HTTP. A mobile app takes it from the deep link the site editor opens, or from a "preview" field on a debug screen, and keeps it in app state: + +```ts +// React Native, on a deep link like mystore://preview?__draft=... +const pointer = new URL(url).searchParams.get("__draft"); +setClient(pointer ? cms.forDraft(pointer) : cms.forRelease()); +``` + +A script passes it as an argument. To look inside a pointer, or build one from parts, use `parseDraftPointer` and `formatDraftPointer` from the [API reference](/next/api-reference#draft-pointers); `forDraft` parses on its own, so normally you don't have to. + +## Who may preview + +Anyone can put `?__draft=` in a URL, so the CMS checks two things, and neither needs code in your app. First, the pointer must name your site's own Deco API host, which the CMS looks up from your site ID and token. A pointer to any other host is refused (the client returns an error), so a crafted pointer can't make your server fetch from somewhere else. Second, the pointer carries a token signed by the site editor, which the Deco API checks before serving the draft. + +A revision on its own selects content but unlocks nothing; only a signed pointer reaches unpublished drafts. + +Where previews may happen is a setting, not code you write. There is no separate preview server: the server that renders a draft is a production server, it renders the draft the same way it renders visitors, and it keeps picking up new releases either way. To keep drafts on a staging host and off your public domain, list the hosts in the `preview` section of your CMS settings, optionally capped in `createCMS` (see [Allow previews per host](/next/releases-and-drafts#allow-previews-per-host)). On any other host, `cms.draftPointer` and `cms.draftCookie` ignore the draft, and the request gets the release. The list is read from the release, so a draft can't allow its own host. + +That check keeps drafts out of public caches and search results, but it isn't what protects them: the signed, expiring token is. To narrow previews further, on something your app knows about the request, such as a header your CDN sets or a signed-in employee, check it before calling the helpers: + +```ts +const pointer = isEmployee(request) ? await cms.draftPointer(request) : null; // isEmployee: your rule +``` + +<Callout type="warning">**The token is the only Deco credential.** The site ID isn't secret, and the site holds no other credential for Deco (the private key for [secrets](/next/built-in-blocks#secrets) is your own). An invalid or unexpected pointer never loads anything: the client returns an error, and your app decides what to show.</Callout> + +## The site editor's canvas + +The site editor edits your content without your site. It opens your site only for previews: the canvas loads the real page with a `?__draft=` link, so the site needs its site ID and token and the wiring above. With [`deco serve`](/next/site-editor#edit-on-your-machine), the canvas opens your dev app instead, which already renders your working tree. + +## Preview readiness + +A saved commit is durable before its preview asset is necessarily ready. The editor shows preparation status, then opens a signed pointer to the exact immutable overlay. Moving the internal draft branch never changes an older pointer's overrides, but later requests inherit whatever production that server currently has. Draft reads use private object storage through the delivery service; a missing revision never triggers a GitHub fetch. See [Exact draft previews](/next/content-delivery#exact-draft-previews). diff --git a/docs/content/next/hosted-publishing.mdx b/docs/content/next/hosted-publishing.mdx new file mode 100644 index 00000000..d8357004 --- /dev/null +++ b/docs/content/next/hosted-publishing.mdx @@ -0,0 +1,90 @@ +--- +title: Publishing without a deploy +nav: Publishing without a deploy +group: Hosted Deco CMS +order: 30 +description: With the hosted Deco CMS, production commits prepare immutable releases; promotion and rollback reach running servers on their next background check. +--- + +# Publishing without a deploy + +A price on the homepage is wrong in the middle of a sale. Without the [hosted Deco CMS](/next/hosted), publishing is committing and the fix waits for a deploy (see [Deployment](/next/releases-and-deployment#publishing)). With it, there's no rebuild: the [Deco API](/next/hosted#terms) prepares the commit as an immutable [release](/next/releases-and-drafts#releases), and the servers already running your app pick it up after promotion on their next background check. + +This page shows how a commit becomes a release, how servers pick it up, and what keeps running code compatible with new content. + +## How a commit becomes a release + +Your production branch (usually `main`) moves: someone pushes or merges a commit in `.deco/blocks`, or an editor publishes a draft in [the site editor](/next/hosted-site-editor). Then: + +1. **The control plane prepares the commit** as a complete JSON snapshot in object storage. Only after the asset is ready does it promote the production channel manifest. A release never changes; its [revision](/next/releases-and-deployment#what-a-revision-is) identifies it. Production servers read storage/CDN assets and never GitHub. +2. **Each server checks for a new release** about once a minute, at an idle moment, in the background. A request never waits for it: requests always read from memory. +3. **The server swaps the release in whole** and serves it from its next [client](/next/api-reference#createcms-config) on (the object `cms.forRelease()` returns). Requests already in flight keep the revision they started with (see [One revision per response](/next/releases-and-deployment#one-revision-per-response)). + +A publish doesn't check content against your schema: [`deco check`](/next/checking) in CI does that (see [CI on site editor commits](/next/hosted-site-editor#ci-on-site-editor-commits)), and at request time content that doesn't fit fails only its own block, while two routes for one URL resolve to the earlier one. + +A publish needs no application rebuild. Propagation depends on preparation time, the manifest cache lifetime, each server's check interval and rendered-page caching; it is not an immediate global switch. Different servers can briefly serve different releases, by design. + +On Cloudflare Workers, which run no timers between requests, the SDK runs a due check after the response, inside `ctx.waitUntil` (`ctx` is the Worker's execution context; `waitUntil` lets work finish after the response is sent). You don't call anything. + +Calling `cms.update()`, for example from a webhook or an admin "refresh now" button, checks at once, but only on the server or isolate that receives the call; every other one picks the release up on its next check. It never throws. + +The request path, the idle scheduling on each runtime and the swap are described step by step [under the hood](/next/hosted-releases-internals). + +## Fast content rollback + +Choose a retained release compatible with the deployed code. Rollback advances the production channel's generation while pointing it to that older snapshot. It needs no rebuild, code deploy or GitHub read. Servers follow it through the same refresh path as a publish, and rendered-page caches must also observe the promotion. + +Rollback pauses automatic promotion until explicitly resumed, so delayed jobs and new commits cannot silently undo it. It does not change the repository: a Git revert can reconcile that later. Code rollback is a separate operation; select compatible content for the older build. Retention, publication ordering and recovery are described in [Production delivery and rollback](/next/content-delivery#fast-rollback). + +## Check interval + +```ts title="cms.ts" +// Check for new releases every 2 minutes instead of every minute +createCMS({ blocks, content, site: process.env.DECO_SITE, token: process.env.DECO_SITE_TOKEN, interval: 120_000 }); +``` + +`interval` is the core [`createCMS`](/next/api-reference#createcms-config) option that paces every content source that can change (see [Loaders](/next/api-reference#loaders)); with a connected site, that source is the Deco API, so it's how often each server checks for a new release. It defaults to the `DECO_CONTENT_INTERVAL` environment variable, or 60 000 ms (one minute), and lower values are raised to one minute, with a warning. Each check runs one interval, plus or minus up to 10 seconds, after the previous one, so servers that started together don't check together. A check fetches only the current revision hash, a few bytes, however much traffic the server serves. + +## Fallback + +Until a server has fetched its first release (a new server, or one that has never reached the Deco API), it serves the [content module](/next/content#the-content-module) your build shipped with: the content this deploy was tested against, so it's always there and always compatible with the code. If the Deco API becomes unreachable later, the server keeps serving the newest release it already has. Any network error, or a snapshot that doesn't parse, leaves memory as it was; nothing reaches a request. + +The CMS always switches between whole revisions, never mixing entries from two of them, because a mix can break references or bring back deleted entries. + +## Same revision, no download + +[`deco content`](/next/content#the-content-module) and the Deco API compute a revision the same way, as a hash of the whole content map, so two copies of the same content get the same revision wherever they're computed. When a server's check finds that the current release has the revision of the content module it was built with, which is the common case right after a deploy, it uses the bundled copy and never downloads it. + +## Keep running code compatible + +Publishing without a deploy means the code that's already running renders the new content, and nothing at runtime checks that the two fit. On top of the [backward-compatibility rules](/next/checking#backward-compatibility) every site follows, this adds one rule: **deploy the code first, then publish content that uses it.** + +Content must only use block types and fields the deployed code has. The site editor reads the schema committed on the branch it edits, falling back to your default branch when that branch has none (see [What the site editor reads from the schema](/next/studio-compatibility#what-the-site-editor-reads-from-the-schema)), so keep that schema in step with what's deployed. An entry that uses a type the deployed code lacks fails that block with `UNKNOWN_BLOCK` (see [Errors](/next/api-reference#errors)) until the code ships. + +Before rolling code back, select a retained content revision compatible with the older build. Newer content may use types or fields the older code lacks. Content rollback and code rollback are independent; coordinate them explicitly. + +## Large content on Workers + +With `site` and `token` (your [site ID and token](/next/hosted#connect-your-site)), the bundled content module stays in memory as the fallback beside the live release, so a published site holds two copies (a Worker isolate has 128 MB), and the bundle counts toward the script size limit. That only matters at several megabytes of JSON; for sites that large, pass a loader that reads Workers KV as `content` instead, which keeps the fallback in KV; it's a few lines of your own code (see [Example: Workers KV](/next/api-reference#example-workers-kv)). Your deploy must write the content to that key before the new Worker takes traffic (for example with `wrangler kv key put`); if the key is missing, a new isolate's first reads fail until its first release check. + +## Troubleshooting + +To see which release a server is serving, log `await client.revision()` from a fresh client (see [`createCMS`](/next/api-reference#createcms-config)). + +```ts title="cms.ts" +import { createCMS, remoteLoader } from "@decocms/blocks"; +import blocks from "./.deco"; +import content from "./.deco/blocks.gen"; + +const loader = remoteLoader(content, { site: process.env.DECO_SITE, token: process.env.DECO_SITE_TOKEN }); +export const cms = createCMS({ blocks, content: loader }); + +// in a request handler or a debug route: +console.log((await loader.load()).revision); +``` + +| Symptom | What to check | +|---|---| +| Stale or fallback content | Compare the revision your server serves with the revision of the latest release. A server that hasn't fetched a release yet reports its fallback's revision, which matches when the fallback is the content module. If they differ, the server hasn't checked yet: allow preparation time, the manifest cache lifetime and the configured check interval, or call `cms.update()`. An idle Worker checks after a subsequent response. Check that both `site` and `token` are set in production. | +| `UNKNOWN_BLOCK` right after publishing | The content uses a block type the deployed code doesn't have, usually because the content went out before the code that defines the type, or the code was rolled back to a version without it. Deploy the code, or revert the content commit. See [Keep running code compatible](#keep-running-code-compatible). | +| Warning that `interval` was raised | The minimum is 60 000 ms (one minute); lower values, from `interval` or `DECO_CONTENT_INTERVAL`, are raised to it. | diff --git a/docs/content/next/hosted-releases-internals.mdx b/docs/content/next/hosted-releases-internals.mdx new file mode 100644 index 00000000..82c49f80 --- /dev/null +++ b/docs/content/next/hosted-releases-internals.mdx @@ -0,0 +1,53 @@ +--- +title: How hosted releases stay current +nav: Hosted releases internals +group: Under the hood +kind: internals +order: 3 +--- + +# How hosted releases stay current + +With the [hosted Deco CMS](/next/hosted), [`createCMS`](/next/api-reference#createcms-config) with a [site and token](/next/hosted#connect-your-site) wraps your content in [`remoteLoader`](/next/api-reference#loaders), which keeps each server on the latest release without a deploy. [Publishing without a deploy](/next/hosted-publishing) covers what that means for your site. This page shows how it works inside: how a server checks for a release, swaps it in, and stays fast while it does. + +The delivery API serves [prepared assets from storage/CDN](/next/content-delivery); even background checks, cold reads and errors never call GitHub. + +The rule that matters: **a production request never waits for the network.** `load()`, the loader's read method, returns from memory, immediately, every time. Staying current happens beside requests, not in front of them. + +<Small style={{ marginBottom: '6px' }}>Request path · synchronous, never touches the network</Small> + +<Flow label="Request path"> + <FlowNode title="Request">`client.list` or `client.resolve`</FlowNode> + <FlowNode title={<><code>{"load()"}</code>{" from memory"}</>}>Newest fetched release, or the fallback loader's content</FlowNode> + <FlowNode title="Content resolves">Same revision for the whole request</FlowNode> +</Flow> + +<Small style={{ margin: '18px 0 6px' }}>Background path · on first use, then about once a minute, when idle</Small> + +<Flow label="Background refresh"> + <FlowNode title="Ask for the hash">A few bytes from the Deco API</FlowNode> + <FlowNode title="Compare">Same as memory: done. Same as the fallback: use it, no download.</FlowNode> + <FlowNode title="Fetch, verify, swap">Whole revision at once. Any error: keep what's there.</FlowNode> +</Flow> + +1. **Serve from memory.** `load()` returns the newest release this process has fetched, or, if it hasn't fetched one yet, the [fallback](/next/hosted-publishing#fallback)'s content. A freshly started server (a cold start) serves the content the build shipped with on its very first request. +2. **Ask for the hash.** In the background, on first use and then every interval, `update()` fetches a small channel manifest containing the current revision and a monotonically increasing promotion generation from the delivery API. That's a few bytes. The interval is one minute by default; set it with `interval` or the `DECO_CONTENT_INTERVAL` environment variable, never under 60 000 ms. Each check is scheduled one interval, plus or minus up to 10 s, after the previous one (60 s ± 10 s by default; the random part is called jitter), so servers that started together don't check together. A check that's due waits for an idle moment, a gap when the process has nothing else to do: + - `requestIdleCallback` where it exists (browsers, React Native); + - otherwise `scheduler.postTask({ priority: "background" })`; + - otherwise `setTimeout`; + - on Node, an unref'd timer (one that doesn't keep the process alive) plus `setImmediate`, so it never keeps the process running; + - on Cloudflare Workers, which run no timers between requests, after the response inside `ctx.waitUntil` (the Worker's way to finish work after a response is sent), automatically. + Updating is scheduled outside the response path, and a request never waits for it; parsing and refresh work still share CPU and memory with rendering. There's no push signal: servers ask, the API never calls them. +3. **Compare.** Ignore manifests older than the newest observed generation. If the hash equals what's in memory, no content download is needed, but adopt the generation. If it equals the fallback's revision, the content the build shipped with is current: the loader uses it and never downloads it. This is the common case right after a deploy: the [content module](/next/content#the-content-module)'s revision is the CLI's copy of the same hash. A loader you write with its own revision scheme is downloaded once. +4. **Fetch and swap.** Otherwise it fetches that immutable revision asset, checks that the content hashes to it, and swaps it in whole. A release or draft over 64 MB is refused while it downloads, so one oversized publish can't exhaust a server's memory. Before swapping, confirm this is still the newest observed generation; a slower earlier refresh must not undo a publish or rollback. A higher generation can select an older revision. Whether that content fits this build was settled before it merged, not here (see [Backward compatibility](/next/checking#backward-compatibility)). Requests in flight keep the revision they started with. +5. **Fail quietly.** Any network error, or a snapshot that doesn't parse, leaves memory as it was, so a server that loses the API keeps serving the newest release it has. Nothing reaches a request. + +So different servers can briefly serve different releases, by design. A published change reaches each server on its next check plus the manifest cache delay (or after a successful immediate check on the one server where you call `cms.update()`), and each server sends one tiny request per interval, however much traffic it serves. (This is called eventual consistency.) + +[Drafts](/next/hosted-drafts) fetch only a versioned overlay and changed block blobs, waiting for those assets and caching them afterwards. The client captures whatever production content is local at first use and layers replacements and deletion tombstones over it. There is no base-revision field or matching-release fetch. Later clients can inherit newer local production; in-flight clients keep their original pair. Release checks don't depend on whether a server renders drafts: a draft client is a production client reading a draft pointer, so it counts as use like any other, and the check never runs in front of it. A failed overlay makes draft calls return an error. See [Draft overlays for fast previews](/next/content-delivery#exact-draft-previews). + +## One instance per process + +A cache and a poller only help if there's one of each. `createCMS` and `remoteLoader` store their instances on `globalThis`, under keys made with `Symbol.for("decocms.blocks…")`. `Symbol.for` returns the same symbol for the same string anywhere in the process, so two copies of the package, from two bundles or from a dev reload (hot module replacement, HMR), find each other's instances. Without it, each copy would keep its own cache and run its own poller, doubling the memory and the checks. The key is derived from the configuration (the site ID, the token and the content's identity: for the content module, the `.deco` folder it was generated from; for a loader you write, the loader object. Never the revision, so a hot reload that hands in new content keeps the same instance), so several sites in one app get separate instances. A second call with the same key but different options, such as another `interval`, keeps the first instance and logs a warning that names the conflicting options, so there is still only one cache and one poller and the mismatch is visible. Each call gets its own handle on the instance, holding the block map it passed, so a second bundle in the process (Next.js's `proxy.ts`) shares the cache and poller without replacing the app's block map. `resetForTests()` clears every stored instance. + +A loader you write that has `update()` is checked the same way: the CMS calls its `update()` on this idle schedule and `interval`. To connect a site, see [Connect your site](/next/hosted#connect-your-site); for the interval, [Loaders](/next/api-reference#loaders); for how telemetry behaves with several instances, [Several CMS instances](/next/telemetry-internals#several-cms-instances); for what a release is, [How a commit becomes a release](/next/hosted-publishing#how-a-commit-becomes-a-release). diff --git a/docs/content/next/hosted-site-editor.mdx b/docs/content/next/hosted-site-editor.mdx new file mode 100644 index 00000000..405707eb --- /dev/null +++ b/docs/content/next/hosted-site-editor.mdx @@ -0,0 +1,64 @@ +--- +title: Editing in the site editor on GitHub +nav: Site editor on GitHub +group: Hosted Deco CMS +order: 28 +description: "With the hosted Deco CMS, the site editor edits your GitHub repository directly, with nothing to clone or install: saves are commits on a draft branch, and publishing brings them to production." +--- + +# Editing in the site editor on GitHub + +A merchandiser needs to change the homepage hero before tomorrow's sale. They don't have Git, a copy of the code or a dev server, and they shouldn't need one. + +On your machine, [the site editor](/next/site-editor) edits your working tree through `deco serve`, and you commit the changes yourself (see [Edit on your machine](/next/site-editor#edit-on-your-machine)). With the [hosted Deco CMS](/next/hosted), the site editor's **GitHub backend** edits your repository itself. + +This page shows how the site editor edits your repository on GitHub, where uploads go, and how publishing and CI work. + +## What changes + +The site editor's forms, [variants](/next/matchers-and-variants#variants), pages and redirects are the same on both backends: they need only the [schema](/next/schema#from-types-to-forms) and the files. What differs is where a save goes and what happens next. + +| | `deco serve` | The site editor's GitHub backend | +|---|---|---| +| Edits | The files in your working tree | Your repository on GitHub | +| Where `.deco/` is | The folder you pass with [`--root`](/next/cli#finding-the-deco-folder), or the nearest one with a `.deco/`; `deco serve` tells the site editor | The site editor's "app root" setting, such as `apps/storefront` in a monorepo | +| A save | Writes the file; your app hot-reloads | One commit on the draft's branch, with all the files of that save | +| Committing | You review the diff and commit | Each save is already a commit | +| Drafts and publishing | Not shown: your branch is the draft, and you merge it | A draft branch per draft; publishing brings it to production | +| Uploads | Files in `public/assets/` (or the folder you pass with `--assets`) in your working tree, which you commit | Deco's asset storage, served from a CDN | +| Preview canvas | Your dev app | Your site, with a [`?__draft=` link](/next/hosted-drafts) | +| The site editor checks for changes | About every 2 seconds | When you return to the tab, and about every 30 seconds | +| Needs | A copy of the repository on your machine and a running CLI | A [connected site](/next/hosted#connect-your-site) | + +## Uploads + +With the hosted Deco CMS, images and other files editors upload in the site editor go to Deco's asset storage, not your repository. The site editor saves each file's CDN address in the field, such as `https://assets.decocms.com/…/summer-banner.jpg`, so images are served from a CDN close to your visitors, your app serves nothing for them, and your repository doesn't grow with every image. Without the hosted Deco CMS, uploads are files in your repository (see [Images and other uploads](/next/site-editor#images-and-other-uploads)). + +Uploads already in your repository keep working: a field holds either kind of address, and your app keeps serving the files it has. + +## Drafts are branches + +The editor works with a draft; they never need to create a branch, merge or rebase. The backend manages the branch automatically. An editor's changes live on a **draft branch** in your repository, created on the first save. Each save is one commit on it, so the draft's history is ordinary Git history: you can review it in a pull request, diff it, or check it out. Saving is last-writer-wins: if two people edit the same entry at once, the later save replaces the earlier one (see [Where edits go](/next/content-protocol#where-edits-go)). + +When new content lands on production, open drafts incorporate it automatically; inactive drafts catch up when reopened. Untouched files follow production. For any file edited or deleted in the draft, the draft wins the whole file, even if production changed different fields in it. There is no property-level merging. The backend also synchronizes before saves and publishing. See [Keeping drafts current](/next/draft-synchronization). + +While the draft is unpublished, the site editor previews it on your real site; see [Previewing drafts](/next/hosted-drafts). + +## Publishing + +Publishing reconciles the draft with current production and writes its content-only changes to your production branch (usually `main`), respecting required checks. The delivery pipeline prepares an immutable release and promotes it only when ready. Servers adopt it on their next background check (see [Publishing without a deploy](/next/hosted-publishing)); saving, preparing and promoting are separate statuses. + +The site editor reads the schema committed on the branch it edits, so editors only see the block types and fields that branch's code has. Deploy code that adds a type before publishing content that uses it (see [Keep running code compatible](/next/hosted-publishing#keep-running-code-compatible)). + +## CI on site editor commits + +The site editor's saves are commits that only change content, and the [check workflow](/next/checking#backward-compatibility) validates them like any other change. Saves always land on a draft branch. When publishing opens a pull request, the check runs on it before merge. When publishing brings the draft straight to your production branch, it's live as soon as it lands, so also run the check on pushes to `main`: + +```yaml title=".github/workflows/check.yml (changes)" +on: + pull_request: + push: + branches: [main] +``` + +How the site editor reads and writes your files over the protocol is in [Content protocol](/next/content-protocol), and its compatibility details in [Site editor compatibility](/next/studio-compatibility). diff --git a/docs/content/next/hosted-telemetry.mdx b/docs/content/next/hosted-telemetry.mdx new file mode 100644 index 00000000..8b248a29 --- /dev/null +++ b/docs/content/next/hosted-telemetry.mdx @@ -0,0 +1,39 @@ +--- +title: Hosted telemetry +nav: Hosted telemetry +group: Hosted Deco CMS +order: 31 +description: Send your site's telemetry to the hosted Deco CMS collector with a site ID and token, with no collector to run. +--- + +# Hosted telemetry + +You want upstream latency and error rates for your site, but you don't want to run a collector or a dashboard. The hosted Deco CMS runs one for you. + +[Telemetry](/next/telemetry) is open source and can go to any OpenTelemetry collector. This page shows how to send it to the hosted Deco CMS instead, and what you get there. Page views are [analytics](/next/analytics), which is separate: by default, analytics sends to the hosted collector. + +## Turn it on + +Pass your site's ID and token as the `telemetry` option of [`createCMS`](/next/api-reference#createcms-config): + +```ts title="cms.ts" +const { DECO_SITE: site, DECO_SITE_TOKEN: token } = process.env; + +export const cms = createCMS({ + blocks, + content, + site, // loads releases and drafts + token, + telemetry: site && token ? { site, token } : false, // telemetry is separate: site/token above only load releases +}); +``` + +They're the same values that [connect your site](/next/hosted#connect-your-site) for releases and drafts, but telemetry is passed explicitly: the top-level `site` and `token` alone never send it. The `site && token` check keeps telemetry off where the variables aren't set, such as on your machine. + +## What you get + +The hosted Deco CMS collects and shows, for your site, the [measurements](/next/telemetry#whats-sent) your site sends: upstream latency, error rates and cache hits per provider, sampled error logs, and traces when you turn them on. Inbound requests and page caches come from your framework's own OpenTelemetry setup, not from Deco CMS. + +## Settings + +What's sent and how often is the same as with any collector: the `telemetry` section of the [`CMS` block](/next/telemetry#telemetry-settings-are-content) holds the switches and sample rates, and [`limits`](/next/telemetry#sampling) in code cap them. diff --git a/docs/content/next/hosted.mdx b/docs/content/next/hosted.mdx new file mode 100644 index 00000000..3289362c --- /dev/null +++ b/docs/content/next/hosted.mdx @@ -0,0 +1,117 @@ +--- +title: The hosted Deco CMS +nav: Overview +group: Hosted Deco CMS +order: 27 +description: What the hosted Deco CMS adds on top of the open-source Deco CMS, what it doesn't do, and how to connect a site to it with a site ID and token. +--- + +# The hosted Deco CMS + +Your editors want a fix live without waiting for an application build, and they want to edit without cloning the repository. That's what the hosted Deco CMS is for: an optional service Deco runs on top of the same files. It changes how fast content reaches visitors and where editors work, not how your code is written. + +[Deco CMS](/next/how-it-works) is complete without it: your functions, your content files, your deploys and your own telemetry collector are all you need. This page shows what the hosted Deco CMS adds, what it doesn't do, and how to connect your site. + +## What it adds + +Deco runs these services for a connected site: + +| Service | What it changes | Without it | +|---|---|---| +| **Site editor on GitHub** | Editors use [the site editor](/next/site-editor) on your GitHub repository, with nothing to clone or install: each save is a commit on a draft branch, and publishing brings it to production. See [Editing in the site editor on GitHub](/next/hosted-site-editor). | The site editor on your machine, and you commit the changes ([Edit on your machine](/next/site-editor#edit-on-your-machine)). | +| **Drafts** | The Deco API serves an editor's draft to your real site, so the site editor previews it in place. See [Previewing drafts](/next/hosted-drafts). | Drafts are branches you preview with your own preview deploys ([Releases and drafts](/next/releases-and-drafts#on-a-branch)). | +| **Publishing** | The control plane prepares production commits as immutable **releases**, and a storage/CDN delivery plane serves the promoted release without a deploy. Servers refresh in the background. See [Publishing without a deploy](/next/hosted-publishing). | Content ships with your next deploy ([Deployment](/next/releases-and-deployment#publishing)). | +| **Asset storage** | Images and other files editors upload go to Deco's storage and are served from a CDN. See [Uploads](/next/hosted-site-editor#uploads). | Uploads are files in your repository ([Images and other uploads](/next/site-editor#images-and-other-uploads)). | +| **Telemetry collector** | A hosted OpenTelemetry collector: set `telemetry: { site, token }` and view latency, error rates and traces with nothing to run. See [Hosted telemetry](/next/hosted-telemetry). | Send telemetry to your own OpenTelemetry collector, or nothing ([Telemetry](/next/telemetry)). | +| **Analytics collector** | [Analytics](/next/analytics)' default `collector`: view page views with nothing to run. | Send page views to One Dollar Stats or your own collector ([Analytics](/next/analytics)). | + +## What stays the same + +- **Your repository is the authoring source of truth.** Content is still the JSON files in `.deco/blocks`, and every edit is still a commit. The delivery channel selects what visitors receive; a fast content rollback can temporarily select an older release without reverting Git. +- **Your code doesn't change.** Block functions, the [block map](/next/blocks#the-block-map), [`resolve`](/next/api-reference#client-resolve-target-options), [`list`](/next/api-reference#client-list-type-options) and [routing](/next/routing) work the same way. The same [`createCMS`](/next/api-reference#createcms-config) call takes two more options, `site` and `token`. +- **The content module stays.** Your build still bundles the [content module](/next/content#the-content-module), `.deco/blocks.gen.ts`. It's what a server serves before it has fetched its first release, and what it keeps serving if the Deco API is unreachable. + +## What it doesn't do + +- **It doesn't run your app.** Your app runs where you deploy it: Node, Cloudflare Workers or any other host you choose. +- **It doesn't run your code.** The Deco API serves content files; your servers call your functions. The site editor never runs your code either (see [What works without your code](/next/site-editor#what-works-without-your-code)). +- **It doesn't build or deploy.** Code still ships through your own pipeline; only content can skip the deploy. + +## Connect your site + +A site is connected by two values: its **site ID** in the hosted Deco CMS and its **site token**. Read them from environment variables, by convention `DECO_SITE` and `DECO_SITE_TOKEN`, and pass them to `createCMS` next to your content: + +```ts title="cms.ts" +import { createCMS } from "@decocms/blocks"; +import blocks from "./.deco"; +import content from "./.deco/blocks.gen"; + +const site = process.env.DECO_SITE; // your site's ID in the hosted Deco CMS +const token = process.env.DECO_SITE_TOKEN; // your site token (secret) + +export const cms = createCMS({ + blocks, + content, + site, + token, + // Optional: also send telemetry to the hosted collector + telemetry: site && token ? { site, token } : false, +}); +``` + +The site ID isn't a secret; the token is, so keep it out of the repository. When either value is undefined, in development or in tests for example, the CMS reads `content` only, exactly like `createCMS({ blocks, content })`. So the same lines work everywhere, and a site keeps working if you disconnect it. + +**Local files win in development.** In your dev server, even with both values set (in `.env.local` or `.dev.vars`), ordinary requests read your local `.deco/blocks`, not the published release, so your edits and the site editor's saves through [`deco serve`](/next/site-editor#edit-on-your-machine) show up right away. A `?__draft=` link still loads that draft from the Deco API. + +`site` and `token` load releases and drafts, nothing else. Telemetry is separate and opt-in: it goes only where the [`telemetry` option](/next/telemetry#choose-where-telemetry-goes) points, so pass the same two values there to use the hosted collector, or point it at your own collector instead. Drafts need a few lines of wiring in your app; see [Wire drafts into your app](/next/hosted-drafts#wire-drafts-into-your-app). How often servers check for a release is the `interval` option of `createCMS`; see [Check interval](/next/hosted-publishing#check-interval). + +### Next.js + +Set `DECO_SITE` and `DECO_SITE_TOKEN` in your host's environment (and in `.env.local`, which you keep out of Git, to try it locally). Create the CMS in a module that starts with `import "server-only"`, or import it only from one that does, as the [Next.js guide](/next/nextjs) does: the build then fails if a Client Component imports it, which keeps the token out of the browser. + +### Cloudflare Workers + +On Workers, environment variables come from `env` in `cloudflare:workers`: + +```ts title="src/cms.ts" +import { createCMS } from "@decocms/blocks"; +import { env } from "cloudflare:workers"; +import blocks from "../.deco"; +import content from "../.deco/blocks.gen"; + +export const cms = createCMS({ + blocks, + content, + site: env.DECO_SITE, + token: env.DECO_SITE_TOKEN, + telemetry: { site: env.DECO_SITE, token: env.DECO_SITE_TOKEN }, // optional: the hosted collector +}); +``` + +Locally, `wrangler dev` reads them from a `.dev.vars` file you keep out of Git. In production, set the site ID as a plain variable in `wrangler.jsonc` (`vars`) and the token as a secret: + +```bash +npx wrangler secret put DECO_SITE_TOKEN +``` + +Release checks on Workers run after the response, inside `ctx.waitUntil`, on their own; see [How a commit becomes a release](/next/hosted-publishing#how-a-commit-becomes-a-release). Sites with several megabytes of content should read [Large content on Workers](/next/hosted-publishing#large-content-on-workers). + +## Terms + +<Terms> + <Term name="Site ID and site token">The two values that connect your app to the hosted Deco CMS, usually the `DECO_SITE` and `DECO_SITE_TOKEN` environment variables, passed to `createCMS` as `site` and `token`. They load releases and drafts. Passed under `telemetry` too, they send telemetry to the hosted collector. Only the token is secret.</Term> + <Term name="Deco API">The delivery service that serves prepared release and draft assets from storage/CDN. It never reads GitHub in response to a production content request.</Term> + <Term name="Hosted collector">The hosted Deco CMS's endpoint for telemetry, used when `telemetry` is `{ site, token }` (see [Hosted telemetry](/next/hosted-telemetry)), and for page views, [analytics](/next/analytics)' default `collector`.</Term> + <Term name="Release">A [release](/next/releases-and-drafts#releases) as the Deco API serves it: the content of one commit to your production branch, live without a deploy. See [Publishing without a deploy](/next/hosted-publishing).</Term> + <Term name="Draft pointer">The string the site editor puts in a `?__draft=` link: where a draft lives on the Deco API, with a token the site editor signed, and which version. [`cms.forDraft(pointer)`](/next/api-reference#draft-pointers) reads it. See [Previewing drafts](/next/hosted-drafts).</Term> + <Term name="Draft branch">The branch the site editor's GitHub backend commits an editor's saves to, created on the first save. Publishing brings it to your production branch. See [Editing in the site editor on GitHub](/next/hosted-site-editor).</Term> +</Terms> + +## On these pages + +- [Site editor on GitHub](/next/hosted-site-editor): draft branches, uploads in hosted storage, publishing, and CI on the site editor's commits. +- [Previewing drafts](/next/hosted-drafts): the `?__draft=` link, wiring drafts into your app, and who may preview. +- [Publishing without a deploy](/next/hosted-publishing): how a commit becomes a release, how servers pick it up, the fallback, and keeping running code compatible with new content. +- [Production delivery and rollback](/next/content-delivery): immutable assets, channel promotion, outage behavior and restoring an older release. +- [Keeping drafts current](/next/draft-synchronization): automatic production updates, conflict resolution and bounded merge memory. +- [Hosted telemetry](/next/hosted-telemetry): turning on the hosted collector for telemetry. diff --git a/docs/content/next/how-it-works.mdx b/docs/content/next/how-it-works.mdx new file mode 100644 index 00000000..1688f5fa --- /dev/null +++ b/docs/content/next/how-it-works.mdx @@ -0,0 +1,104 @@ +--- +title: How it works +nav: How it works +group: Getting started +kind: docs +order: 1 +--- + +# How it works + +<Callout>**Status.** The next version of Deco is being designed in the open: the API on these pages is proposed and not released yet. The [Roadmap](/roadmap) lists what's left.</Callout> + +Most of what changes on a website isn't code. It's the values the code is called with: a headline, a banner image, the share of visitors who see the new checkout. **Deco CMS** is a Git-based CMS with a full suite of open-source libraries, CLIs and services that let you write your code so that marketers can tweak and evolve those values in a rich site editor. + +Deco CMS has a built-in way to handle [releases and drafts](/next/releases-and-drafts), [campaign scheduling](/next/matchers-and-variants), [telemetry](/next/telemetry) and [analytics](/next/analytics). A [CLI](/next/cli) supports each of them, and the [site editor](/next/site-editor) has them built in, whether you run it on your machine or use the [hosted Deco CMS](/next/hosted). + +Deco CMS is powered by a syntax called [Blocks](/next/blocks). Blocks serializes function calls as JSON, so the way your code calls its functions becomes editable. For example, the call `PromoBanner({ title: "Free shipping over $50" })` is saved as `{ "__resolveType": "promo-banner", "title": "Free shipping over $50" }`, where `promo-banner` is the name your site gives `PromoBanner`. + +This page follows one change, marketing switching the summer banner from "Free shipping over $75" to "Free shipping over $50", from the first line of code to a visitor's screen. + +## The life of a change + +<Flow label="The life of a change"> + <FlowNode title="1. Write">A developer writes `PromoBanner`</FlowNode> + <FlowNode title="2. Schema">Its types become a form</FlowNode> + <FlowNode title="3. Edit">Someone changes the text</FlowNode> + <FlowNode title="4. Ship">The change goes out with your next deploy</FlowNode> + <FlowNode title="5. Render">Your app shows the new banner</FlowNode> +</Flow> + +### 1. A developer writes the function + +The banner is an ordinary component, `PromoBanner`, whose props are typed: a `title` and an `href`. The developer adds it to the [block map](/next/blocks#the-block-map), which gives each function a name content can call, such as `"promo-banner"`. That's the only Deco-specific code you write. + +### 2. The types become a form + +The `deco schema` command reads those prop types and writes a schema file, `.deco/schema.gen.json`. The site editor builds a form from that file, so `title` becomes a text box without anyone designing a form. See [Forms from types](/next/schema). + +### 3. Someone changes the content + +The banner's text lives in a [saved block](/next/saved-blocks): a JSON file in your repository, `.deco/blocks/SummerBanner.json`, that calls `promo-banner` with a `title` of "Free shipping over $75". Edit the file by hand, ask an AI agent, or use [the site editor](/next/site-editor). Either way, the change is a commit like any other. + +### 4. The change ships + +Your usual CI builds and deploys the app, and the new text goes live with it, the same way a code change does. See [Deployment](/next/releases-and-deployment). + +<Hosted to="/next/hosted-publishing" label="Publishing without a deploy">**No deploy needed.** With the hosted Deco CMS, a prepared release reaches your servers on their next background check, with no new application build or deploy.</Hosted> + +### 5. Your app renders it + +Your app asks the SDK for `SummerBanner`. It asks through a `client` it gets from `cms.forRelease()`, where `cms` is the object your app creates once with [`createCMS`](/next/content#create-the-cms) (walked through in the [Quickstart](/next/quickstart#5-read-the-content-from-your-code); how content reaches it is in [Content and loaders](/next/content)). The SDK calls `PromoBanner` with the saved props and hands back the result, which you render like any other component. See [Rendering](/next/rendering) for the two ways that result reaches the browser. + +## Small on purpose + +- **Content is plain JSON.** Read it, diff it, or generate it with a script. There's no database to host. +- **Block functions are plain functions.** You can call any of them directly in a test. +- **Data fetching is your code.** See [Calling APIs](/next/upstream-clients). +- **Nothing is hidden.** The formats are documented in [Blocks](/next/blocks) and [Internals](/next/internals). + +## Coming from v7 + +| v7 | Next major | +|---|---| +| decofile | Your saved blocks, in `.deco/blocks` | +| Section | Block function that returns JSX | +| Loader (data) | Your code over an [upstream client](/next/upstream-clients) | +| Admin protocol (`/live/_meta`, `/.decofile`) | [Content protocol](/next/content-protocol) | +| `/deco/invoke` | Your server functions or route handlers | +| `meta.gen.json` | `.deco/schema.gen.json` (same format) | + +For the full move, see [Migrating from v7](/next/renames-and-migrations). + +## Key terms + +<Terms> + <Term name="Deco CMS">The whole system: Blocks plus the libraries, CLIs and services built on it (forms, checks, the site editor, matchers and variants, content and releases, telemetry and analytics).</Term> + <Term name="Blocks">The syntax for writing a function call as JSON. It's a format, not a product. See [Blocks](/next/blocks).</Term> + <Term name="Block">One function call written as JSON: `__resolveType` names the function, the other keys are its arguments. See [Functions as blocks](/next/blocks#functions-as-blocks).</Term> + <Term name="Block function">One of your functions with a typed first parameter, such as `PromoBanner`. See [Functions as blocks](/next/blocks#functions-as-blocks).</Term> + <Term name="Block type">A name in the block map, such as `"promo-banner"`, that a block's `__resolveType` uses to call a function. See [The block map](/next/blocks#the-block-map).</Term> + <Term name="Block map">An object from names to your block functions, such as `{ "promo-banner": PromoBanner }`, in `.deco/index.ts`. See [The block map](/next/blocks#the-block-map).</Term> + <Term name="Saved block">A block stored under a name, as one JSON file in `.deco/blocks`, such as `SummerBanner.json`. See [Saved blocks](/next/saved-blocks).</Term> + <Term name="Built-in block">One of ten functions Deco CMS adds to every block map, such as `page`, `redirect` and `multivariate`. See [Built-in blocks](/next/built-in-blocks).</Term> + <Term name="Lazy block">A `lazy` block: a value that's resolved only when your code asks for it. See [Lazy blocks](/next/lazy-blocks).</Term> + <Term name="Matcher">A block function that returns a `boolean`, such as `date`, used as the rule that picks a variant. See [Matchers](/next/matchers-and-variants#matchers).</Term> + <Term name="Variant">The alternate content for a field, the `value` next to a `rule`. See [Variants](/next/matchers-and-variants#variants).</Term> + <Term name="Schema">`.deco/schema.gen.json`, the file `deco schema` generates from your types. See [Forms from types](/next/schema).</Term> + <Term name="Site editor">Deco's content editor, part of Deco Studio. See [Site editor](/next/site-editor).</Term> + <Term name="Release">The content everyone sees right now, read with `cms.forRelease()`. See [Releases](/next/releases-and-drafts#releases).</Term> + <Term name="Draft">Another version of the content, read with `cms.forDraft(pointer)`. See [Drafts](/next/releases-and-drafts#drafts).</Term> + <Term name="Revision">One exact version of the content, such as one commit. See [What a revision is](/next/releases-and-deployment#what-a-revision-is).</Term> + <Term name="Resolve">What `client.resolve` does: read a saved block and call its function with the saved inputs. See [How resolution works](/next/how-resolution-works).</Term> + <Term name="Telemetry">Errors, metrics and traces from your servers, sent over OpenTelemetry. See [Telemetry](/next/telemetry).</Term> + <Term name="Analytics">Page views from the browser, without cookies, compatible with One Dollar Stats. Separate from telemetry; its settings are a section of the `CMS` block. See [Analytics](/next/analytics).</Term> + <Term name="The hosted Deco CMS">The optional service Deco runs on the same files: publishing without a deploy, drafts on your real site, the site editor on GitHub, asset storage, and collectors for telemetry and analytics. See [The hosted Deco CMS](/next/hosted).</Term> +</Terms> + +`@decocms` is just the package scope, the company's name on npm. + +### Other terms + +<Terms> + <Term name="Platform template">A starter site per commerce platform (VTEX, Shopify, …) that you copy and then own.</Term> +</Terms> diff --git a/docs/content/next/how-resolution-works.mdx b/docs/content/next/how-resolution-works.mdx new file mode 100644 index 00000000..940c96a8 --- /dev/null +++ b/docs/content/next/how-resolution-works.mdx @@ -0,0 +1,30 @@ +--- +title: How resolution works +nav: How resolution works +group: Under the hood +kind: internals +order: 2 +--- + +# How resolution works + +Here's the setup. Your [block map](/next/blocks#the-block-map) registers two functions, `product-card` and `catalog-product`. Two blocks are [saved](/next/saved-blocks): `CurrentProduct`, which is `{ "__resolveType": "catalog-product", "slug": "summer-shirt" }`, and `SummerCard`, shown in step 1, which uses it ([`__resolveType`](/next/blocks#functions-as-blocks) names the function a block calls). As code, `SummerCard` means `productCard({ title: "Summer collection", product: catalogProduct({ slug: "summer-shirt" }) })`. + +This page shows, step by step, how the SDK evaluates that block. Step through [`client.resolve("SummerCard")`](/next/api-reference#client-resolve-target-options) to watch [the lookup rule](/next/blocks#the-lookup-rule) at work, or switch to `{ run: false }` to see what a read returns. `product-card` can return a [descriptor](/next/rendering#which-mode-to-use) or a React element (for frameworks that render React Server Components, such as the Next.js App Router); the second toggle compares them. This is an illustration, so it doesn't run any application code. + +<Walkthrough /> + +## The rule in full + +[Blocks](/next/blocks#the-lookup-rule) gives the rule in one paragraph. These are the details behind it: + +- **One namespace.** [Saved blocks](/next/saved-blocks), [built-in blocks](/next/built-in-blocks) and your block map share one registry, `{ ...savedBlocks, ...builtIns, ...blocks }`, so names should be unique. When a saved block has the same name as a block type, the function wins; [`deco check`](/next/checking#what-deco-check-checks) reports the collision. +- **Inputs resolve concurrently.** A function's inputs resolve before it runs, children before parents, and sibling blocks at the same time. +- **One exception: `lazy`.** A [lazy block](/next/lazy-blocks) is the only block whose input doesn't resolve first. The function gets `() => Promise<T>` instead, which resolves `value` the first time it's called and returns the same result after that. If it's never called, nothing inside it runs. If its value fails, calling the function rejects with that error; the function that called it is already running, so it handles the error itself. +- **Failures stop the parent.** If a nested block fails (outside a `lazy` block), its parent never runs and `resolve` returns the error, much as an outer call never runs when an inner one throws. The error's `path` says where in the tree it happened. +- **Overrides merge, then look up again.** A reference with extra arguments expands to `{ ...saved, ...arguments }` (see [Override arguments](/next/saved-blocks#override-arguments)), and the merged block goes through the rule again. +- **A name with no saved block fails.** `client.resolve("Name")` fails with [`NOT_FOUND`](/next/api-reference#errors) when nothing is saved under that name; a string is always a saved block's name, never a block type. +- **Results are returned as is.** The CMS never walks a function's return value. This is also why a function that returns its input, like `seo`, can't loop: `__resolveType` is stripped before the call, and whatever comes back is never looked up again. +- **Only data can loop.** A saved-entry expansion is the one branch that applies the rule again, so two entries that reference each other, or an entry that references itself, would recurse forever. The CMS tracks the entries expanded on the current path and fails with `CYCLE` the moment one repeats, with the chain in `error.path`. +- **Block functions should only read.** They run whenever content is resolved: for a visitor, in a preview, or when an agent checks its edit. Put side effects in your own route handlers or server functions (see [Call a client](/next/upstream-clients#call-a-client)). +- **Reading without running.** `client.resolve(target, { run: false })` stops after expansion: saved blocks are replaced and merged, and no function runs. It's useful for previews, tooling and debugging, and it's what [`client.list`](/next/api-reference#client-list-type-options) returns by default. diff --git a/docs/content/next/internals.mdx b/docs/content/next/internals.mdx new file mode 100644 index 00000000..ce3ea4cb --- /dev/null +++ b/docs/content/next/internals.mdx @@ -0,0 +1,88 @@ +--- +title: How Deco works inside +nav: Overview +group: Under the hood +kind: internals +order: 1 +--- + +# How Deco works inside + +The [developer docs](/next/how-it-works) describe how to use Deco CMS. This part describes how it works inside. It's for contributors, for anyone connecting a new framework or content store, and for the curious: + +- [How resolution works](/next/how-resolution-works): one saved block, expanded and run step by step. +- [How hosted releases stay current](/next/hosted-releases-internals): with the [hosted Deco CMS](/next/hosted), new releases arrive without a request ever waiting on the network. +- [How telemetry is sent](/next/telemetry-internals): the wire format, why a slow collector never slows a page, and what's scrubbed before anything leaves. +- [How `matchRoute` matches](/next/router-internals): the data structure that maps a URL to an entry. +- [Content protocol](/next/content-protocol): the four methods [the site editor](/next/site-editor) reads and writes content through, and the two backends that serve them. +- [Site editor compatibility](/next/studio-compatibility): what the site editor reads from the schema, the alias table, and the legacy endpoints older sites serve. +- [Design decisions](/next/design-decisions): the choices behind all of the above, and why they won. +- [Snapshots and revisions](#snapshots-and-revisions): how the content module holds your saved blocks, and how a revision names one version of them. +- [How the CLI ships](#how-the-cli-ships): why the `deco` command lives inside `@decocms/blocks`, and why it never ends up in your app's bundle. + +## Snapshots and revisions + +The [content module](/next/content#the-content-module)'s default export, `{ revision, blocks }`, is a **snapshot**: one fixed copy of your content map, the saved blocks by name. Each client reads one fixed effective content view. Releases use complete snapshots; hosted drafts layer immutable changes and tombstones over the production snapshot already local to that client (see [Draft overlays](/next/content-delivery#exact-draft-previews)). Unchanged objects are shared, not downloaded again. The field is called `blocks` after the [Blocks](/next/blocks) format, which the site editor also reads and writes, but it holds [saved blocks](/next/saved-blocks): any JSON, never functions. Functions only ever come from your [block map](/next/blocks#the-block-map). + +A release revision names that exact set of entries, like a version number. The CMS and your app use it to tell when content changed, to keep cached data from different versions apart, and to tell one deploy's content from the next. Any change to any entry produces a new revision, and a revision is never reused for different content. [`deco content`](/next/cli#deco-schema-and-deco-content) computes it as a hash of the whole map, so two copies of the same content get the same revision wherever they're built; [Deployment](/next/releases-and-deployment#one-revision-per-response) shows what that buys you. + +Hosted draft clients use an opaque composite identity of the local production revision and overlay version instead of hashing the whole merged map. The same overlay can yield different effective content over different local releases; only the changes, not the inherited base, are fixed by a draft pointer. + +To read an entry exactly as stored, with references in place, index the map: `content.blocks["PromoBanner"]`. + +## How the CLI ships + +The `deco` command ships inside `@decocms/blocks` rather than in a package of its own. That means one install, and the CLI's version can never drift from the runtime's: the schema and content module it generates always match the SDK that reads them. + +- **The bin.** The package declares a single bin, `deco`. That's why `npx @decocms/blocks schema` works with nothing installed, and why scripts can say `deco schema` once it's installed. The bin is a small JavaScript file that loads the CLI's TypeScript sources, so it runs under plain Node as well as Bun. +- **Programmatic use.** The same commands are importable from the `@decocms/blocks/cli` subpath. +- **Nothing in your bundle.** `typescript` is a peer dependency, which npm and Bun install for you, and the CLI loads it only when a command runs. Bundlers only include what your app imports, and apps never import `@decocms/blocks/cli`, so the CLI and the TypeScript compiler never reach an app bundle. The runtime never imports the `cli` subpath either. + +## How `deco check` works + +[`deco check`](/next/checking) validates your saved blocks with a JSON Schema validator, against `.deco/schema.gen.json` as it is on disk, the file `deco schema` writes. It doesn't generate a schema of its own, so run `deco schema` first. It doesn't type-check generated TypeScript with `tsc`, for three reasons: + +- **Speed.** Validating thousands of JSON files takes milliseconds, and no TypeScript is loaded at all. `tsc` would have to load your whole app first. +- **Messages that point at the data.** Errors are grouped by file: a line with the file, then one indented line per field, not a line in a generated file: + + ```text + .deco/blocks/HomePage.json + sections[2].title: required + ``` +- **One contract.** The schema also carries what types can't express, such as `@maxLength` or `@format date`, and it's what the site editor enforces. What passes `deco check` is exactly what the site editor allows. + +Three things make the messages useful: + +1. **Dispatch on [`__resolveType`](/next/blocks#functions-as-blocks).** A field that takes any block of a type, like `product: Product` or `sections: ReactNode[]`, is an `anyOf` in the schema, and plain `anyOf` validation reports every branch that didn't match. The check reads `__resolveType` first and validates against that one block type's schema, so an unknown name is a single error: `unknown block type "promo-banner"`. +2. **References by return type.** A reference to a saved block is checked against the return type of the function behind it, the same rule the site editor uses to offer blocks for a field. +3. **Plain wording.** Validator errors are rewritten into short lines: `title: required`, `title: 214 characters, max 60`, `size: "xl" isn't one of "sm", "md", "lg"`. + +The check is only as accurate as the schema, so `deco schema`'s translation of types into schemas is tested on its own. Your usual `tsc` run still checks the code itself. + +## Contributing + +The framework lives in one repository, `decocms/blocks`, as a Bun workspace (Bun is the package manager and test runner). The framework is one package, `@decocms/blocks`; the `apps-*` folders under `packages/` are [upstream clients](/next/upstream-clients) for commerce and other services, which depend only on it. The v7 to v8 migration is an agent skill in `.agents/skills` (see [Migrating from v7](/next/renames-and-migrations#run-the-migration)). The [content protocol](/next/content-protocol) lives inside `@decocms/blocks`, as its `protocol` subpath; the `cli` subpath uses it, and the runtime never imports it. Packages depend on each other in one direction only: + +```text +packages/ +├── blocks/ @decocms/blocks the SDK: loaders, client, resolution, router, +│ plus the deco CLI (the cli subpath) and the content +│ protocol the site editor and deco serve speak +│ (the protocol subpath). +│ depends on no other @decocms package +└── apps-*/ @decocms/apps-{vtex,shopify,wake,magento,algolia,resend,sfmc-personalization} + thin upstream clients. + depends on: @decocms/blocks +``` + +The framework used to be a single bundled package, and bundling one package into another can create two copies of a module that must exist once, such as the block registry; two registries in one process disagree in ways that are hard to find. So packages import each other's TypeScript source, never a bundled build. The SDK also guards against this at runtime: [`createCMS`](/next/api-reference#createcms-config) and [`remoteLoader`](/next/api-reference#loaders) keep their instances on `globalThis`, so a second copy reuses the first (see [One instance per process](/next/api-reference#one-instance-per-process)). `blocks` never imports from an `apps-*` package; if a feature needs both sides, split it into two functions rather than import in reverse. + +Apps following these docs need only `@decocms/blocks`, which includes the CLI (see [How the CLI ships](#how-the-cli-ships)). The scripts that move sites off Fresh and Deno are v7 tooling (see [Migrate from Fresh](/v7/migrate-from-fresh)). The next major has no per-framework packages. v7's `@decocms/blocks-admin`, `@decocms/tanstack` and `@decocms/nextjs` stay on the 7.x line, serving v7 sites on the site editor's legacy endpoints; next-major apps don't depend on them. What depended on the platform (caching upstream responses, loading content from Workers KV) is a few lines in your template (see [Upstream data](/next/caching#upstream-data) and [Example: Workers KV](/next/api-reference#example-workers-kv)), and the site editor reaches next-major apps through the content protocol. + +```bash +bun install +bun run check # typecheck + lint + unused code +bun run test # vitest, whole repo +``` + +Two rules before your first PR. If a site has to copy or wrap code because a package doesn't export something it needs, add the export to the package. And some tests guard bugs that already reached production, such as the test that keeps two copies of the package from creating two CMS instances; if one fails, fix the code, not the assertion. diff --git a/docs/content/next/lazy-blocks.mdx b/docs/content/next/lazy-blocks.mdx new file mode 100644 index 00000000..04a082b7 --- /dev/null +++ b/docs/content/next/lazy-blocks.mdx @@ -0,0 +1,62 @@ +--- +title: Lazy blocks +nav: Lazy blocks +group: Built on Blocks +kind: docs +order: 11 +description: Every argument runs before its function, except a lazy block, which runs only when the function calls it. Ask for one by typing a prop as Lazy<T>. +--- + +# Lazy blocks + +On [Matchers and variants](/next/matchers-and-variants) you gave the home page two heroes, each fetching its own products, and a rule that picks one. In code you'd write `inTest ? newHero() : oldHero()`, and only one hero would fetch. But a block's arguments run before its function, so if both heroes were plain arguments to `multivariate`, both would fetch and one result would be thrown away. + +This page shows the one exception to that rule, the built-in `lazy` block: how to write one, how a function asks for it with `Lazy<T>`, and where Deco CMS uses it. + +## Write a lazy block + +Blocks resolve [from the inside out](/next/blocks#composing-blocks): every argument runs before its function. A **lazy block** is the one block that doesn't. `lazy` wraps a `value` (any block or JSON), and the function receives a function instead of the value. Calling it resolves `value`: + +```jsonc +// The call +productCard({ + title: "Summer collection", + showProduct: false, + product: () => catalogProduct({ slug: "summer-shirt" }), +}); + +// Saved as JSON +{ + "__resolveType": "product-card", + "title": "Summer collection", + "showProduct": false, + "product": { + "__resolveType": "lazy", + "value": { "__resolveType": "catalog-product", "slug": "summer-shirt" } + } +} +``` + +`catalogProduct` runs only if `productCard` calls `product()`, and at most once: a second call returns the same result. If it's never called, nothing inside it runs. If `value` fails, calling the function rejects with that error, and the function that called it handles it. + +<h2 id="lazyt-props">`Lazy<T>` props</h2> + +A function asks for laziness in its types. Type the prop as `Lazy<T>`, which is `() => Promise<T>`, and call it when you need the value: + +```tsx title="src/product-card.tsx" +import type { Lazy } from "@decocms/blocks"; + +export async function productCard({ title, showProduct, product }: { title: string; showProduct: boolean; product: Lazy<Product> }) { + if (!showProduct) return <Title text={title} />; // catalogProduct never runs + const item = await product(); // catalogProduct runs here + return <Card title={title} product={item} />; +} +``` + +Editors never see the wrapper. [`deco schema`](/next/schema#from-types-to-forms) gives a `Lazy<Product>` field the form of a `Product`, editors fill in a `Product`, and the site editor writes the `lazy` block around it. [`deco check`](/next/checking#what-deco-check-checks) fails if a `Lazy<T>` field holds something other than a `lazy` block, or a `lazy` block sits in a field that isn't `Lazy<T>`. See [`Lazy<T>`](/next/api-reference#types). + +## Where lazy is used + +- **Variants.** The built-in `multivariate` takes lazy variants, so only the winning variant runs, like `cond ? a() : b()`. That's why saved variants always carry the wrapper (see [Variants](/next/matchers-and-variants#variants)). +- **Expensive rules.** Rules stay eager, because they're usually cheap booleans. A matcher you write can take an inner rule as `Lazy<boolean>` and call it only when needed (see [Write your own matcher](/next/matchers-and-variants#write-your-own-matcher)). +- **Your own functions.** Any function that only sometimes needs an argument, like the product card above. diff --git a/docs/content/next/matchers-and-variants.mdx b/docs/content/next/matchers-and-variants.mdx new file mode 100644 index 00000000..93986d58 --- /dev/null +++ b/docs/content/next/matchers-and-variants.mdx @@ -0,0 +1,197 @@ +--- +title: Matchers and variants +nav: Matchers & variants +group: Built on Blocks +order: 10 +kind: docs +description: Schedule a campaign days ahead and it goes live on time, with no publish or deploy at the switch. A matcher is a rule that returns true or false; multivariate picks the first variant whose rule is true. +--- + +# Matchers and variants + +Black Friday starts at midnight, and nobody wants to be awake to publish the new banner. The easy way out is to add the content ahead of time and let it switch on by itself, on time. In code, that's an `if`/`else` on a condition. Matchers and variants let you write that condition as [blocks](/next/blocks), so editors can change it. + +This page shows how to schedule a campaign with `multivariate`, what a variant is, the built-in matchers, how to hide a block, how to write your own matchers, and how to run an A/B test. + +## Schedule a campaign + +Say the promo banner should show a Black Friday title from Friday to the end of Cyber Monday, and the usual title the rest of the time: + +```tsx +const now = new Date(); +const start = new Date("2026-11-27T00:00:00-05:00"); +const end = new Date("2026-12-01T00:00:00-05:00"); +const title = now >= start && now < end ? "Black Friday: 40% off everything" : "Free shipping over $50"; + +<PromoBanner title={title} href="/deals" />; +``` + +It works, but the dates and the titles live in your code. Changing them means a code change and a deploy each time. + +As blocks, the same condition is content that editors can change: + +```jsonc +{ + "__resolveType": "promo-banner", + "title": { + "__resolveType": "multivariate", + "variants": [ + { + "rule": { "__resolveType": "date", "start": "2026-11-27T00:00:00-05:00", "end": "2026-12-01T00:00:00-05:00" }, + "value": { "__resolveType": "lazy", "value": "Black Friday: 40% off everything" } + }, + { + "rule": { "__resolveType": "always" }, + "value": { "__resolveType": "lazy", "value": "Free shipping over $50" } + } + ] + }, + "href": "/deals" +} +``` + +Each `value` is wrapped in `lazy` so only the winning variant runs; the site editor adds this wrapper for you (see [Variants](#variants)). + +`multivariate` is a [built-in block](/next/built-in-blocks#language-built-ins). It evaluates the rules in order and returns the first variant whose rule is `true`. Each piece of the code version has a block: + +| In code | As blocks | +| --- | --- | +| The ternary, or a chain of `if`/`else if` | `multivariate` with a list of rules and their variants; the first variant whose rule is `true` wins. | +| The date condition | The built-in `date` matcher. | +| The final `else` | A last entry whose rule is `always`; its variant is the fallback. | +| The constants | Saved values that editors change in [the site editor](/next/site-editor). | + +`end` takes the campaign down too, so there's nothing to do on Monday. `PromoBanner` gets a plain string: it never knows there were variants. + +## Variants + +A **variant** is the alternate content: the `value` next to a `rule` in `variants`. Each entry pairs one rule with one variant. + +Any field can have variants: a title, one block in a page's `sections`, or the whole list, which is how the site editor varies a page. [Forms from types](/next/schema#from-types-to-forms) shows how the field's type flows through `multivariate<T>`. + +Variants are lazy: each `value` is wrapped in a [`lazy` block](/next/lazy-blocks), so only the winning variant runs, like `cond ? a() : b()`. If each variant were a hero section that fetches products, only the chosen hero fetches. Saved variants always carry the wrapper (`deco check` flags a variant without it), and editors never see it: the site editor adds it for them. Rules aren't lazy: they're cheap booleans and always run. + +Caching a page whose content varies per request is in [Pages with variants](/next/caching#pages-with-variants). To see a variant whose rule is false right now, preview it: a [draft pointer can force it](/next/releases-and-drafts#preview-a-variant), which is what the site editor's variant tabs do. + +## Matchers + +A block function that returns `true` or `false` is a **matcher**. Matchers are what make variants usable without code: the site editor lists every matcher in your block map in the rule picker next to each variant, so the marketing team builds conditions by picking from a list and filling a short form. `date` is one; you can add your own (see [Write your own matcher](#write-your-own-matcher)), such as: + +- **Temperature**: `true` when it's above a given temperature where the visitor is, to promote cold drinks on hot days. +- **Birthday**: `true` during a signed-in customer's birthday week, to show a gift. +- **Cart value**: `true` when the cart is over an amount, to offer free shipping. +- **Loyalty tier**: `true` for gold members, to show early access. + +### Built-in matchers + +Three matchers are [built-in blocks](/next/built-in-blocks#language-built-ins), because they depend on nothing but the clock. Like every built-in, they need no import: + +- **`always`** is always `true`. Use it for the fallback. +- **`never`** is always `false`. The site editor uses it to hide a block. +- **`date`** is `true` from `start` until `end`. Both are optional. + +`start` and `end` are ISO 8601 strings. Write them with the offset your store's time zone has on that date, like `"2026-11-27T00:00:00-05:00"` for New York in November, so the campaign starts at midnight on your clock. Daylight saving time changes the offset (New York is `-04:00` in summer), so check it for each date. A date without a time, like `"2026-11-27"`, means midnight UTC. `end` itself doesn't match, so a campaign that ends at midnight uses that midnight as its `end`. + +### Hide a block + +When an editor hides a block, the site editor gives it variants whose only rule is `never`. No rule matches, so the block resolves to `undefined` and nothing inside it runs. In a list, such as a page's `sections`, a block that resolves to `undefined` is left out, so your components never see a gap. + +Blocks doesn't render anything itself, so what a missing block shows is up to your app. Each framework guide renders nothing for it, and shows a fallback only when a block fails (see [Errors, streaming, and cancellation](/next/rendering#errors-streaming-and-cancellation)). + +### Write your own matcher + +In code, a weekend title would be one more condition, like `today === "Sat" || today === "Sun"`. As a matcher, that condition moves into a function that returns a `boolean`. This one is `true` on the days of the week you pick, in your store's time zone: + +```ts title="src/matchers.ts" +type Day = "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat" | "Sun"; + +export function weekday({ days, timeZone = "America/New_York" }: { days: Day[]; timeZone?: string }) { + const today = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone }).format(new Date()); // "Sat" + return days.some((day) => day === today); +} +``` + +Add it to your [block map](/next/blocks#the-block-map) and run `npx @decocms/blocks schema` (see [Forms from types](/next/schema#from-types-to-forms)): + +```ts title=".deco/index.ts" +import type { Blocks } from "@decocms/blocks"; +import { weekday } from "../src/matchers"; +import { PromoBanner } from "../src/promo-banner"; + +export default { weekday, "promo-banner": PromoBanner } satisfies Blocks; // always, never, date, multivariate and lazy are built in +``` + +The site editor's rule picker now lists `weekday` next to the built-in `date` (see [what the site editor reads](/next/studio-compatibility#what-the-site-editor-reads-from-the-schema)). An editor can give weekends their own title: + +```jsonc +// The title field of the promo banner +{ + "__resolveType": "multivariate", + "variants": [ + { + "rule": { "__resolveType": "weekday", "days": ["Sat", "Sun"] }, + "value": { "__resolveType": "lazy", "value": "Weekend: free shipping on everything" } + }, + { + "rule": { "__resolveType": "always" }, + "value": { "__resolveType": "lazy", "value": "Free shipping over $50" } + } + ] +} +``` + +A few more things you can do: + +- **Make it `async`.** `multivariate` waits for it. +- **Match on the request.** To match on a cookie, a header or a country, read the request the way the rest of your app does (see [Reading the request](/next/blocks#reading-the-request)). +- **Combine rules.** `and`, `or` and `not` are one-line matchers, like `export const not = ({ rule }: { rule: boolean }) => !rule;`. +- **Skip an expensive rule.** Rules always run. A matcher can take an inner rule as [`Lazy<boolean>`](/next/lazy-blocks#lazyt-props) and call it only when needed, like `export const and = async ({ a, b }: { a: boolean; b: Lazy<boolean> }) => a && (await b());`, where `b` runs only if `a` is `true`. +- **Pick variants your way.** Declare your own `multivariate` in your block map; it replaces the built-in (see [Change a built-in](/next/built-in-blocks#change-a-built-in)). Type each `value` as `Lazy<T>` to keep running only the chosen variant. + +## Run an A/B test + +An A/B test is a variant whose rule puts each visitor in a group. Write it as a matcher that takes the share of traffic and buckets each visitor the same way on every request: + +```ts title="src/matchers.ts" +import { visitorId } from "./visitor"; // your app's anonymous visitor ID, kept in a cookie + +const bucket = (key: string) => [...key].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 0) % 100; + +/** @title A/B split */ +export function split({ experiment, percent }: { experiment: string; percent: number }) { + return bucket(`${experiment}:${visitorId()}`) < percent; // true for `percent`% of visitors +} +``` + +Then give `multivariate` the test's ID in `experiment`, and pass the same ID to the rule: + +```jsonc +{ + "__resolveType": "multivariate", + "experiment": "hero-headline", + "variants": [ + { + "rule": { "__resolveType": "split", "experiment": "hero-headline", "percent": 50 }, + "value": { "__resolveType": "lazy", "value": "Summer starts here" } + }, + { + "rule": { "__resolveType": "always" }, + "value": { "__resolveType": "lazy", "value": "New summer collection" } + } + ] +} +``` + +The `experiment` ID names the test wherever the variants live. Results are kept per ID, so renaming a saved block or moving the variants keeps a test's history. The site editor fills it in when an editor creates a test. Older sites keyed results on the saved block's name; [migrating from v7](/next/renames-and-migrations) copies that name into `experiment`, so existing results carry over. + +<Callout type="warning">**Variants are not access control.** Only the chosen variant runs, but that's an optimization, not a guard: choose between variants that are all fine to show. Anyone with a preview link can [force any variant](/next/releases-and-drafts#preview-a-variant). Never use one to guard protected data or to decide whether a side effect runs; put that check inside the function that does the work.</Callout> + +## Schedule with confidence + +A scheduled campaign is a change to content files, so it's checked like any other change, days before it goes live: + +- **Review it in a pull request.** Reviewers read the dates and the campaign value in the diff of `.deco/blocks`, and [`deco check`](/next/checking) makes sure every block fits your code (see [On a branch](/next/releases-and-drafts#on-a-branch)). +- **Preview the page.** A [draft](/next/releases-and-drafts#preview) or your host's [preview deployment of the branch](/next/releases-and-drafts#on-a-branch) renders it at the current time, so before the start date it shows the fallback, exactly what visitors see until midnight. To check the campaign variant itself, [force it](/next/releases-and-drafts#preview-a-variant), as the site editor's variant tabs do. +- **Keep `always()` last.** The first match wins, so the variant paired with the `always()` rule is the fallback. If no rule matches, `multivariate` returns `undefined`, and the component gets `undefined` instead of a title. With `always()` last, the page never comes up empty, before the campaign or after it ends. [`deco check`](/next/checking#what-deco-check-checks) warns about any variant after an `always` rule, since it can never be picked. + +If a cached page still shows the old title after the switch, see [Pages with variants](/next/caching#pages-with-variants) and [Troubleshooting](/next/troubleshooting). diff --git a/docs/content/next/nextjs.mdx b/docs/content/next/nextjs.mdx new file mode 100644 index 00000000..01792e6a --- /dev/null +++ b/docs/content/next/nextjs.mdx @@ -0,0 +1,362 @@ +--- +title: Next.js App Router +nav: Next.js +group: Websites +order: 18 +description: Render Deco CMS pages in the Next.js App Router with Server Components, generate metadata from the page's SEO block, and stream each block. +--- + +# Next.js App Router + +Editors choose page URLs in [the site editor](/next/site-editor), so your app can't know its routes ahead of time. Instead, one catch-all route receives every URL, asks [`matchRoute`](/next/api-reference#matchroute-url-items) which page or redirect owns it, and renders that page's blocks. + +This page shows how to render Deco pages with Server Components, generate metadata from the page's SEO block, and optionally stream each block. + +**Prerequisites:** an App Router app (the SDK reads no files, so the Node and Edge runtimes both work), with a root layout; `npm install @decocms/blocks server-only` (`@decocms/blocks` includes the [`deco` CLI](/next/cli)); and the [example project](/next/routing#the-example-project): `src/model.ts` plus the entries in `.deco/blocks`, in your app root. + +## 1. Add the components + +`PromoBanner` and `ProductHero` are the components the two blocks render. `CartButton` is a Client Component with state, there to show that interactive components keep working inside blocks the CMS renders on the server. None of them knows about the CMS. + +```tsx title="src/CartButton.tsx" +"use client"; +import { useState } from "react"; + +export default function CartButton() { + const [count, setCount] = useState(0); + return ( + <button type="button" onClick={() => setCount((value) => value + 1)}> + Add to cart ({count}) + </button> + ); +} +``` + +```tsx title="src/PromoBanner.tsx" +import type { PromoBannerProps } from "./model"; + +export default function PromoBanner({ title, href }: PromoBannerProps) { + return ( + <a href={href} role="note"> + {title} + </a> + ); +} +``` + +```tsx title="src/ProductHero.tsx" +import CartButton from "./CartButton"; +import type { ProductHeroProps } from "./model"; + +export default function ProductHero({ name, price, currency, image }: ProductHeroProps) { + const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency }).format(price); + return ( + <section> + <img src={image} alt={name} /> + <h1>{name}</h1> + <p>{formatted}</p> + <CartButton /> + </section> + ); +} +``` + +## 2. Create the CMS + +Next renders both blocks as Server Components, and `CartButton` stays a Client Component. The [block map](/next/blocks#the-block-map) lives in `.deco/`, in your app root, next to your content (see [The `.deco` folder](/next/saved-blocks#the-deco-folder)), so it imports your components from `../src/`. + +```tsx title=".deco/index.tsx" +import type { Blocks, Seo } from "@decocms/blocks"; +import ProductHero from "../src/ProductHero"; +import PromoBanner from "../src/PromoBanner"; +import type { ProductHeroProps, PromoBannerProps } from "../src/model"; + +export default { + seo: (input: Seo) => input, + "promo-banner": (input: PromoBannerProps) => <PromoBanner {...input} />, + "product-hero": (input: ProductHeroProps) => <ProductHero {...input} />, +} satisfies Blocks; +``` + +`seo` returns its input. Registering it lets editors save SEO as a reusable entry with its own form. `page` and `redirect` are [built-in blocks](/next/built-in-blocks#pages-and-redirects), so they aren't listed here. Both blocks return JSX, so they [fit the built-in page's](/next/schema#interchangeable-blocks) `sections: ReactNode[]`. + +`cms.ts` imports the [content module](/next/content#the-content-module), `.deco/blocks.gen.ts`. Step 3 generates it; until it has run once, TypeScript can't find the import. + +```ts title="src/cms.ts" +import { createCMS } from "@decocms/blocks"; +import blocks from "../.deco"; +import content from "../.deco/blocks.gen"; + +// Serves the content module: the content of the commit this build was made from. +export const cms = createCMS({ blocks, content }); +``` + +[`createCMS`](/next/api-reference#createcms-config) combines your block map and your content into the `cms` object (see [Create the CMS](/next/content#create-the-cms)). This guide doesn't cache upstream responses; for a short recipe over Next's data cache, see [Upstream data](/next/caching#next-js). + +Telemetry is one more option, and it's off until you say where it goes: for example, `telemetry: { endpoint: process.env.OTLP_ENDPOINT! }` sends it to your own OpenTelemetry collector. See [Telemetry](/next/telemetry#choose-where-telemetry-goes). + +```ts title="src/client.server.ts" +import "server-only"; +import { cms } from "./cms"; + +// The client for this request. Every page gets its client here, so this is the one place to change +// if requests ever need different content. +export async function client() { + return cms.forRelease(); +} +``` + +`cms.forRelease()` returns the client your code calls `list` and `resolve` on, one per request (see [One revision per response](/next/releases-and-deployment#one-revision-per-response)). `import "server-only"` makes the build fail if a Client Component imports this file, which keeps the CMS and its content out of the browser. + + +## 3. Generate the schema and content + +`deco schema` and `deco content` generate the files the site editor and the app need; run them before the dev server and the build (see [Run it before dev and build](/next/cli#run-it-before-dev-and-build)): + +```json title="package.json" +{ + "scripts": { + "predev": "deco schema && deco content", + "prebuild": "deco schema && deco content && deco check" + } +} +``` + +Commit `.deco/schema.gen.json` and gitignore `.deco/blocks.gen.ts`; don't edit either. What reloads on its own is in [The content module](/next/content#the-content-module); while you work, you can leave `npx @decocms/blocks content --watch` running beside `next dev`. + +## 4. Match the URL to a page + +`openPage` lists pages and redirects, matches the pathname, and resolves the page that wins (each step is explained in [Route a request](/next/routing#route-a-request)). The built-in `page` block resolves `seo` and every block in `sections`, so the page comes back ready to render. Listing is cheap: the CMS holds the content in memory. + +`generateMetadata` and the page component both need the page. Wrap the lookup in React's `cache` so they share one client and one result. + +```ts title="src/open-page.server.ts" +import "server-only"; +import { cache, type ReactNode } from "react"; +import { notFound, permanentRedirect, redirect } from "next/navigation"; +import { matchRoute, type Redirect } from "@decocms/blocks"; +import { client } from "./client.server"; +import type { ResolvedPage, StoredPage } from "./model"; + +export const openPage = cache(async (pathname: string) => { + const c = await client(); + const [pages, pagesError] = await c.list<StoredPage>("page"); + if (pagesError) throw pagesError; + const [redirects, redirectsError] = await c.list<Redirect>("redirect"); + if (redirectsError) throw redirectsError; + + const match = matchRoute(pathname, { routes: pages, redirects }); + if (match.kind === "not-found") notFound(); + if (match.kind === "redirect") { + if (match.status === 301 || match.status === 308) permanentRedirect(match.location); + redirect(match.location); + } + + const [page, error] = await c.resolve<ResolvedPage<ReactNode>>(match.entry); + if (error) throw error; + return page; +}); + +export const pathnameFor = (segments: string[] = []) => + "/" + segments.map(encodeURIComponent).join("/"); +``` + +## 5. Render the catch-all route + +The page's `sections` are already rendered blocks, so the route lists them in order. + +```tsx title="src/app/[[...path]]/page.tsx" +import { Fragment } from "react"; +import type { Metadata } from "next"; +import { openPage, pathnameFor } from "../../open-page.server"; + +type Props = { params: Promise<{ path?: string[] }> }; + +export async function generateMetadata({ params }: Props): Promise<Metadata> { + const page = await openPage(pathnameFor((await params).path)); + return { title: page.seo?.title, description: page.seo?.description }; // without seo, the layout's metadata applies +} + +export default async function Page({ params }: Props) { + const page = await openPage(pathnameFor((await params).path)); + return ( + <main> + {page.sections.map((section, index) => ( + <Fragment key={index}>{section}</Fragment> + ))} + </main> + ); +} +``` + +If any block fails, `resolve` returns an error, `openPage` throws it, and Next shows its error page. To show a fallback for just the failing block, use the [streaming step](#7-stream-each-block-optional). + +## 6. Add your own routable type (a blog) + +Add `src/post.ts` from [Your own routable types](/next/routing#your-own-routable-types), add `post: (props: Post) => props` to the default export of `.deco/index.tsx` (importing `Post` from `../src/post`), run `npx @decocms/blocks schema` again, and save an entry such as `.deco/blocks/HelloWorld.json` (a new file, so run `npx @decocms/blocks content` again unless `--watch` is running). Then route posts in `openPage`; without this, every post link is a 404. List posts next to pages, and return the post as is when it wins: `match.entry` is already the saved post. Call `c.resolve(match.entry)` only if your posts contain blocks: + +```ts title="src/open-page.server.ts (changes)" +import type { Post } from "./post"; + + // inside openPage, after listing pages and redirects: + const [posts, postsError] = await c.list<Post>("post"); + if (postsError) throw postsError; + + const match = matchRoute(pathname, { routes: [...pages, ...posts], redirects }); + // … not-found and redirect handling as before … + + if ("__resolveType" in match.entry && match.entry.__resolveType === "post") return { post: match.entry as Post, page: null }; + const [page, error] = await c.resolve<ResolvedPage<ReactNode>>(match.entry); + if (error) throw error; + return { post: null, page }; +``` + +`openPage` now returns `{ post, page }`, one of them set. In `Page`, render the post when `post` is set (for example `<article><h1>{post.name}</h1>…</article>`), and the page's sections otherwise. In `generateMetadata`, use `post.name` as the title for a post. + +A blog index needs no page entry. List the `post` type; each entry carries its own `path`: + +```tsx title="src/app/blog/page.tsx" +import Link from "next/link"; +import { client } from "../../client.server"; +import type { Post } from "../../post"; + +export default async function BlogIndex() { + const c = await client(); + const [posts, error] = await c.list<Post>("post", { sort: (a, b) => b.date.localeCompare(a.date) }); + if (error) throw error; + return ( + <ul> + {posts.map((post) => ( + <li key={post.path}><Link href={post.path}>{post.name}</Link></li> + ))} + </ul> + ); +} +``` + +## 7. Stream each block (optional) + +So far the page renders once every block is ready, so one slow block holds up the whole page. To send each block as soon as it's ready, resolve the blocks one by one instead of resolving the page, and give each its own Suspense boundary. A failing block then shows a fallback instead of failing the page. + +`c.list("page")` already returns each page as saved, with its blocks not yet run (the same as `c.resolve(entry, { run: false })`; see [Reading without running](/next/api-reference#client-resolve-target-options)). So `openPage` starts resolving `seo` and every block in `sections` with separate calls, without waiting for any, and returns the promises. If an editor gave the whole list [variants](/next/matchers-and-variants), `sections` is one `multivariate` block instead of a list; `openPage` then resolves it as one, and React renders the list it returns in one Suspense boundary. Block keys include the pathname, so React mounts fresh Suspense boundaries when you navigate. These files replace steps 4 and 5; if you added posts in step 6, see the changes right after them: + +```ts title="src/open-page.server.ts" +import "server-only"; +import { cache, type ReactNode } from "react"; +import { notFound, permanentRedirect, redirect } from "next/navigation"; +import { matchRoute, type Redirect, type Seo } from "@decocms/blocks"; +import { client } from "./client.server"; +import type { StoredPage } from "./model"; + +export const openPage = cache(async (pathname: string) => { + const c = await client(); + const [pages, pagesError] = await c.list<StoredPage>("page"); + if (pagesError) throw pagesError; + const [redirects, redirectsError] = await c.list<Redirect>("redirect"); + if (redirectsError) throw redirectsError; + + const match = matchRoute(pathname, { routes: pages, redirects }); + if (match.kind === "not-found") notFound(); + if (match.kind === "redirect") { + if (match.status === 301 || match.status === 308) permanentRedirect(match.location); + redirect(match.location); + } + + const page = match.entry; // as saved: seo and sections are still blocks + // Variants of the whole list are one multivariate block: it resolves to the chosen list and streams as one. + const sections = Array.isArray(page.sections) ? page.sections : [page.sections]; + return { + blocks: sections.map((block, index) => ({ + key: `${pathname}:${index}`, + result: c.resolve<ReactNode>(block), + })), + seo: c.resolve<Seo | undefined>(page.seo), // undefined when the page has no seo + }; +}); + +export const pathnameFor = (segments: string[] = []) => + "/" + segments.map(encodeURIComponent).join("/"); +``` + +Each block is now a promise of a `Result`, `[value, error]`. `BlockSlot` awaits it inside its own Suspense boundary and shows a fallback if that one block failed. A [hidden](/next/matchers-and-variants#hide-a-block) block resolves to `undefined` and renders nothing. + +```tsx title="src/app/[[...path]]/page.tsx" +import { Suspense, type ReactNode } from "react"; +import type { Metadata } from "next"; +import type { Result } from "@decocms/blocks"; +import { openPage, pathnameFor } from "../../open-page.server"; + +type Props = { params: Promise<{ path?: string[] }> }; + +export async function generateMetadata({ params }: Props): Promise<Metadata> { + const page = await openPage(pathnameFor((await params).path)); + const [seo, error] = await page.seo; + if (error) throw error; + return { title: seo?.title, description: seo?.description }; // without seo, the layout's metadata applies +} + +async function BlockSlot({ result }: { result: Promise<Result<ReactNode>> }) { + const [node, error] = await result; + if (error) { + console.error(error); + return <p role="status">This content is temporarily unavailable.</p>; + } + return node ?? null; // undefined when an editor hid the block: render nothing +} + +export default async function Page({ params }: Props) { + const page = await openPage(pathnameFor((await params).path)); + return ( + <main> + {page.blocks.map((block) => ( + <Suspense key={block.key} fallback={<p>Loading…</p>}> + <BlockSlot result={block.result} /> + </Suspense> + ))} + </main> + ); +} +``` + +If you added posts in step 6, list them and pass them to `matchRoute` as before, return the post before resolving any block, and add `post: null` to the page's return value: + +```ts title="src/open-page.server.ts (posts)" + if ("__resolveType" in match.entry && match.entry.__resolveType === "post") { + return { post: match.entry as Post, blocks: [], seo: null }; + } + // … build blocks and seo as above, then: + return { post: null, blocks, seo }; +``` + +`Page` and `generateMetadata` check `post` the same way as in step 6: when it's set, render the post and use `post.name` as the title, and only otherwise await `page.seo` and render `page.blocks`. + +Metadata now waits only for SEO, and Next can stream it separately. Once streaming starts, the status code and headers are already sent, so a later error can't change them; `openPage` decides 404s and redirects before anything streams (see [Errors, streaming, and cancellation](/next/rendering#errors-streaming-and-cancellation)). The same technique works in TanStack Start; see the [TanStack Start guide](/next/tanstack-start-descriptors). + +## How it works + +- **One place picks the content.** Every page gets its client from `client()`, so changing where content comes from touches only that function. +- **`openPage` takes a pathname.** React's `cache` compares arguments by identity, so pass a primitive. If content depends on query parameters, include a normalized search string in the argument. A block that needs headers or cookies reads them with `next/headers`, like any other server code. +- **Redirect status codes.** Next's navigation helpers send 308 and 307. For an exact 301 or 302, or a redirect's own `status`, match redirects in a `proxy.ts` (Next.js 16's name for `middleware.ts`) with `matchRoute` and return `NextResponse.redirect(url, match.status)`. Proxy makes its own client, so if the content changes between the two (in development, for example), the redirect check and the page can briefly see different versions of it. + + +These files use the default App Router configuration. [Cache Components](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents) adds caching and Suspense requirements of its own. See the Next.js docs on [metadata](https://nextjs.org/docs/app/api-reference/functions/generate-metadata) and [proxy](https://nextjs.org/docs/app/api-reference/file-conventions/proxy). + +### Caching + +A page rendered at build time (static rendering or ISR) never switches [variants](/next/matchers-and-variants): its matchers ran once, during the build. Render pages with date-switched variants on each request, with `await connection()` from `next/server` or `export const dynamic = "force-dynamic";`. The other caches a request passes through are in [Caching](/next/caching). + +## Edit in the site editor + +To edit this app's content in [the site editor](/next/site-editor) while you develop, run [`deco serve`](/next/cli#deco-serve) beside `next dev`. Its preview defaults to Vite's port, so point it at Next's: + +```bash +npx @decocms/blocks serve --preview localhost:3000 +``` + +Each save writes a file in `.deco/blocks` and your app hot-reloads; you commit the changes as usual (see [Edit on your machine](/next/site-editor#edit-on-your-machine)). + +### Reload on content changes + +`deco serve` rewrites `.deco/blocks.gen.ts` on every save, and Blocks has no dev hook for it; on Next.js you don't need one. `next dev` recompiles the server modules that import the content module, runs `src/cms.ts` again and refreshes open pages. Running it again is safe because `createCMS` adopts the new content module into the same instance for the same `.deco` folder (and the `cms` it returns uses the new block map), so keep it a plain module-scope call, as in step 2: no hot-reload handler, and no instance kept in a variable of your own. The [TanStack Start guide](/next/tanstack-start-descriptors#reload-on-content-changes) needs a few lines for both, since Vite does neither on its own. + +<Hosted to="/next/hosted#connect-your-site" label="Connect your site">**Publish and preview without a deploy.** Two environment variables connect this app to the hosted Deco CMS: published content goes live without a rebuild, and drafts render on your site.</Hosted> diff --git a/docs/content/next/quickstart.mdx b/docs/content/next/quickstart.mdx new file mode 100644 index 00000000..a386df81 --- /dev/null +++ b/docs/content/next/quickstart.mdx @@ -0,0 +1,218 @@ +--- +title: Quickstart +nav: Quickstart +group: Getting started +kind: docs +order: 2 +--- + +# Quickstart + +<Callout>**Status.** The next version of Deco is being designed in the open: the API on these pages is proposed and not released yet. The [Roadmap](/roadmap) lists what's left.</Callout> + +Suppose your app has a function that decides which A/B tests a visitor sees. It takes the traffic split for each experiment and returns whether each one is enabled: + +```ts title="experiments.ts" +export interface Experiments { + /** + * @title New checkout flow + * @minimum 0 + * @maximum 100 + */ + newCheckout: number; + /** + * @title Sticky header + * @minimum 0 + * @maximum 100 + */ + stickyHeader: number; + /** + * @title Free shipping banner + * @minimum 0 + * @maximum 100 + */ + freeShippingBanner: number; +} + +const roll = (percent: number) => Math.random() * 100 < percent; + +export default function experiments(input: Experiments) { + return { + newCheckout: roll(input.newCheckout), + stickyHeader: roll(input.stickyHeader), + freeShippingBanner: roll(input.freeShippingBanner), + }; +} +``` + +```ts title="checkout.ts" +import experiments from "./experiments"; + +experiments({ newCheckout: 10, stickyHeader: 50, freeShippingBanner: 0 }); +// e.g. { newCheckout: false, stickyHeader: true, freeShippingBanner: false } +``` + +The split is hardcoded at the call site, so ramping the new checkout from 10% to 25% means a code change. This page shows how to make that function editable in about five minutes: give it a block map, generate a form, save the numbers in a JSON file, and read them back, while the function stays exactly as it is (why: [How it works](/next/how-it-works)). Everything here runs in plain Node, with no framework. + +This function doesn't render anything; it returns flags. A block function can just as well return JSX; see [Rendering](/next/rendering). + +**Prerequisites:** + +- Node.js 24 or later, and `tsx` to run TypeScript. +- An ESM project (`"type": "module"` in `package.json`), because the examples use top-level `await`. + +```bash +npm install @decocms/blocks +``` + +The package also provides the `deco` command, the CLI used below. Run it with `npx @decocms/blocks <command>`; inside `package.json` scripts it's just `deco <command>` (see [CLI](/next/cli)). + +## 1. Make the function configurable + +Any function with a typed first parameter can become a block function. Register it in a [block map](/next/blocks#the-block-map), a plain object of functions. Each key is a block type, the name content uses to call that function; here it's `experiments`. Everything Deco lives in one folder, [`.deco/`](/next/saved-blocks#the-deco-folder), in your app root (the folder with your app's `package.json`), so create it and put the map in `.deco/index.ts`. The CLI reads this file, and your app hands it to Deco in [step 5](#5-read-the-content-from-your-code). + +```ts title=".deco/index.ts" +import type { Blocks } from "@decocms/blocks"; +import experiments from "../experiments"; + +export default { experiments } satisfies Blocks; +``` + +`satisfies Blocks` checks the map's shape without widening it, so TypeScript keeps each function's exact input and return types. The CLI generates the editor form from those types in step 2. + +## 2. Give editors a form + +The CLI reads the map and turns the `Experiments` type into a JSON Schema. The JSDoc tags shape the form: `@title` becomes the field's label, and `@minimum`/`@maximum` its allowed range. [Forms from types](/next/schema#widgets) lists every tag. + +```bash +npx @decocms/blocks schema +``` + +It reads the block map in `.deco/index.ts` and writes `.deco/schema.gen.json`. Commit the file, and don't edit it: the `.gen.` in its name marks a generated file. [The site editor](/next/site-editor) builds its form from it, showing each experiment as a number field clamped to 0–100. + +## 3. Add content + +There are two ways to create content: + +- **By hand.** Write the JSON file yourself, or ask an AI agent to "ramp the new checkout to 25%" and let it edit the file. +- **In the site editor, on your machine.** Run [`npx @decocms/blocks serve`](/next/site-editor#edit-on-your-machine), open the link it prints, pick the `experiments` block type and fill in the form. Each save writes the file to your working tree; you commit it like any other change. + +Both routes produce the same file in `.deco/blocks`, which is where the CLI looks for content (the site editor reads and writes this folder). If you write it by hand, create the `.deco/blocks/` folder first, then save the file: + +```json title=".deco/blocks/Experiments.json" +{ + "__resolveType": "experiments", + "newCheckout": 10, + "stickyHeader": 50, + "freeShippingBanner": 0 +} +``` + +```ts +// The call +experiments({ newCheckout: 10, stickyHeader: 50, freeShippingBanner: 0 }); + +// Saved as JSON + +``` + +This "stored as JSON, means a call" pattern is what a block is: a function call written as JSON (see [Functions as blocks](/next/blocks#functions-as-blocks)). `__resolveType` is a reserved key that names the function to call by its block type. The other keys are that function's inputs. The file is a [saved block](/next/saved-blocks), a block stored under a name: its file name without `.json`. + +Two names are in play, on purpose: `experiments` is the block type and names a function, and `Experiments` is the saved block's name and names this one piece of saved content. You could save several blocks that call `experiments`, such as `HolidayExperiments`. Names are case-sensitive, so they don't clash. + +## 4. Generate the content module + +The CLI turns your content files into a module your app imports, the [content module](/next/content#the-content-module): + +```bash +npx @decocms/blocks content +``` + +It writes `.deco/blocks.gen.ts`: it's your saved blocks as a module. It's generated, so don't edit it, and add this one line to `.gitignore`: + +```text title=".gitignore" +.deco/blocks.gen.ts +``` + +Ignore only this file, not `.deco/`: the rest of the folder is your code and content. + +Your app root now looks like this: + +```text +package.json +experiments.ts +checkout.ts +cms.ts you'll write this in step 5 +.deco/ +├── index.ts your block map +├── blocks/ +│ └── Experiments.json +├── schema.gen.json generated, committed +└── blocks.gen.ts generated, gitignored +``` + +To run them automatically, add them to the `scripts` in `package.json` ([when to rerun](/next/cli#run-it-before-dev-and-build)). The build also runs [`deco check`](/next/checking), so content that doesn't fit your code fails the build instead of deploying: + +```json title="package.json" +{ + "scripts": { + "predev": "deco schema && deco content", + "prebuild": "deco schema && deco content && deco check" + } +} +``` + +To make sure your saved blocks fit your code, run `npx @decocms/blocks schema && npx @decocms/blocks check` (see [what it checks](/next/checking#what-deco-check-checks)). + +## 5. Read the content from your code + +Create the CMS once, at module scope, by calling [`createCMS`](/next/api-reference#createcms-config) with your block map and the content module ([why at module scope](/next/content#create-the-cms)). It returns `cms`, the object your app reads content through: + +```ts title="cms.ts" +import { createCMS } from "@decocms/blocks"; +import blocks from "./.deco"; +import content from "./.deco/blocks.gen"; + +export const cms = createCMS({ blocks, content }); +``` + +Then ask it for a client, the object you read content with. `forRelease()` returns a client over the content your app serves to visitors, the [release](/next/releases-and-drafts#releases): here, what's in the content module. Its sibling `forDraft()` reads a [draft](/next/releases-and-drafts#drafts) from a content source that has drafts; the content module has none, so you won't need it here. Make one client per request: a client reads one consistent version of the content for its whole life ([why](/next/releases-and-deployment#one-revision-per-response)). + +Fetch the saved block by name with [`client.resolve`](/next/api-reference#client-resolve-target-options). The client runs `experiments` on the saved inputs, so you get the same value as calling `experiments()` yourself. A name is a string, so TypeScript can't infer the result type from it; pass the type you expect, and it's checked at compile time, not at runtime: + +```ts title="checkout.ts" +import { cms } from "./cms"; +import type experiments from "./experiments"; + +const client = cms.forRelease(); +const [flags, error] = await client.resolve<ReturnType<typeof experiments>>("Experiments"); +if (error) throw error; +console.log(flags); +// e.g. { newCheckout: false, stickyHeader: true, freeShippingBanner: false } +// (newCheckout is true about 10% of the time) +``` + +Every call returns a `[value, error]` tuple, so a missing block or a failing function never throws. The error names what went wrong, for example `NOT_FOUND`. [Troubleshooting](/next/troubleshooting) lists every code. + +Run it with `npx tsx checkout.ts`. Now open `.deco/blocks/Experiments.json`, change `newCheckout` to `100`, and run it again: `newCheckout` is `true` every time. You changed what the code does without touching the code. + +A real implementation would hash a visitor ID from a cookie instead of rolling a die, so each visitor gets a stable result. Block functions receive only their saved inputs, not the HTTP request, so read the cookie the way the rest of your app does (see [Reading the request](/next/blocks#reading-the-request)). + +<Hosted to="/next/hosted" label="What the hosted Deco CMS adds">**Change the numbers without a deploy.** With the hosted Deco CMS, editors change `Experiments.json` in the site editor on GitHub, and running servers pick it up on their next background check after release preparation.</Hosted> + +## Next steps + +- [Blocks](/next/blocks): the syntax, a function call written as JSON, and how calls nest. +- [Saved blocks](/next/saved-blocks): naming a call, reusing it, overriding its arguments, and the `.deco` folder. +- [Built-in blocks](/next/built-in-blocks): the ten functions every block map gets, such as `page` and `multivariate`. +- [Forms from types](/next/schema): how your types become editor forms, and the JSDoc tags that shape them. +- [The site editor](/next/site-editor): editing content in forms, with a live preview. +- [Checking content](/next/checking): how `deco check` keeps saved content and code in step. +- [Matchers and variants](/next/matchers-and-variants): schedule content by date, run A/B tests, or switch it on any rule you write. +- [Lazy blocks](/next/lazy-blocks): the one argument that runs only when it's asked for. +- [Content and loaders](/next/content): how saved blocks reach your running app. +- [Releases and drafts](/next/releases-and-drafts): what everyone sees, and how to preview a change before it ships. +- [Pages and routing](/next/routing): how pages and redirects get their URLs, and how one route serves them. +- [Rendering](/next/rendering): the two ways a block becomes UI. +- [Telemetry](/next/telemetry): upstream latency and errors, sent to your own collector. +- Guides for [Next.js](/next/nextjs) and [TanStack Start](/next/tanstack-start-descriptors). diff --git a/docs/content/next/releases-and-deployment.mdx b/docs/content/next/releases-and-deployment.mdx new file mode 100644 index 00000000..b73e90e3 --- /dev/null +++ b/docs/content/next/releases-and-deployment.mdx @@ -0,0 +1,57 @@ +--- +title: Deployment +nav: Deployment +group: Content and data +order: 15 +kind: docs +--- + +# Deployment + +On Thursday night an editor fixes a typo in Friday's sale banner. How does that fix reach visitors, and if it breaks the page, how do you undo it? + +This page shows how a change ships, how each response stays on one revision, and how to roll back. What a release, a draft and a preview are is in [Releases and drafts](/next/releases-and-drafts). + +## How a change ships + +Your block functions and your content live in your repository (see [The .deco folder](/next/saved-blocks#the-deco-folder)), so they ship together: + +| Step | What happens | +|---|---| +| Commit | A developer, an agent, or an editor in [the site editor](/next/site-editor) changes a function, a [saved block](/next/saved-blocks), or both. Review them in one pull request like any other change. | +| Build | The [`prebuild` script](/next/cli#run-it-before-dev-and-build) runs `deco schema && deco content && deco check`: it bundles `.deco/blocks` into the build as the [content module](/next/content#the-content-module), and fails the build if saved content doesn't fit the code (see [Checking content](/next/checking)). | +| Deploy | Your host serves the new build. Every server of that build serves exactly one revision: the content of the commit it was built from. | + +## What a revision is + +A **release revision** identifies one exact content map: `deco content` computes it as a hash of the whole map, so two copies of the content are the same exactly when their revisions match (see [Snapshots and revisions](/next/internals#snapshots-and-revisions)). A revision never changes once it exists: change any saved block and you get a new revision. That makes a revision a safe cache key everywhere. + +<h2 id="publishing">Publishing is committing</h2> + +There's no separate publish step: once a change to `.deco/blocks` is on your production branch, your next deploy serves it. In development, editing a file hot-reloads, so you see a change before you commit it. + +Because content and code share one history, a content change is a commit like any other. That gives you: + +- **Review.** A content change is a diff in a pull request, next to the code it needs. +- **Branches.** A content change can wait on a branch, with its own preview deployment, until it's ready (see [On a branch](/next/releases-and-drafts#on-a-branch)). +- **Rollback.** Undo a change like any other commit (see [Roll back](#roll-back)). + +<Hosted to="/next/hosted-publishing" label="Publishing without a deploy">**Skip the deploy wait.** With the hosted Deco CMS, a commit to your production branch is served as a [release](/next/releases-and-drafts#releases) and reaches running servers on their next background check, with no application rebuild.</Hosted> + +## Roll back + +If Friday's banner breaks the page, redeploy the previous build: its content and code roll back together, so they always fit. To undo only the content, revert the content commit and deploy. Reverting a single code commit doesn't revert content committed after it; see [Backward compatibility](/next/checking#backward-compatibility). + +<Hosted to="/next/hosted-publishing#fast-content-rollback" label="Fast content rollback">**Restore content without a deploy.** With hosted delivery, repoint the production channel to a retained, compatible release. Git reconciliation and code rollback are separate actions.</Hosted> + +## One revision per response + +Content can change while a server is running: in development, when you edit a file, or with a [loader](/next/content#write-a-loader) that updates. Two reads a few milliseconds apart could then see different revisions; a header from one and a footer from the other make an inconsistent page. A client (what `cms.forRelease()` returns; see [`createCMS`](/next/api-reference#createcms-config)) prevents that: its first call picks a revision, and every later `list` and `resolve` on it, including concurrent ones, uses the same one. So make one client per request. + +Follow-up requests from the browser may land on a newer revision. If data must match the HTML, render it in the same response, or send `client.revision()` with the page and read follow-up requests with `cms.forRevision(revision)`. + +A revision only selects content; it isn't a secret and unlocks nothing. + +## Hosted draft identity + +A hosted draft client captures its local release and one draft overlay on first use. Its opaque revision identifies that pair; the draft pointer alone identifies only the overrides. Follow-up clients using the pointer may inherit newer local production. A served composite revision can be pinned only while the CMS retains that pair; it does not instruct the delivery API to fetch a matching base. See [Draft overlays for fast previews](/next/content-delivery#exact-draft-previews). diff --git a/docs/content/next/releases-and-drafts.mdx b/docs/content/next/releases-and-drafts.mdx new file mode 100644 index 00000000..d3f19ebf --- /dev/null +++ b/docs/content/next/releases-and-drafts.mdx @@ -0,0 +1,113 @@ +--- +title: Releases and drafts +nav: Releases & drafts +group: Content and data +order: 14 +kind: docs +description: A release is the content everyone sees. A draft is another set of calls your same code runs. Preview is your app rendering a draft. +--- + +# Releases and drafts + +Marketing has rewritten the home page for the summer sale and wants to see it on the real site before anyone else does. Visitors should keep seeing today's page until the new one is approved. + +This page shows the two versions of content your app can render, the release and a draft, how preview picks between them, and how to check a change when you have no drafts at all. + +Your app always renders one version of your content. Usually that's the release. A draft is another set of calls, run by the same code: only the content differs. + +## Releases + +A **release** is the content everyone sees right now. In the open-source setup, it's the [content module](/next/content#the-content-module) in your build: the `.deco/blocks` files of the commit you deployed. A new release goes out with your next deploy (see [Deployment](/next/releases-and-deployment)). + +`cms.forRelease()` gives you a client, the object you read content with, over the release. `cms` is the object your app creates once with [`createCMS`](/next/content#create-the-cms). + +<Hosted to="/next/hosted-publishing" label="Publishing without a deploy">**Releases without a deploy.** With the hosted Deco CMS, the release is your latest publish, and it reaches running servers on their next background check, with no new application build.</Hosted> + +## Drafts + +A **draft** is unpublished content. Hosted draft transport contains only changed blocks and deletions, layered over the production content the server already has; a custom loader may instead return a complete draft snapshot. Your content [loader](/next/content#write-a-loader) fetches it from wherever it lives: your own storage, or the hosted Deco CMS (see [Previewing drafts](/next/hosted-drafts)). + +The loader is what you pass to `createCMS` as its `content` option: + +```ts +const cms = createCMS({ blocks, content: myLoader }); // myLoader.load(pointer) returns the draft +``` + +A **draft pointer** is a short string that says which draft to load; it travels in `?__draft=` links (see [Draft pointers](/next/api-reference#draft-pointers)). `cms.forDraft(pointer)` passes it to that same loader's `load(pointer)`. The client you get back works exactly like the release client, so your rendering code doesn't change. + +The content module has no drafts: it ignores the pointer, so `forDraft` acts like `forRelease`, apart from any [variants the pointer forces](#preview-a-variant). You get real drafts with the hosted Deco CMS or with a [loader you write](/next/content#write-a-loader), which must treat the pointer as untrusted input. + +## Preview + +Preview is your real app rendering a draft instead of the release. It isn't a separate feature. In your route or page handler (see [Rendering](/next/rendering)), read the pointer from the request with [`cms.draftPointer`](/next/api-reference#draft-pointers) and pick the client: + +```ts +const pointer = await cms.draftPointer(request); // from ?__draft= or the draft cookie; null otherwise, and on hosts previews aren't allowed on +const client = pointer ? cms.forDraft(pointer) : cms.forRelease(); +``` + +Everything after these two lines is the code that serves visitors. It's safe to add even when your content has no drafts: you get the release. + +If a draft can't load, the draft client's `resolve` and `list` return an error instead of published content, so nobody mistakes the release for their draft. What to show is your app's choice, such as a short "this draft couldn't be loaded" page (see [A failed draft is an error](/next/content#write-a-loader)). + +### Preview a variant + +A field with [variants](/next/matchers-and-variants#variants) shows whichever variant's rule is true for you right now, so a preview of the Black Friday banner on a Tuesday in October shows the fallback. To see the other variants, a pointer can **force** them: each forced variant names a `multivariate` block by the saved block it's saved in and its JSON path inside that block, plus the index of the variant to show. That `multivariate` then returns that variant's value without evaluating any rule. + +The site editor's variant tabs work this way: picking the second variant of the hero in `Home` loads the preview with a pointer that forces `Home@sections.3` to variant `1`. Forced variants travel inside the pointer as `__variant` parameters (format in [Draft pointers](/next/api-reference#draft-pointers)), so they need nothing in your code beyond the two lines above: + +- **Only drafts force variants.** `forDraft` applies them; `forRelease` never does, so visitors always get the rules. +- **They work without drafts.** Over the content module, which has no drafts, `forDraft` still applies them, so variant tabs work while you edit on your machine. +- **A stale address is ignored.** If the block was renamed, the section moved, or the variant is gone, the content renders as saved. + +A link can force any variant. That's fine because [variants are not access control](/next/matchers-and-variants#run-an-a-b-test): every variant must be fine to show. + +<Hosted to="/next/hosted-drafts" label="Previewing drafts">**Drafts on your real site.** With the hosted Deco CMS, the site editor opens your real site with a `?__draft=` link and the draft renders in place, with no preview deployment. Only pointers signed by the site editor reach unpublished drafts.</Hosted> + +## Allow previews per host + +Your store has a public domain and a staging host, and drafts belong only on staging: a draft on the public domain could land in your CDN's cache or in a search engine's index. List the hosts previews are allowed on in the `preview` section of your [CMS settings](/next/built-in-blocks#cms-settings), in the site editor's **Settings** or by hand: + +```json title=".deco/blocks/CMS.json" +{ + "__resolveType": "cms-settings", + "preview": { "hosts": ["staging.example.com", "*.preview.example.com"] } +} +``` + +On any other host, `cms.draftPointer` returns `null` and the request gets the release, so a `?__draft=` link there shows published content instead of an error. Without the block, or without `hosts`, every host may preview. An empty list turns previews off everywhere. If you set a list, include the host your dev server runs on, such as `localhost:5173`, so the site editor's previews keep working on your machine. + +- **The list comes from the release.** A draft can't add its own host: the CMS reads `preview.hosts` from the release your server already has, never from the draft being previewed. A changed list takes effect when the release that carries it does. +- **Code can cap the list.** To keep content from ever allowing a host, such as your public domain, give `createCMS` the most content may allow. Content can then only narrow it, and with no `preview` section, code's list applies: + + ```ts title="cms.ts" + export const cms = createCMS({ + blocks, + content, + preview: { hosts: ["*.example.com", "localhost:3000"] }, // the most content may allow + }); + ``` + + With this cap, content can allow `staging.example.com` or `localhost:3000`, but an entry such as `store.attacker.com` is left out. The exact rule for `*`, ports and look-alike hosts is in [Host patterns](/next/api-reference#host-patterns). +- **It's not access control.** Who may see a draft is decided by the signed, expiring grant inside each pointer (see [Who may preview](/next/hosted-drafts#who-may-preview)). The host list keeps drafts off domains, caches and search results where they don't belong. + +## Checking changes without a draft loader + +Without a loader that serves drafts, you check a change by running a different build: your dev server, or your host's preview deployment of a branch. Neither needs a pointer; each build simply has its own release. + +### On your machine + +Your dev app already renders your working tree. Editing a file in `.deco/blocks` reloads the page through your framework's hot module replacement, whether you edit by hand, with an agent, or in the site editor through [`deco serve`](/next/site-editor#edit-on-your-machine). On Vite that takes a few lines of your own; see the [TanStack Start guide](/next/tanstack-start-descriptors#reload-on-content-changes). Nothing is committed: review the diff and commit it when the page looks right. + +### On a branch + +A content change is a change to files, so it can wait on a branch. Open a pull request: reviewers read the diff of `.deco/blocks`, and CI runs `npx @decocms/blocks schema && npx @decocms/blocks check` (see [Backward compatibility](/next/checking#backward-compatibility)). Your host's preview deployment builds the branch with its own content module, so its URL shows that branch's content: + +```bash +git switch -c summer-sale +# edit .deco/blocks/SummerSale.json, by hand or with deco serve +git add .deco/blocks && git commit -m "Summer sale page" +git push -u origin summer-sale # your host's preview deployment renders /summer with the branch's content +``` + +A branch that also changes a block function is checked the same way, so you can try a new field and the content that uses it together. Merging publishes the change (see [Publishing is committing](/next/releases-and-deployment#publishing)). Your host's preview deployment is public unless you protect it with your host's access controls. diff --git a/docs/content/next/renames-and-migrations.mdx b/docs/content/next/renames-and-migrations.mdx new file mode 100644 index 00000000..44915d2e --- /dev/null +++ b/docs/content/next/renames-and-migrations.mdx @@ -0,0 +1,138 @@ +--- +title: Migrating from v7 +nav: Migrating from v7 +group: Getting started +order: 3 +description: Keep v7 content working with aliases, replace v7 loaders, actions, telemetry and analytics, fold site settings into the CMS block, and migrate saved content safely. +--- + +# Migrating from v7 + +Your v7 store's saved pages refer to blocks like `site/sections/Product.tsx`, and its product shelf calls a VTEX loader from the [apps](/v7/apps). You want the new code without rewriting all that content. + +Saved content refers to your code by name: every block stores a type name in [`__resolveType`](/next/blocks#functions-as-blocks), references store saved block names, and inputs are stored by field name. Treat these names like database columns: once content uses them, changing one breaks that content. + +This page shows how to run the migration, how to keep old names working with aliases, what to do with v7 loaders and actions, what replaces v7's telemetry and analytics, how secrets move over, and a [checklist for moving content](#checklist-for-migrating-existing-content). + +## Run the migration + +The migration is an agent skill, `deco-v7-to-v8-migration`, in the `decocms/blocks` repository (`.agents/skills/deco-v7-to-v8-migration`). Point your coding agent at it, and it runs the migration script and walks you through what's left; or run the script yourself. Either way, first install the next major in your site (`npm install @decocms/blocks@^8.1`; not `^8`, because the `8.0.0` on npm is an accidental v7 build), and keep your v7 apps installed until the migration has run: it copies the loaders your content calls from them. Then, from your app root and on a clean working tree, run the script once from a checkout of `decocms/blocks` (clone it and run `bun install` there first): + +```bash +DECO_CRYPTO_KEY=… bun <blocks>/.agents/skills/deco-v7-to-v8-migration/scripts/main.ts --root . +``` + +| Flag | What it does | +|---|---| +| `--root <dir>` | The app root, the folder with your site's `package.json`. Default: the current folder. | +| `--decofile <file>` | Content to split into `.deco/blocks` when the site has none yet: the JSON your v7 site serves at `/.decofile`. | + +It changes your files in place and prints a report of what it did and what's left for you. It writes a block map that keeps your old type names as [aliases](#rename-a-type-with-an-alias), copies the [loaders](#loaders-actions-and-invoke) your content uses, re-encrypts [secrets](#secrets), folds your site's settings into the [`CMS` block](#site-settings), and lists what it can't move by itself, such as [tag IDs](#telemetry-and-analytics). Review the diff and commit it like any other change. + +Next-major apps depend only on `@decocms/blocks`. Remove `@decocms/tanstack` or `@decocms/nextjs`, and `@decocms/blocks-admin`, from your dependencies: they stay on the 7.x line. What they did for your platform is now a few lines of your own code: upstream caching is a fetch you pass to a client (see [Upstream data](/next/caching#upstream-data)), and content in Workers KV is a small loader (see [Example: Workers KV](/next/api-reference#example-workers-kv)). + +Seven v7 type names have no alias in the next major, so the migration rewrites them in your saved content to a name that resolves the same way, with the same props: + +| v7 name | Saved as | +|---|---| +| `website/flags/multivariate/image.ts`, `website/flags/multivariate/message.ts`, `website/flags/multivariate/page.ts`, `$live/flags/multivariate.ts` | `website/flags/multivariate.ts` | +| `$live/matchers/MatchAlways.ts` | `website/matchers/always.ts` | +| `website/matchers/date.ts`, `$live/matchers/MatchDate.ts` | `date` | + +The next major has no async rendering. v7 could defer a section behind a wrapper block, and the migration removes those wrappers from your saved content, pages and saved blocks alike, putting in each one's place the section it held, with its props unchanged: + +| v7 wrapper | Becomes | +|---|---| +| `website/sections/Rendering/Lazy.tsx`, `website/sections/Rendering/SingleDeferred.tsx` (`{ "section": … }`) | its `section` | +| `website/sections/Rendering/Deferred.tsx` (`{ "sections": [ … ] }`) | its `sections`, in place in the list that held it | + +The wrapper's own options (`loading`, `display`, `behavior`) go with it. Blocks resolve on the server, so what used to arrive after the page is in the first HTML. A wrapper that held several sections where only one block fits is reported for you to unwrap by hand. Running the migration again changes nothing. + +<h2 id="rename-a-type-with-an-alias">Rename a block type</h2> + +Aliases work for any rename, not only a v7 migration. An alias is another key in the [block map](/next/blocks#the-block-map) pointing at the same function. Content that stores the old name keeps working, and routing is unaffected because [`matchRoute`](/next/api-reference#matchroute-url-items) looks at each entry's `path` field (see [Pages and routing](/next/routing)), not its type name: + +```ts title=".deco/index.ts" +import type { Blocks } from "@decocms/blocks"; +import productCard from "../src/product-card"; + +export default { + "product-card": productCard, + "site/sections/Product.tsx": productCard, // the name legacy content stores +} satisfies Blocks; +``` + +Legacy page names such as `website/pages/Page.tsx` need no entry: the [CLI](/next/cli)'s alias table maps them to the [built-in `page`](/next/built-in-blocks#pages-and-redirects) (see [Site editor compatibility](/next/studio-compatibility)). Legacy `website/flags/multivariate.ts` blocks map to the built-in `multivariate`, and the alias bridge wraps each variant, a plain `value`, in a [`lazy` block](/next/lazy-blocks#write-a-lazy-block) on the way. + +An alias only works while both names take the same inputs. If the old shape differs, register a function under the old name that converts the old inputs and calls the new function, or migrate the content first. Here the old content called the title `name`: + +```ts +type CardInput = Parameters<typeof productCard>[0]; + +export default { + "product-card": productCard, + "site/sections/Product.tsx": ({ name, ...rest }: Omit<CardInput, "title"> & { name: string }) => + productCard({ ...rest, title: name }), // converts the old inputs +} satisfies Blocks; +``` + +[`deco check`](/next/checking#what-deco-check-checks) rejects an alias that collides with a saved block's name. + +<h2 id="loaders-actions-and-invoke">Loaders, actions and invoke</h2> + +On [v7](/v7/loaders), saved content often calls loaders and actions that ship in Deco's [apps](/v7/apps), such as a VTEX product loader. In the next major the apps are thin [upstream clients](/next/upstream-clients) and ship no loaders, so the migration copies (vendors) the ones your site actually uses into your own code, rewritten over the clients, and registers each under its old type name as an [alias](#rename-a-type-with-an-alias). Content keeps resolving, and the copied code is yours to change from then on. + +v7's `/deco/invoke` endpoint (which ran loaders and actions over HTTP for the site editor and the browser) and `cachedLoader` (see [v7 caching](/v7/caching)) are gone. Call upstream clients from your framework's server functions or route handlers instead; upstream caching is a fetch your app passes to a client (see [Upstream data](/next/caching#upstream-data)). + +<h2 id="telemetry-and-analytics">Telemetry and analytics</h2> + +v7 configured telemetry with environment variables and shipped its analytics as components. In the next major, they're two separate parts of Deco CMS: code says where each one sends, and the [`CMS` block](/next/built-in-blocks#cms-settings) holds their switches and rates. + +| v7 | Next major | +|---|---| +| The OpenTelemetry export, switched on with `DECO_OTEL` and the `DECO_OTEL_*` endpoint and header variables (see [v7 observability](/v7/observability)) | The [`telemetry` option](/next/telemetry#choose-where-telemetry-goes) of `createCMS`: `{ endpoint, headers? }` for your own collector, or the standard `OTEL_EXPORTER_OTLP_ENDPOINT` and `OTEL_EXPORTER_OTLP_HEADERS` variables. Sampling moves to the `telemetry` section of the [`CMS` block](/next/telemetry#telemetry-settings-are-content). | +| Metrics Deco collected for your site in its own backend | `telemetry: { site, token }`, set explicitly (see [Hosted telemetry](/next/hosted-telemetry)) | +| The `Stats` and `OneDollarStats` components, and the `DECO_ANALYTICS_ENABLED` and `ONEDOLLAR_ENABLED` variables | `AnalyticsScript` in your root layout, with the `analytics` section of [`cms.settings()`](/next/analytics), which speaks the [One Dollar Stats](/next/analytics#one-dollar-stats) format. Rendering it turns analytics on; the section's `enabled` field switches it off. | +| The `Analytics` section with Google Tag Manager and GA4 tags | A tag manager block from your [platform template](/next/how-it-works#key-terms), owned by your site; the migration lists the tag IDs in its report. Built-in analytics only counts page views, so it doesn't take them. | +| Experiment results keyed on the random matcher's saved-block name | The `experiment` ID of the [A/B test](/next/matchers-and-variants#run-an-a-b-test); the migration copies the old name into it, so results carry over. | + +<h2 id="site-settings">Site settings</h2> + +The next major keeps site-wide settings in one saved block, `CMS`, of the built-in type [`cms-settings`](/next/built-in-blocks#cms-settings). The migration folds what it finds into `.deco/blocks/CMS.json`, keeping anything already there, and writes nothing when there's nothing to fold: + +| From | Into `CMS.json` | +|---|---| +| The v7 Site block's (`Site` or `site`) `previewHosts` | `preview.hosts`, entry for entry, each trimmed and lowercased as v7 compared it. v7 compared hosts exactly, port included, and so does an entry with a port here (see [Host patterns](/next/api-reference#host-patterns)). An entry that isn't a host is left out and listed in the report. On TanStack Start, v7 also allowed `<site>.deco.site` and `<site>.deco-cx.workers.dev` for the site named by `DECO_SITE_NAME`; the migration adds both when it finds the name in `wrangler.*`, `.env` or the Vite config, and lists them in the report when it doesn't. The rest of the Site block stays where it is. | +| A literal `collectorAddress` on v7's `OneDollarStats` component in your code | `analytics.collector` | +| A `Telemetry` saved block (type `telemetry`) from an earlier next-major prerelease | The `telemetry` section, field for field; the old file is deleted | +| An `Analytics` saved block (type `analytics`) from an earlier next-major prerelease | The `analytics` section, field for field; the old file is deleted | + +Variants come along unchanged: a saved block that was a `multivariate` becomes the same `multivariate` in its section. The `telemetry` and `analytics` built-ins are gone, with no alias, so a leftover block of either type fails [`deco check`](/next/checking) until it's folded. Running the migration again changes nothing. + +Settings v7 read from your hosting environment never reach the repository, so the report lists them for you to move by hand: + +- `DECO_ALLOWED_PREVIEW_HOSTS` replaced the Site block's list. Put its hosts in `CMS.json`'s `preview.hosts`, or, to make them the most content may allow, in [`preview.hosts`](/next/releases-and-drafts#allow-previews-per-host) of `createCMS`. Its `none` is an empty list. +- `DECO_OTEL_*` sampling variables become the `telemetry` section's rates, within the [`limits`](/next/telemetry#sampling) in code. +- `DECO_ANALYTICS_ENABLED` and `ONEDOLLAR_ENABLED` become the `analytics` section's `enabled`, and `ONEDOLLAR_COLLECTOR` its `collector`. + +A v7 site with no allowed hosts had draft preview off. In the next major, previews work on every host unless you list some, so add `preview.hosts` if you relied on that. + +Where you render analytics, replace the resolved `Analytics` block with the settings: `const { analytics } = await cms.settings()`, then `<AnalyticsScript {...analytics} />` (see [Analytics](/next/analytics#1-render-the-script)). + +<h2 id="secrets">Secrets</h2> + +v7 saved credentials as `website/loaders/secret.ts` blocks, encrypted with the `DECO_CRYPTO_KEY` environment variable. The next major uses a key pair instead (see [Secrets](/next/built-in-blocks#secrets)), so the migration re-encrypts each secret once: + +1. [Create the key pair](/next/built-in-blocks#create-the-keys) and commit `.deco/secrets.pub`. +2. Run the migration with `DECO_CRYPTO_KEY` set. It decrypts each v7 secret and saves it as a `secret` block encrypted with your public key, in the same commit as the rest of the content. +3. Deploy with `DECO_SECRETS_KEY` set, then remove `DECO_CRYPTO_KEY`. + +## Checklist for migrating existing content + +1. **Keep existing names.** If the content stores file-path type names, register them as they are or alias them, including the app loaders and actions you vendored. +2. **Check inputs.** Run [`deco check`](/next/checking): it validates every saved block against the [schema](/next/schema#from-types-to-forms) and lists each one that no longer fits. It can't see changed defaults, so review those yourself. +3. **Migrate the content in one commit.** A script reads the JSON in `.deco/blocks`, rewrites it, and writes it back. The commit is a new [revision](/next/releases-and-deployment#what-a-revision-is) you can revert in one step. Keep fields the script doesn't recognize instead of deleting them, and report references to missing entries (`NOT_FOUND`) instead of dropping them. +4. **Test the integration.** Load real pages against the migrated content: routes resolve, titles and SEO tags are right, blocks that load later (if your framework streams them) appear, failing blocks show their fallback, and navigation works. +5. **Keep a rollback pair.** Keep the previous build plus the content revision it was tested with. Rolling back content alone can't bring back code you removed. + +Test behavior, not just schemas. A function can keep its input type and still change its output, defaults, or upstream calls. diff --git a/docs/content/next/rendering.mdx b/docs/content/next/rendering.mdx new file mode 100644 index 00000000..62cad60d --- /dev/null +++ b/docs/content/next/rendering.mdx @@ -0,0 +1,55 @@ +--- +title: Rendering +nav: Rendering +group: Websites +order: 17 +kind: docs +--- + +# Rendering + +Your product page has a promo bar and a product hero with an interactive "Add to cart" button. Each is a [block function](/next/blocks#functions-as-blocks) that returns something, but what actually reaches the browser, and does the cart button still work there? + +This page shows the two ways a block becomes UI, how to pick one, and the rules that hold in both. + +- **Descriptors (data mode):** the block returns plain JSON such as `{ component: "product-hero", props: {…} }`, and a view registry in your app (code that maps each `component` name to a React component) renders it on the server and again in the browser. +- **React Server Components (RSC):** the block returns JSX that renders only on the server. React sends the result to the browser in its own wire format, called Flight, and only components marked `"use client"` ship JavaScript. + +Deco CMS returns whatever your block function returns, so it works with both. Each framework guide uses one: + +| Guide | Block returns | Streaming | Ships to the browser | +|---|---|---|---| +| [Next.js](/next/nextjs) | JSX | Server Component Suspense boundaries | Client Components | +| [TanStack Start](/next/tanstack-start-descriptors) | Serializable descriptors | Deferred promises with `Await` | The view registry and its components | +| [TanStack Start + RSC](/next/tanstack-start-rsc) | JSX | RSC stream (Flight) | Client references and the RSC runtime | + +You already render the banner as a plain component: `<PromoBanner title="Free shipping over $50" />`. The same block in both modes: + +```tsx title=".deco/index.tsx" +// RSC (Next.js, TanStack Start + RSC): the block returns a ready element +"promo-banner": (input: PromoBannerProps) => <PromoBanner {...input} />, + +// Data mode (TanStack Start): the block returns a descriptor… +"promo-banner": (input: PromoBannerProps) => ({ component: "promo-banner", props: input }), +// …and your view registry renders it +const views = { "promo-banner": PromoBanner }; +``` + +## Which mode to use + +Use descriptors when views must render in an ordinary browser React app. Use RSC when your framework supports it and you want server-only view code to stay off the client. RSC doesn't guarantee smaller responses, so measure payload size, hydration and navigation on real pages. + +## Block functions run on the server + +In all three guides, block functions run on the server, so data fetching belongs there. In data mode, a block returns a descriptor and a synchronous React view renders its props. With RSC, a block returns JSX that can include Client Components. + +Don't put a Client Component (a component from a `"use client"` file) in the [block map](/next/blocks#the-block-map) directly. On the server, importing it gives you a placeholder React can render but your code can't call, and Deco CMS calls every block function. Wrap it instead: `cart: (input: CartProps) => <CartButton {...input} />`. Block functions should only read; see [The rule in full](/next/how-resolution-works#the-rule-in-full). + +## Errors, streaming, and cancellation + +- **Start every block first.** Await critical data, such as SEO, only after all of the page's blocks have started, so they fetch in parallel instead of waiting behind the SEO call. +- **One Suspense boundary per block** lets each block appear as soon as it's ready. +- **Suspense handles loading, not errors.** Each [`client.resolve(block)`](/next/api-reference#client-resolve-target-options) returns `[value, error]`; render a fallback when `error` is set. Errors thrown later while React renders need an error boundary. +- **A hidden block is not an error.** A block an editor [hid](/next/matchers-and-variants#hide-a-block) resolves to `undefined`, with no error: render nothing for it. Blocks never renders anything itself, so what to show is always your app's choice. +- **Cancellation is your app's job.** Deco CMS never sees the request. A block that fetches upstream should take the request's `signal` from wherever your app keeps request state. +- **Once streaming starts, the status code is fixed.** The status line and headers are already sent, so a later error can't change them. Decide 404s and redirects (the [`matchRoute`](/next/api-reference#matchroute-url-items) result) before you stream. diff --git a/docs/content/next/router-internals.mdx b/docs/content/next/router-internals.mdx new file mode 100644 index 00000000..e5e31f25 --- /dev/null +++ b/docs/content/next/router-internals.mdx @@ -0,0 +1,36 @@ +--- +title: How matchRoute matches +nav: Router internals +group: Under the hood +kind: internals +order: 4 +--- + +# How `matchRoute` matches + +The naive version of this function walks every entry and tests each path against the URL. That's fine for fifty pages and wrong for fifty thousand. [`matchRoute`](/next/api-reference#matchroute-url-items) instead compiles the entries into a **segment trie**, once per list, and matches in time proportional to the URL's length, not the catalog's size. This page shows how that trie is built and walked, and which entry wins when several could match. + +A segment is one piece of a URL path between slashes: `/blog/hello` has the segments `blog` and `hello`. A trie is a tree where each edge is one segment, so paths that start the same share nodes. + +```text +/summer /:slug/p /blog/:slug /blog/archive + ┌── root ──┐ + │ │ + ┌───────────────────────┼───────────┼──────────────────┐ + "summer" "blog" :slug + │ │ │ + [SummerPage] ┌──────────┴──────────┐ "p" + "archive" :slug │ + │ │ [ProductPage] + [BlogArchive] [Post …] +``` + +<Small>Top: four route paths. Below: the trie built from them. Quoted words are literal segments, `:slug` is a parameter, and \[Brackets\] mark the entry stored at that node.</Small> + +1. **Build, once per list.** Every path is split on `/` and inserted segment by segment. A literal segment becomes a named child; a `:param` segment becomes the node's single parameter child; a trailing `*` becomes the node's splat slot. The entry is stored at the leaf. Cost is the total number of segments, and the result is cached in a `WeakMap` keyed by the array you passed, so the second call with the same array skips this entirely. A new array rebuilds the trie, which is one pass over the paths. +2. **Resolve ambiguity while building.** Two entries reaching the same leaf, or two parameter children under one node (`/:slug/p` and `/:id/p` have the same shape, so every URL that matches one matches the other), are a conflict: the entry earlier in the array keeps the leaf, so a lookup never throws. The CLI runs the same build in [`deco check`](/next/checking#what-deco-check-checks) and reports the conflict with both names, so CI catches it (see [Backward compatibility](/next/checking#backward-compatibility) and [CI on site editor commits](/next/hosted-site-editor#ci-on-site-editor-commits)). +3. **Match, one segment at a time.** Walk the URL's segments from the root. At each node try the literal child first, then the parameter child, then the splat, which takes every remaining segment (at least one) and ends the walk. If a branch dead-ends, go back up and try the option you skipped. Each node has at most one literal match, one parameter and one splat, so a lookup visits about as many nodes as the URL has segments, however many entries exist. +4. **Precedence falls out of the walk order.** Trying literal, then parameter, then splat at every node is exactly "exact paths win over parameters, and parameters win over a splat", per segment rather than per path: `/blog/archive` beats `/blog/:slug` for `/blog/archive`, `/blog/:slug` still serves `/blog/hello-world`, and `/blog/*` only gets what neither can serve, such as `/blog/2024/hello`. No sorting, no regex. +5. **[Redirects](/next/routing) are a second trie**, checked first, with the same rules. A hit fills the parameters, and a splat's segments, into the redirect's `to` path and returns `{ kind: "redirect", location, status }`. + +Before matching, the URL is normalized: percent-decoded, query and fragment dropped, trailing slash removed except for `/`. Parameters capture one segment and never contain a slash; a splat captures the rest, slashes included, as `params["*"]`. When a redirect copies captured values into `to`, they're percent-encoded again, so `/old/%2F%2Fevil.example` can't become `//evil.example`, a link to another site. That's the whole algorithm; it's the same shape as the routers in Fastify and Hono, and it's about a hundred lines. diff --git a/docs/content/next/routing.mdx b/docs/content/next/routing.mdx new file mode 100644 index 00000000..675a284e --- /dev/null +++ b/docs/content/next/routing.mdx @@ -0,0 +1,261 @@ +--- +title: Pages and routing +nav: Pages & routing +group: Websites +order: 16 +kind: docs +--- + +# Pages and routing + +Marketing wants a summer campaign live at `/summer` today, and the old `/campaigns/summer` link from last year's emails should land there too. In most apps, each of those is a code change and a deploy. + +With Deco CMS, an editor saves the page and the redirect in [the site editor](/next/site-editor), and one route you wrote once serves them both. + +This page shows how pages and redirects are saved, how one catch-all route serves them with `matchRoute`, and how to give your own types a URL. + +Without a CMS, every page is a route file and every redirect is a line in your framework's config: + +```tsx +// app/summer/page.tsx: one file per page, written and deployed by a developer +export default function SummerPage() { return <Hero title="Summer starts here" />; } + +// next.config.ts: one entry per redirect +redirects: async () => [{ source: "/campaigns/summer", destination: "/summer", permanent: true }], +``` + +With Deco CMS, pages and redirects are saved [blocks](/next/blocks). + +## Pages and redirects are content + +`page` and `redirect` are [built-in blocks](/next/built-in-blocks#pages-and-redirects), so you don't declare them. Anything with a URL extends the `Route` interface, exported from `@decocms/blocks`: + +```ts +/** An entry that lives at a URL. Extend it to make a type routable. */ +interface Route { + /** @title Name */ + name: string; + /** @title Path */ + path: string; // "/summer", or a template like "/:slug/p" or "/docs/*" +} + +/** The built-in `page` block: a route with SEO and a list of blocks. */ +interface Page extends Route { + seo?: Seo; // optional: without it, your site's SEO defaults apply + sections: ReactNode[]; +} + +/** The built-in `redirect` block. */ +interface Redirect { + from: string; // literal or template + to: string; + permanent: boolean; // 301 or 302 + status?: 301 | 302 | 307 | 308; // wins over permanent + discardQueryParameters?: boolean; // drop the request's query string instead of carrying it over +} +``` + +A page's fields are typed as what it receives, already resolved: a `Seo` object and a list of rendered blocks. A page saved without `seo` gets `undefined` there, and your app falls back to its site-wide title and description ([how the site editor fills them](/next/schema#from-types-to-forms)). In the saved JSON, `seo` is a block and `sections` is a list of blocks: + +```json title=".deco/blocks/SummerPage.json" +{ + "__resolveType": "page", + "name": "Summer campaign", + "path": "/summer", + "seo": { "__resolveType": "SummerSEO" }, + "sections": [ + { "__resolveType": "hero", "title": "Summer starts here", "image": "https://cdn.example.com/summer.jpg" } + ] +} +``` + +```ts +// What client.resolve("SummerPage") returns: seo and every block in sections resolved. +{ + name: "Summer campaign", + path: "/summer", + seo: seo({ title: "Sunny!", description: "Light layers for long days." }), // SummerSEO, expanded + sections: [hero({ title: "Summer starts here", image: "https://cdn.example.com/summer.jpg" })], +} +``` + +```json title=".deco/blocks/LegacySummer.json" +{ + "__resolveType": "redirect", + "from": "/campaigns/summer", + "to": "/summer", + "permanent": true +} +``` + +**The router's rule is one line: an entry with a string `path` matches at that path.** `Route` makes that intentional: the [CLI](/next/cli) checks that any block type with a `path` extends `Route`, so a stray `path` field on an unrelated block is a schema error, not a surprise URL. Keep `path` and `from` plain strings, not blocks, so the router can match without running code. To add fields to pages, [change the built-in](/next/built-in-blocks#change-a-built-in). + +## Route a request + +Deco CMS doesn't route; it lists and resolves, and `matchRoute` is a helper. Routing is three steps in your request handler: list the types you want at URLs, hand them to [`matchRoute`](/next/api-reference#matchroute-url-items), and resolve the winner. Do all three with one client from `cms.forRelease()`, so they see the same content even if it changes mid-request (see [One revision per response](/next/releases-and-deployment#one-revision-per-response)). + +```ts +import { matchRoute, type Redirect } from "@decocms/blocks"; +import type { ReactNode } from "react"; +import type { ResolvedPage, StoredPage } from "./model"; // see The example project below +import type { Post } from "./post"; +// cms comes from cms.ts (see Quickstart); request is the incoming Request; render/renderPost are your own view code +// error handling omitted: check the second tuple element in real code + +const client = cms.forRelease(); +const [pages] = await client.list<StoredPage>("page"); // as saved: seo and sections are still blocks +const [posts] = await client.list<Post>("post"); +const [redirects] = await client.list<Redirect>("redirect"); + +const match = matchRoute(request, { routes: [...pages, ...posts], redirects }); + +switch (match.kind) { + case "not-found": + return new Response("Not found", { status: 404 }); + case "redirect": + return Response.redirect(new URL(match.location, request.url), match.status); + case "match": { + const entry = match.entry; + if ("__resolveType" in entry && entry.__resolveType === "post") { + return renderPost(entry as Post); // already the saved post + } + const [page] = await client.resolve<ResolvedPage<ReactNode>>(entry); // seo and sections resolved + return render(page.seo, page.sections); + } +} +``` + +`Route` types describe what an editor fills in, so they don't declare `__resolveType`. The saved entry still has it, so check it with `in` before comparing. + +Mount this handler as your app's catch-all route, the one that runs when no other route matches. To send each block as soon as it's ready instead of resolving the whole page, see [Stream each block](/next/nextjs#7-stream-each-block-optional). `matchRoute` reads redirects as listed, so routing never resolves them. + +| `kind` | Fields | +|---|---| +| `"match"` | `entry` (the stored route you passed in) and `params` from the path template. | +| `"redirect"` | `location` with parameters filled in and the request's query string carried over (unless the redirect sets `discardQueryParameters`), and `status`: the redirect's `status`, else `301` or `302` from `permanent`. | +| `"not-found"` | None | + +<Callout>**`matchRoute` is a helper, not a requirement.** Deco CMS has no idea what a URL is. If your framework already routes, or you'd rather open a page by name (`client.resolve("SummerPage")` returns it ready to render), skip it. It exists because "an editor typed a path and that page is now live" is the common case, and getting precedence and parameters right by hand is tedious.</Callout> + +Which types route is your decision, per handler: a blog lists `post`, a docs site lists `doc`. Deco CMS never injects `match.params` into blocks; share them the way your app shares request state. For example, with Node's `AsyncLocalStorage`: `export const routeParams = new AsyncLocalStorage<Record<string, string>>();`, wrap rendering in `routeParams.run(match.params, () => render(…))`, and a block reads `routeParams.getStore()?.slug`. + +Matching is a lookup, not a scan: a lookup costs the URL's depth, not the number of routes. To skip rebuilding the lookup on each request, reuse one `routes` array per [`client.revision()`](/next/api-reference#createcms-config); details are in [Router internals](/next/router-internals). + +<h2 id="path-templates-and-match-order">Match order</h2> + +A route's `path` and a redirect's `from` accept literal segments and named parameters. A route at `/:slug/p` matches `/summer/p` with `params.slug = "summer"`. + +A path can also end with `/*`, a **splat**, to match whatever comes after it, one or more segments. A redirect from `/old-blog/*` to `/blog/*` sends `/old-blog/2024/hello` to `/blog/2024/hello`, and a route at `/docs/*` gets the rest of the path in `params["*"]` (`"guides/setup"` for `/docs/guides/setup`). A splat never matches zero segments, so `/docs` needs its own entry. In a redirect's `to`, the captured segments stay percent-encoded, so a crafted URL can't turn the redirect into a jump to another site. + +- Redirects are checked before routes, so a redirect overrides a route at the same path. +- Within each group, exact paths win over parameters, and parameters win over a splat: `/docs/setup` beats `/docs/:page`, which beats `/docs/*`. +- Two entries with the same path, or two templates that can match the same URL, are a conflict. [`deco check`](/next/checking#what-deco-check-checks) reports it; at request time `matchRoute` picks the one earlier in the array instead of throwing. + +Serve framework endpoints, API routes and static assets from their own routes, so they never reach this catch-all. + +## Your own routable types + +A blog doesn't want a `page` entry per post. Make `Post` extend `Route`, declare it in your block map, and pass the saved posts to `matchRoute` along with your pages, as the handler above does: + +```ts title="src/post.ts" +import type { Route } from "@decocms/blocks"; + +export interface Post extends Route { + /** + * @title Published on + * @format date + */ + date: string; + /** @format rich-text */ + body: string; +} +``` + +```ts title=".deco/index.ts" +import type { Blocks } from "@decocms/blocks"; +import type { Post } from "../src/post"; + +const post = (props: Post) => props; // data only: returns what the editor saved +export default { /* your other blocks, */ post } satisfies Blocks; +``` + +```json title=".deco/blocks/HelloWorld.json" +{ "__resolveType": "post", "name": "Hello, world", "path": "/blog/hello-world", "date": "2026-09-01", "body": "…" } +``` + +`client.resolve(match.entry)` returns a page with `seo` and `sections` resolved; a post comes back as saved, so call it on a post only if the post contains blocks. + +Because the URL is a field, linking to an entry is reading it, and listing a routable type gives you an index: + +```ts +const [posts] = await client.list<Post>("post", { sort: (a, b) => b.date.localeCompare(a.date) }); +for (const post of posts) link(post.path, post.name); // "/blog/hello-world", "Hello, world" +// link() stands in for your framework's <Link> or an <a href> +``` + +## Types without a URL + +A menu, an email template or campaign settings work the same way, minus `Route`: declare `menu: (props: Menu) => props` in your [block map](/next/blocks#the-block-map) and save entries with `"__resolveType": "menu"` (see [Data-only blocks](/next/schema#data-only-blocks)). + +## The example project + +The framework guides share these files: the model in `src/model.ts`, the post type in `src/post.ts` (from [Your own routable types](#your-own-routable-types)), and two entries in `.deco/blocks`. The page is a product page with two blocks, a `promo-banner` (a free-shipping bar) and a `product-hero`. Each guide's block map registers `seo`, `promo-banner`, `product-hero` and `post`. + +```ts title="src/model.ts" +import type { Block, Route, Seo } from "@decocms/blocks"; + +export interface PromoBannerProps { title: string; href: string; } +export interface ProductHeroProps { name: string; price: number; currency: string; image: string; } + +// What a block returns in data mode: a component name and its props. +export type BlockDescriptor = + | { component: "promo-banner"; props: PromoBannerProps } + | { component: "product-hero"; props: ProductHeroProps }; + +// A saved page, as client.list returns it by default (nothing run): seo and sections are still JSON. +export interface StoredPage extends Route { + seo?: Seo | Block; + sections: Block[] | Block; // a list of blocks, or one multivariate block that picks a whole list +} + +// What client.resolve returns for a page: seo and every block in sections resolved. +export interface ResolvedPage<T> extends Route { seo?: Seo; sections: T[]; } +``` + +- `BlockDescriptor` is what a block returns in data mode (see [Rendering](/next/rendering)). +- `Block` is a block as stored, `{ "__resolveType": "…", …inputs }`. `StoredPage` needs it because [`client.list`](/next/api-reference#client-list-type-options) returns pages before anything runs, so a guide can resolve each block separately and stream it. If an editor gave the whole list [variants](/next/matchers-and-variants), `sections` is one `multivariate` block. +- `ResolvedPage<T>` is what the `page` block returns, with `T` being what your blocks return. The [TanStack Start guide](/next/tanstack-start-descriptors) uses `ResolvedPage<BlockDescriptor>`; the JSX guides keep the built-in `Page`. + +```json title=".deco/blocks/ShirtPage.json" +{ + "__resolveType": "page", + "name": "Summer shirt", + "path": "/:slug/p", + "seo": { + "__resolveType": "seo", + "title": "Summer shirt | Deco example", + "description": "Light cotton shirt for hot days." + }, + "sections": [ + { "__resolveType": "promo-banner", "title": "Free shipping over $50", "href": "/shipping" }, + { + "__resolveType": "product-hero", + "name": "Summer shirt", + "price": 49, + "currency": "USD", + "image": "/images/summer-shirt.jpg" + } + ] +} +``` + +```json title=".deco/blocks/LegacyProduct.json" +{ + "__resolveType": "redirect", + "from": "/old-products/:slug", + "to": "/:slug/p", + "permanent": true +} +``` + +The redirect sends `/old-products/summer` to `/summer/p`. The hero uses literal values to keep the example small; on a real store, its block function fetches the product for the slug through an [upstream client](/next/upstream-clients#write-a-client), reading the route params from your app's request state (see [Reading the request](/next/blocks#reading-the-request)). diff --git a/docs/content/next/saved-blocks.mdx b/docs/content/next/saved-blocks.mdx new file mode 100644 index 00000000..4504dc79 --- /dev/null +++ b/docs/content/next/saved-blocks.mdx @@ -0,0 +1,111 @@ +--- +title: Saved blocks +nav: Saved blocks +group: Blocks +kind: docs +order: 5 +description: Save a block under a name, refer to it from anywhere, and override its arguments for one use. Saved blocks are JSON files in .deco/blocks. +--- + +# Saved blocks + +The same summer card shows up on the home page and on three category pages. In code you'd extract it into a constant and import it everywhere. With [Blocks](/next/blocks), you save the call once, as `.deco/blocks/SummerCard.json`, and refer to it by name. Change the file and all four pages change. + +This page shows what lives in the `.deco` folder, then how to save a block, reuse it, override its arguments for one use, and how saved blocks get edited. + +## The `.deco` folder + +Before saving and reusing blocks, it helps to know where they live. Deco CMS keeps everything it reads in one folder, `.deco/`, in your app root (the folder with your app's `package.json`). Saved blocks go in `.deco/blocks`, next to the files that describe your code: + +```text +.deco/ +├── index.ts your block map (.tsx also works) +├── blocks/ your saved blocks, one JSON file each +│ ├── HomePage.json +│ ├── SummerCard.json +│ └── SummerSEO.json +├── schema.gen.json generated by deco schema, committed +└── blocks.gen.ts generated by deco content, gitignored +``` + +This table tells the files apart: + +| File | What's in it | Who writes it | Commit it? | +| --- | --- | --- | --- | +| `index.ts` | The [block map](/next/blocks#the-block-map): the functions content can call | You | Yes | +| `blocks/` | Saved blocks, one JSON file each | You, an AI agent or the site editor | Yes | +| `schema.gen.json` | The editor forms, built from `index.ts` (see [Forms from types](/next/schema)) | `deco schema` | Yes | +| `blocks.gen.ts` | The [content module](/next/content#the-content-module), built from `blocks/` | `deco content` | No, gitignore it | + +Don't edit `schema.gen.json` or `blocks.gen.ts`: the `.gen.` in a name marks a generated file. `index.ts` and `blocks/` are yours. Every `deco` command finds this folder by walking up from where you run it, or takes `--root` (see [Finding the `.deco` folder](/next/cli#finding-the-deco-folder)). + +## Save a block + +A **saved block** is a block with a name. Each one is a JSON file in `.deco/blocks`, and the file name without `.json` is its name. Here's a product card (see [Composing blocks](/next/blocks#composing-blocks)), saved as `SummerCard`: + +```json title=".deco/blocks/SummerCard.json" +{ + "__resolveType": "product-card", + "title": "Summer collection", + "product": { "__resolveType": "catalog-product", "slug": "summer-shirt" } +} +``` + +What's saved is the call, not its result: `catalogProduct` runs each time the card is resolved, so the product stays current. + +## Reuse a block + +Refer to a saved block by putting its name in `__resolveType`. `{ "__resolveType": "SummerCard" }` means whatever is saved under `SummerCard`, written in place, so a home page and a campaign page can show the same card, and editing `SummerCard.json` updates every place that uses it: + +```jsonc +// Saved as JSON, in HomePage.json +{ + "__resolveType": "page", + "path": "/", + "sections": [{ "__resolveType": "SummerCard" }] +} +``` + +From your code, pass the name to [`client.resolve`](/next/api-reference#client-resolve-target-options). A string is always the name of a saved block: + +```tsx +const [card, error] = await client.resolve("SummerCard"); +``` + +In [the site editor](/next/site-editor), a field lists the saved blocks that fit its type, and picking one stores this kind of reference (see [Interchangeable blocks](/next/schema#interchangeable-blocks)). + +<Callout>**Reading without running.** `client.resolve(target, { run: false })` returns the JSON that would be called, with saved blocks written in place and nothing run. It's handy for previews, tooling and debugging. Code that handles this saved JSON types it as `Block`, `{ __resolveType: string; …arguments }`; props never do. See [`client.resolve`](/next/api-reference#client-resolve-target-options).</Callout> + +## Override arguments + +A reference can add arguments. They're merged over the saved ones for that one use, and the saved block itself doesn't change: + +```jsonc +// The call +seo({ title: "Summer sale", description: "Light layers for long days." }); + +// Saved as JSON +// Saved as SummerSEO +{ "__resolveType": "seo", "title": "Sunny!", "description": "Light layers for long days." } + +// A reference to it, anywhere in your content +{ "__resolveType": "SummerSEO", "title": "Summer sale" } +``` + +The merge is `{ ...saved, ...arguments }`, so overrides are shallow: an override replaces a whole top-level property. The merged result is then looked up again by its `__resolveType`, by [the lookup rule](/next/blocks#the-lookup-rule). + +## Editing saved blocks + +Saved blocks are plain files, so you can edit them three ways: + +- **By hand**, in your code editor. +- **With an AI agent**, which edits JSON with the tools it already has. +- **In [the site editor](/next/site-editor)**, which shows a form for each block and writes the file for you. + +Because they're files in Git, every edit is a commit you can review and revert. Publishing is pushing to your production branch: your next deploy serves it (see [Publishing is committing](/next/releases-and-deployment#publishing)). + +## Names + +A saved block's name shares one registry with your block types (see [the lookup rule](/next/blocks#the-lookup-rule)), so it can't be the name of a block type, a [built-in block](/next/built-in-blocks) such as `page` or `lazy`, or an alias (a second name for a block type, used when you rename one; see [Rename a block type](/next/renames-and-migrations#rename-a-type-with-an-alias)). At runtime the function wins and you get a warning; [`deco check`](/next/checking) makes it an error, so you catch it before you deploy. + +These docs name block types in `kebab-case` (`product-card`) and saved blocks in `PascalCase` (`SummerCard`), which keeps the two apart at a glance. diff --git a/docs/content/next/schema.mdx b/docs/content/next/schema.mdx new file mode 100644 index 00000000..a50f8b6b --- /dev/null +++ b/docs/content/next/schema.mdx @@ -0,0 +1,143 @@ +--- +title: Forms from types +nav: Forms from types +group: Built on Blocks +kind: docs +order: 7 +--- + +# Forms from types + +You already describe your banner's inputs in TypeScript: `title: string`, `image: string`, `endsOn?: string`. Normally you'd also build an admin form by hand, with a text box, an image picker and a date picker, and keep it in step with the type forever. With Deco CMS, `deco schema` reads the type and writes the form description for you, `.deco/schema.gen.json`, and [the site editor](/next/site-editor) shows editors that form. It only accepts values your function can take. + +This page shows how types become fields, how blocks that return the same type become interchangeable, how to pick widgets with JSDoc tags, and how to declare data-only blocks. + +## From types to forms + +`deco schema` reads the default export of `.deco/index.ts` (or `.deco/index.tsx`), your [block map](/next/blocks#the-block-map), and writes `.deco/schema.gen.json` (flags in [CLI](/next/cli#deco-schema-and-deco-content)). Don't edit `schema.gen.json`: it's generated. Each key's form comes from its function's first parameter. Nothing else in the file is read. + +```bash +npx @decocms/blocks schema +``` + +It finds `.deco/` by walking up from the current folder, or takes `--root` ([how it finds the folder](/next/cli#finding-the-deco-folder)). Commit `.deco/schema.gen.json` with your code. + +For each block, `deco schema` reads the type of the function's first parameter. Each property becomes a field: + +| TypeScript type | Field in the site editor | +|---|---| +| `string` | A text box | +| `number` | A number field | +| `boolean` | A toggle | +| A union of string literals (`"sm" \| "md" \| "lg"`), or an `enum` | A select | +| An array | A list editors can add to and reorder | +| An object, such as `Product` or `Seo` | A group of fields, or a block (see below) | +| An optional property (`endsOn?: string`) | An optional field | +| `ReactNode` / `ReactNode[]` | A choice of components: one, or a list (see [Interchangeable blocks](#interchangeable-blocks)) | +| `Secret`, from `@decocms/blocks` | A write-only password box; the value is saved encrypted (see [Secrets](/next/built-in-blocks#secrets)) | + +**Think in functions.** A field's type is the value your function *receives*, already resolved. The site editor fills the field with a plain value of that type or, for objects and JSX, with a block whose function returns that type. The return type is awaited, so a function that returns `T` and one that returns `Promise<T>` both fit: + +```ts +interface ProductCardProps { + title: string; + product: Product; // The site editor offers any block whose function returns a Product, e.g. catalogProduct +} +``` + +`deco schema` reads each block function's return type to know which functions fit which fields: + +- **Objects** (`product: Product`, `seo: Seo`) take a plain value, or a block whose function returns that type, such as `catalogProduct` or `seo`. +- **`ReactNode` and `ReactNode[]`** (`sections: ReactNode[]`) take functions that return JSX, sync or async (Server Components): one, or as many as you like. Only JSX counts. TypeScript's `ReactNode` also includes strings, numbers and booleans, but functions that return those aren't offered here, so a [matcher](/next/matchers-and-variants) (a function that returns a `boolean`) never shows up as a section. +- **Simple types** (`string`, `number`, `boolean`, a union of literals) get a plain input only. A function that returns a `string` isn't offered for `title: string`, and matchers aren't offered for `permanent: boolean`. + +Two things work on top of this. Any field can have [variants](/next/matchers-and-variants#variants), alternate content picked per request (by date, or by any rule you write): `multivariate<T>` is generic, so `T` becomes the field's type, a `string` for a title or `ReactNode[]` for a whole `sections` list. Each `rule` in `variants` is the one `boolean` field that offers blocks: any function that returns a `boolean` (a matcher). And a [`Lazy<T>` field](/next/lazy-blocks#lazyt-props) gets the form of `T`: `deco schema` makes it a `lazy` block whose `value` is a `T`, editors fill in a `T`, and the site editor writes the `lazy` block around it. + +Never type a prop as a block. `Block` is only the shape of saved JSON (see [Composing blocks](/next/blocks#composing-blocks)). + +A date, an image and rich text are all `string`s to TypeScript; a JSDoc `@format` tag tells the site editor which input to show (see [Widgets](#widgets)). + +### Interchangeable blocks + +Any block whose function returns the field's type can fill it, so editors can swap one for another without touching code. This is polymorphism, and it comes from the schema: `deco schema` records each function's return type, so the schema already lists which functions fit which field, the way TypeScript would match them. The site editor just shows the choices. For such a field, there are two kinds: + +- **A new block**: pick one of the functions that fit, and fill in its form right there. +- **A [saved block](/next/saved-blocks)**: the site editor lists the saved blocks in `.deco/blocks` and shows the ones whose function returns the field's type. Picking one stores a reference, `{ "__resolveType": "SummerCard" }`, so editing `SummerCard` updates every place that uses it (see [Reuse a block](/next/saved-blocks#reuse-a-block)). + +## Widgets + +Each type gets a default widget, such as a text box for a `string`. JSDoc tags on a field change the label, add help text and limits, or swap in a richer widget: a date picker, an image uploader, a color picker or a rich-text editor. + +| Tag | Effect in the editor | Example | +|---|---|---| +| `@title` | The field's label (the default is the property name) | `@title Headline` | +| `@description` | Help text under the field | `@description Shown above the fold` | +| `@default` | The value a new block starts with, parsed as JSON when it can be | `@default 10` | +| `@minimum` / `@maximum` | The allowed range of a number | `@maximum 100` | +| `@minLength` / `@maxLength` | The allowed length of a string | `@maxLength 60` | +| `@format` | A specialized input for a string, such as `date`, `date-time`, `rich-text`, `textarea`, `color` or `image-uri`. For a credential, type the field as `Secret` instead. | `@format rich-text` | +| `@options` | A select over a `string` field, from a list of allowed values, without narrowing the TypeScript type | `@options ["sm", "md", "lg"]` | +| `@ignore` | Leaves the field out of the form | `@ignore` | + +Put each tag on its own line. This type: + +```ts +export interface PromoBannerProps { + /** + * @title Headline + * @maxLength 60 + */ + title: string; + /** + * @title Image + * @format image-uri + */ + image: string; + /** @title Link */ + href: string; + /** + * @title Ends on + * @format date + */ + endsOn?: string; +} +``` + +becomes a form with a *Headline* text box limited to 60 characters, an *Image* picker, a *Link* field and an optional *Ends on* date picker. + +**`@format` on a type alias.** Write the tag once on a type alias and every field of that type gets the widget, including `Color | null` and each item of a `Color[]`. A field's own `@format` wins. + +```ts title="src/widgets.ts" +/** @format color */ +export type Color = string; // background?: Color is a color picker + +/** @format color */ +export type TextTone = "black" | "white"; // a select of these two values, marked as colors +``` + +**Options are written into the schema.** A union of string literals, a TypeScript `enum` and an `@options` list all become an `enum` in the schema, so the site editor shows a select without asking your site for the options. A picker whose options come from a function shows a text field, since the site editor never runs your code (see [What works without your code](/next/site-editor#what-works-without-your-code)). + +## Data-only blocks + +A blog post or a navigation menu has fields but no logic. Declare it with a function that returns its input unchanged: + +```ts title=".deco/index.ts" +import type { Blocks } from "@decocms/blocks"; +import type { Post } from "../src/post"; +import seo from "../src/seo"; +import hero from "../src/hero"; + +const post = (props: Post) => props; // data only: returns what the editor saved + +export default { seo, hero, post } satisfies Blocks; // page, redirect and the other built-in blocks need no entry +``` + +A `post` is now a block like any other. Your app reads posts by type with [`client.list("post")`](/next/api-reference#client-list-type-options) or by URL with [`matchRoute`](/next/api-reference#matchroute-url-items) (see [Pages and routing](/next/routing)), and [`client.resolve`](/next/api-reference#client-resolve-target-options) works on a saved post too. Blocks inside a post still resolve, because a block's inputs resolve before its function runs. And like any block, it can fill a field of its return type: a block with a `featured: Post` field lets editors pick a saved post (see [Interchangeable blocks](#interchangeable-blocks)). + +To add fields to a built-in such as `page`, see [Change a built-in](/next/built-in-blocks#change-a-built-in). + +A saved block can't share a name with a block type, a built-in or an alias; see [Names](/next/saved-blocks#names). + +## When your types change + +Saved content must keep working when you change a type, and `deco check` makes sure it does, on every pull request and before every build. See [Backward compatibility](/next/checking#backward-compatibility). diff --git a/docs/content/next/site-editor.mdx b/docs/content/next/site-editor.mdx new file mode 100644 index 00000000..4acdd558 --- /dev/null +++ b/docs/content/next/site-editor.mdx @@ -0,0 +1,75 @@ +--- +title: Site editor +nav: Site editor +group: Built on Blocks +kind: docs +order: 8 +--- + +# Site editor + +Your merchandising team wants to swap the home page hero image for the weekend and fix a typo in the footer, and none of them opens a code editor. The site editor is Deco CMS's content editor: it shows the form [`deco schema`](/next/schema) built from each block's types, next to a preview of your running dev app, and saves each change back to the block's JSON file. + +This page shows what the site editor is, how to run it against the app on your machine, how uploads work, and what works differently because the site editor never runs your code. + +## What the site editor is + +The site editor is part of Deco Studio: a web app with a form for each block. The forms come from your schema, `.deco/schema.gen.json` (see [Forms from types](/next/schema)), so they only accept values your functions can take. Every save writes a [saved block](/next/saved-blocks), a JSON file in `.deco/blocks`: the same file you could edit by hand or ask an AI agent to edit. + +The site editor never runs your code. It needs only two things, both files in your repository: the saved blocks and the schema, which your `predev` script writes (see [Run it before dev and build](/next/cli#run-it-before-dev-and-build)). It reads and writes them through the [content protocol](/next/content-protocol). + +## Edit on your machine + +The site editor runs at `/site-editor` in Deco Studio. It needs no account and works whether you're signed in or not. It opens in Studio's app layout, with the same Preview and Content tabs as the site editor inside a project: Preview shows your running dev app, Content holds the forms. Signed in, the sidebar lists your organizations; signed out, it's the same layout without them. Hosted features, such as publishing, releases and the GitHub backend, aren't there. If you're signed in, you can also pick "localhost" in a project's draft environment selector, which previews the same app. + +To edit the app running on your machine: + +1. Start your app's dev server. +2. Run `npx @decocms/blocks serve`. It finds the nearest `.deco/` (or takes `--root`) and prints a link to the site editor: + + ```text + Deco server http://localhost:4545/rpc + Root apps/storefront (.deco/schema.gen.json, 214 blocks) + Assets apps/storefront/public/assets (PUT /assets/<name>) + Preview http://localhost:5173 + Site editor https://studio.decocms.com/site-editor#endpoint=http%3A%2F%2Flocalhost%3A4545%2Frpc + ``` + +3. Open the link. The Preview tab loads the address printed as `Preview`: the port from your Vite config, else `http://localhost:5173`. If your app runs elsewhere, pass it, such as `npx @decocms/blocks serve --preview localhost:8001`. +4. The first time, Chrome asks whether the site editor may reach a server on your machine (Local Network Access). Allow it. + +Each save writes `.deco/blocks` and your app hot-reloads. The server regenerates the [content module](/next/content#the-content-module) after every save, so you don't need `deco content --watch` as well. Nothing is committed: review the diff and commit it like any other change. The server listens only on your machine, but it has no authentication and answers any website open in your browser, so run it only while you're editing and stop it when you're done; `--host` also exposes it to your network (see [The local server](/next/content-protocol#the-local-server)). The site editor remembers the server, so opening `/site-editor` again reconnects to it, and if you restart `deco serve` it waits for the server and reconnects on its own. Its flags, such as `--port` and `--preview`, are in the [CLI reference](/next/cli#deco-serve). + +## Images and other uploads + +When an editor uploads an image or another file in the site editor, `deco serve` writes it into your repository, in `public/assets/`, and the field stores the file's path, such as `/assets/summer-banner.jpg`. Uploads are ordinary files: you review them and commit them with the rest of your content. + +Vite, TanStack Start and Next.js all serve `public/` at the site root, so uploads are served at `/assets/`, in development and in production, with no configuration. The folder is relative to the folder that contains `.deco`, so in a monorepo it's your app's own `public/`. To write uploads somewhere else, pass `--assets <dir>`; the field still stores `/assets/<name>`, so your app has to serve that folder at `/assets/`. + +With Vite, `public/assets/` ends up in the same `dist/assets/` folder as the build's hashed JavaScript and CSS. That's safe: the server never reuses a file name, so hosts that cache `/assets/*` for a long time serve it correctly. + +Uploads make your repository bigger: every clone carries every image, and a replaced image stays in the history. Resize and compress images before you upload them, and link large media such as video from wherever you host it. + +<Hosted to="/next/hosted-site-editor#uploads" label="Uploads in hosted storage">**Uploads without growing your repository.** With the hosted Deco CMS, the site editor stores uploads in Deco's asset storage and saves their CDN address in the field, so images are served from a CDN and never enter your repository.</Hosted> + +## What works without your code + +Because the site editor never runs your code, a few things that depend on it work differently: + +- **Block previews:** clicking a block's preview opens your dev app at that page. Gallery cards show the block's name, plus the `@title`, description and `@image` set in its JSDoc (see [Widgets](/next/schema#widgets)). +- **Pickers whose options come from your code** show the options written into the schema, otherwise a text field (see [Widgets](/next/schema#widgets)). +- **Secret fields** are write-only: the site editor encrypts what an editor types with your public key, `.deco/secrets.pub`, and never shows the saved value (see [Secrets](/next/built-in-blocks#secrets)). +- **Fetching data and installing apps from the site editor:** older Deco sites could run data loaders and install commerce apps from the site editor; here those live in your code. Add platform clients in code (see [Calling APIs](/next/upstream-clients)). These are different from [content loaders](/next/content#write-a-loader), which deliver your saved blocks. + +## Settings + +The site editor's **Settings** entry opens your site's [CMS settings](/next/built-in-blocks#cms-settings): one form with the hosts previews are allowed on, the telemetry switches and sample rates, and where analytics sends page views. Until someone saves it, the form shows the defaults, and the first save creates `.deco/blocks/CMS.json`. It's an ordinary saved block, so the change ships like any other content, and you can review it in the diff. + +Settings take effect from the release, not from a draft: previewing a draft never changes its own preview hosts, telemetry or analytics. What code allows still wins, such as the highest sample rates or the hosts previews may ever use (see [Allow previews per host](/next/releases-and-drafts#allow-previews-per-host)). + +## Saved blocks and variants in the site editor + +- **Saved blocks:** a field that takes a block offers the saved blocks whose function returns the field's type. Picking one stores a reference, so editing it updates every place that uses it (see [Reuse a block](/next/saved-blocks#reuse-a-block)). +- **Variants:** any field can have variants, each with a rule such as a date range, and the site editor writes the `multivariate` block for you (see [Matchers and variants](/next/matchers-and-variants)). + +<Hosted to="/next/hosted-site-editor" label="Site editor on GitHub">**Edit with nothing to install.** With the hosted Deco CMS, editors use the site editor on your GitHub repository straight from the browser: each save is a commit on a draft branch, and publishing brings it to production.</Hosted> diff --git a/docs/content/next/studio-compatibility.mdx b/docs/content/next/studio-compatibility.mdx new file mode 100644 index 00000000..f4e891a1 --- /dev/null +++ b/docs/content/next/studio-compatibility.mdx @@ -0,0 +1,118 @@ +--- +title: Site editor compatibility +nav: Site editor compatibility +group: Under the hood +kind: internals +order: 6 +--- + +# Site editor compatibility + +[The site editor](/next/site-editor) reads and writes a next-major site through the [content protocol](/next/content-protocol), which needs only the committed [schema](/next/schema#from-types-to-forms) and the files in `.deco/blocks`. This page shows what the site editor reads from the schema, the alias table, and the endpoints older sites still serve. + +## What the site editor reads from the schema + +Everything the site editor knows about the shape of your content comes from the schema, so it's all inferred from your types. Where it reads the schema depends on what it edits: + +- **Your machine**, through [`deco serve`](/next/site-editor#edit-on-your-machine) on localhost: the schema in your working tree, so a type you just added shows up as soon as `deco schema` writes it. +- **A draft on GitHub**, with the hosted Deco CMS: the `schema.gen.json` committed on the draft's branch, falling back to your default branch when that branch has none. + +The file follows the site editor's format, `deco-meta@1`: + +- **`manifest`**: every key of your [block map](/next/blocks#the-block-map), plus the [built-ins](/next/built-in-blocks), grouped by kind (table below). +- **`schema.definitions`**: one JSON Schema per type, the form the site editor shows, keyed by the padded-base64 type name. + +The group names are the site editor's, inherited from the Fresh and Deno framework. The CLI picks the group from the type: + +| Manifest group | What lands there | The site editor uses it for | +|---|---|---| +| `sections` (the site editor's legacy name) | Block functions that return JSX or a [render descriptor](/next/rendering#which-mode-to-use) | The site editor's "add" catalog: the blocks an editor can add to a page | +| `matchers` | Block functions that return `boolean` ([matchers](/next/matchers-and-variants)), including the built-in `always`, `never` and `date` | The rule picker next to each [variant](/next/matchers-and-variants#variants) | +| `loaders` | Every other block function, such as one that fetches products. The site editor calls these loaders, after the Fresh and Deno name for data functions; they have nothing to do with where your content comes from. This includes the built-ins `multivariate` and `lazy`. The site editor recognizes `lazy` by name and never offers it as a pick: it only writes it around a value in a `Lazy<T>` field. | Data fields that can point at a function | +| `pages` | The built-in `page` (or your override of it) and [data-only blocks](/next/schema#data-only-blocks) whose type extends `Route`, like `post` | The page list, "new page", the URL field | +| `redirects` | The built-in `redirect` | The redirects screen | +| `content` | Every other data-only block (a function that returns its input), including the built-in `cms-settings` | Plain entries: navigation menus, settings, email templates | + +```json title=".deco/schema.gen.json, abridged" +{ + "manifest": { + "blocks": { + "sections": { "hero": { "$ref": "#/definitions/aGVybw==" } }, + "matchers": { "always": { "$ref": "…" }, "never": { "$ref": "…" }, "date": { "$ref": "…" } }, + "loaders": { "multivariate": { "$ref": "…" }, "lazy": { "$ref": "…" } }, + "content": { "seo": { "$ref": "…" } }, + "pages": { "page": { "$ref": "…" } }, + "redirects": { "redirect": { "$ref": "…" } } + } + }, + "schema": { + "definitions": { + "aGVybw==": { "type": "object", "properties": { "title": { "type": "string", "title": "Heading" } } } + }, + "root": { "sections": { "anyOf": ["…"] } } + } +} +``` + +Three conventions follow from this: + +- A key is a **block type if it's in the manifest and a saved block if it isn't**, so short names like `hero` are fine, as long as no saved block uses the same name (see [the lookup rule](/next/blocks#the-lookup-rule)). +- A field with variants is `{ "variants": [{ "rule": …, "value": … }, …] }`: each entry pairs a rule with a variant, and each `value` is a [`lazy` block](/next/lazy-blocks#write-a-lazy-block) that the site editor writes around what the editor fills in. The first variant whose rule is true wins, so the default, with the `always` rule, goes last. +- A redirect is a flat `{ from, to, permanent }` entry, plus the optional `status` and `discardQueryParameters` (see [Pages and redirects](/next/built-in-blocks#pages-and-redirects)). + +Saved blocks aren't in the schema: the site editor builds its pickers of saved blocks from the map `blocks.list` returns. Schema strings, such as titles and `@image` templates, are untrusted input to the site editor, which escapes them. + +## Well-known types and the alias table + +The Fresh and Deno framework, and v7, name every block type by the path of the file that defined it, so a page type was `website/pages/Page.tsx` and the always-true rule was `website/matchers/always.ts`. The site editor's special screens still find a few types by those names, and `deco-meta@1` keeps them: + +| Role | The name the site editor looks for | +|---|---| +| Page | `website/pages/Page.tsx` (or `$live/pages/LivePage.tsx`) | +| Variants (`multivariate`) | `website/flags/multivariate.ts`, `website/flags/multivariate/section.ts` | +| Always and never rules (the default variant; hiding a block) | `website/matchers/always.ts`, `website/matchers/never.ts` | +| Redirect | `website/loaders/redirect.ts` | +| Secret | `website/loaders/secret.ts` (v7 secrets are re-encrypted once when the site migrates; see [Secrets](/next/renames-and-migrations#secrets)) | + +Your code uses `page`, `always` and the other short names. The CLI emits an **alias table**, a list of second names for types, and writes it into the schema, so the site editor's screens find your types under the names they expect. Content the site editor saves under an old name resolves too: [`deco content`](/next/content#the-content-module) writes the alias table into the content module, and [`createCMS`](/next/api-reference#createcms-config) reads it from there. Under `website/flags/multivariate.ts`, variants are saved as plain values; the alias bridge wraps each one in a `lazy` block, so they run only when chosen, like new ones. + +One difference remains: redirects created in the site editor are saved in the old nested shape, not the flat one from [Pages and routing](/next/routing): + +```json +// Saved by the site editor's legacy redirect screen +{ "__resolveType": "website/loaders/redirect.ts", + "redirect": { "from": "/campaigns/summer", "to": "/summer", "type": "temporary", "discardQueryParameters": true } } + +// The shape these docs use +{ "__resolveType": "redirect", "from": "/campaigns/summer", "to": "/summer", "permanent": false, "status": 307, "discardQueryParameters": true } +``` + +[`matchRoute`](/next/api-reference#matchroute-url-items) accepts both. A legacy `"type": "permanent"` is a 301 and `"temporary"` a 307, as before, and `discardQueryParameters` carries over, so no live redirect changes its status code or query handling. The site editor decides whether a key is a block type or a saved block by looking it up in the manifest, so a site can drop the aliases once its content is migrated. + +## The Settings entry + +The site editor's **Settings** entry is the one screen found by a saved block's name rather than by a type: it opens the saved block named `CMS`, whose type is the built-in `cms-settings` (see [CMS settings](/next/built-in-blocks#cms-settings)). There's no marker in the schema and no list of settings types; the form is the `cms-settings` definition the schema already carries, like any other type's. + +- **When `CMS` exists**, the entry opens it like any saved block, and saves go through `blocks.apply` with the block's version in `ifMatch`, as every save does. +- **When it doesn't**, the form starts from the defaults in the schema, and the first save creates it with one `blocks.apply` that sets `CMS` with `ifMatch: { "CMS": null }`, so two editors saving at once can't overwrite each other: the second gets a conflict and reloads the block (see [The content protocol](/next/content-protocol)). +- **A `CMS` of another type** isn't settings: [`cms.settings()`](/next/api-reference#cms-settings) ignores it and returns the defaults, and the Settings entry never replaces it. Rename that block to free the name. + +The protocol has no methods for settings; they're content like any other. + +## Variant tabs + +A variant tab previews its variant through the [draft pointer](/next/api-reference#draft-pointers), never through a header or a site endpoint: the site editor loads the preview with a `?__draft=` pointer that [forces](/next/releases-and-drafts#preview-a-variant) the variant, addressed by the page (or saved block) that holds the `multivariate` and the JSON path to it, such as `Home@sections.3=1`. On your machine the pointer names the `deco serve` endpoint, which the content module ignores, so only the forced variant applies; with the hosted Deco CMS it's the draft's own pointer plus the `__variant` parameters. A site that picks its client with `draftPointer` needs nothing else. v7 sites keep `x-deco-matchers-override`. + +## Legacy endpoints + +Sites on Fresh and Deno are edited the legacy way: the site editor reads from the running site and calls it for anything that runs code. v7 sites serve the same endpoints, from `@decocms/blocks-admin`, so a v7 project can stay on this path: + +| Endpoint | Legacy use | +|---|---| +| `GET /live/_meta` | The schema and manifest | +| `GET /.decofile` | The saved blocks | +| `/live/previews` | Block previews: gallery thumbnails, saved blocks, in-place renders | +| `/deco/invoke` | Running a loader or action: dynamic pickers and the Run button | +| `/live/invoke` | Encrypting secret fields through the site's encrypt action | + +Next-major sites serve none of them, and the SDK has no invoke endpoint. The site editor picks the protocol for any project that has a committed schema in its app root (`<root>/.deco/schema.gen.json` or `<root>/.deco/meta.gen.json`), and the legacy path otherwise. v7 sites commit `meta.gen.json`, so they move to the protocol automatically, without what it leaves out: block previews, Run, dynamic pickers and editing v7 secrets. diff --git a/docs/content/next/studio-implementation.mdx b/docs/content/next/studio-implementation.mdx new file mode 100644 index 00000000..310ff82f --- /dev/null +++ b/docs/content/next/studio-implementation.mdx @@ -0,0 +1,237 @@ +--- +title: Studio implementation handoff +nav: Studio implementation +group: Under the hood +kind: internals +order: 11 +description: Where to implement managed drafts, automatic synchronization, immutable delivery and fast rollback in Studio, with contracts and acceptance scenarios. +--- + +# Studio implementation handoff + +This is the proposed engineering handoff for the hosted site editor, written so implementation does not depend on the original discussion. It extends Studio's existing Fast Preview rather than introducing a second editing backend. The contracts below are proposed implementation defaults, not claims about released behavior. + +Read [Content protocol](/next/content-protocol), [Keeping drafts current](/next/draft-synchronization) and [Production delivery and rollback](/next/content-delivery) for the shared semantics. This page maps those decisions onto Studio, supplies operation sequences, and names the checks that demonstrate completion. + +## Decisions to preserve + +- Business users work with drafts. Studio creates and maintains internal Git branches; creating branches and rebasing are never editor tasks. +- Successful autosave means durable Git content. GitHub failure prevents new saves, but cannot prevent serving an already prepared release. +- Production updates flow into active drafts automatically. Untouched files follow production; any file edited or deleted in the draft favors the draft in full. Synchronization reuses Git blobs and never merges JSON properties or downloads conflict bodies. +- Production and versioned preview delivery read only prepared object-store assets. Neither a cache miss nor recovery contacts GitHub, Studio's editing API or its database. +- A draft asset is an immutable overlay of changed blocks and tombstones, with no base revision. Preview uses its server's existing production content, captured once per client; publish still builds a complete snapshot. +- A release asset is immutable. A channel manifest selects the current release. Rollback increases the channel generation while selecting an older revision; it does not decrease the generation or require a build. +- Editing, synchronization, publication and preparation are TypeScript control-plane work. The data plane is object storage/CDN with a minimal authorization layer when needed, independently deployable from Studio. +- The portable protocol has four methods. Draft lifecycle, synchronization, publication, rollback and preview grants are separate Studio routes. + +## Existing Studio integration points + +These are relative paths in the public [Studio repository](https://github.com/decocms/studio), not paths to add to Blocks. Recheck their current interfaces before implementation; the responsibilities below are the stable part of the map. + +| Existing module | Reuse | Change or extend | +|---|---|---| +| `apps/api/src/api/routes/decofile.ts` | Organization/project authorization, saved-content scope, schema reads and Fast Preview routes | Adapt reads and writes to the protocol core; delegate draft and publication lifecycle to durable services | +| `apps/api/src/decofile/commit-coalescer.ts` | Autosave batching, awaiting committed results, retrying head races | Preserve per-request guards and receipts, normalize protocol set precedence, coordinate with synchronization | +| `apps/api/src/decofile/read-decofile.ts` | Scoped tree traversal, blob hashes, bounded blob reads and single-flight resolution | Use for control-plane ingestion; do not use it as the production data-plane reader | +| `apps/api/src/decofile/disk-cache.ts` | Bounded immutable blob cache | Treat it as an optimization, never as the durable release store | +| `apps/api/src/decofile/git-compat.ts` | Git comparison and content-path replay machinery | Apply content-only file-level draft-wins selection; retain append-only history for managed drafts | +| `apps/api/src/git-providers/content.ts` and `github/content.ts` | Provider abstraction, existing-blob references, Git tree/commit/ref operations | Add explicit multi-parent commits and a non-forced draft-head update; support recovering uncertain writes | +| `apps/api/src/api/routes/github-webhook.ts` | Existing signed GitHub event intake | Durably enqueue push processing, deduplicate deliveries, trigger release preparation and draft invalidation | +| `apps/api/src/decofile/draft-token.ts` | Signing and verification machinery | Bind new grants to site, overlay version and expiry rather than only a moving branch | +| `apps/web/src/components/sections-editor/decofile-api.ts` | Editor read/write state | Use the protocol client, stable draft IDs, guarded saves and retry keys | +| `apps/web/src/components/sections-editor/use-fast-preview-draft-url.ts` | One source of preview URLs | Wait for the exact saved revision's asset, then mint its grant; surface preparing/unavailable states | +| `apps/web/src/components/thread/github/publish-flow.ts` | Publish progress and review UI | Use the hosted publication operation for content drafts; retain the existing coding-session flow | + +Mount organization routes through Studio's existing org-scoped middleware and emit updates through its existing SSE delivery. Reuse durable workflow infrastructure for jobs and database-backed coordination. An in-process mutex or workflow-local map is not cross-replica serialization. Keep GitHub rate-limit handling and bounded fetch utilities. + +Fast Preview can share a branch with a coding session today. Do not apply the content-draft policy blindly to that branch: new managed drafts have their own internal branches. Preserve coding-session behavior and offer explicit migration of content changes, rather than silently dropping code edits during synchronization. + +The protocol now lives under `@decocms/blocks/protocol`, with shared keys, server, filesystem storage and conformance subpaths. The hosted storage adapter belongs in Studio; the SDK must not import the editing protocol at runtime. + +## Persisted state + +Store these records through Studio's existing storage adapters and migrations. IDs are opaque; repository, organization and app-root scope are checked on every access. A repository can host several sites. + +| Record | Minimum persisted fields | +|---|---| +| Draft | `id`, organization/project/site IDs, repo identity, app root, internal branch, repository production ref, `headCommit`, `incorporatedProductionCommit`, lifecycle state, last editor activity, latest preview overlay version | +| Draft operation | operation ID, draft ID, kind, request digest, expected head, status, candidate commit and result, lease/fencing version, attempt and retry timing | +| Save receipt | principal/project/draft scope, `requestKey`, canonical request digest, committed result, commit SHA, expiry; recoverable from committed metadata | +| Repository observation | repo/ref scope, latest reconciled head, observed time; webhook delivery IDs recorded separately for deduplication | +| Release | site ID, source repo/app root/commit, content revision, format, asset key, byte size and integrity digest, preparation state and timestamps | +| Channel | site/environment, monotonic generation, revision, `automatic` or `held` mode, desired manifest, last confirmed manifest generation | +| Promotion operation | request key and digest, expected generation, source intent, target revision, durable result and storage-write recovery state | +| Audit event | actor, operation, before/after identifiers, resolution counts, fallback reasons, timestamp; no tokens or raw secret content | + +Draft lifecycle is `open → published` or `open → discarded`; synchronization has its own `idle / queued / running / failed` state. A failed synchronization does not close a draft. Release preparation is `queued → preparing → ready` or `failed`. A channel promotes only `ready` assets. + +Commit SHA, per-block blob version, content revision and channel generation are different identifiers. Never use a commit SHA as publication order, or an asset revision as authorization. Record the incorporated production SHA explicitly instead of inferring it from a squash-rewritten branch. + +## Hosted operation contracts + +These routes are a proposed Studio surface under `/api/:org/sites/:siteId`. Keep project/root authorization explicit. Use the existing protocol errors for protocol calls; lifecycle routes return ordinary HTTP errors and typed bodies. + +| Operation | Request | Result | +|---|---|---| +| `POST /drafts` | request key, optional display name | Stable draft ID; reuse the default open draft where appropriate; no branch needed yet | +| `GET /drafts/:id` | editor session | Lifecycle/sync state, incorporated production commit, current head, preview readiness and bounded resolution summary | +| `POST /drafts/:id/sync` | request key | Durable operation ID; an internal service schedules the same operation automatically | +| `POST /drafts/:id/rpc` | The four-method JSON-RPC protocol | Endpoint bound to the draft; reject an explicit `ref` that attempts to escape its internal target | +| `POST /drafts/:id/publish` | request key, expected draft head, expected channel generation | Durable operation ID; states distinguish awaiting checks, Git committed, preparing and promoted | +| `POST /drafts/:id/discard` | request key, expected draft head | Terminal draft state; coordinated with in-flight saves | +| `POST /drafts/:id/preview` | exact saved commit or prepared overlay version | Preparing status, or signed pointer to the exact prepared overlay with expiry | +| `POST /channels/:channel/rollback` | request key, expected generation, retained target revision, reason | New generation and held channel state; no GitHub work in this operation | +| `POST /channels/:channel/resume` | request key, expected generation | Explicitly resume automatic mode and reconcile latest desired production; never resume merely because an event arrived | +| `GET /operations/:id` | editor session | Durable status/result or bounded error with retry timing | + +Identical retries return the same operation/result; a reused request key with a different body is rejected. A stale expected head or generation is a conflict, not a silent overwrite. The managed editor sends `ifMatch` from the form snapshot on saves, so a production change incorporated during synchronization cannot silently be overwritten by an old whole-entry request. The UI preserves unsaved form state on conflict and shows newer stored content. A reconciled request has a new request key because its body and guards changed; replaying the old key must return its original result or error. Keep branch names out of the business-user flow. + +Event notifications invalidate editor reads; they do not replace durable status or the portable polling fallback. Never log signed preview pointers. The lifecycle response and SSE event report synchronization summaries, not the entire content map. + +## Save and synchronization sequence + +Use one durable operation lane per draft. All API replicas submit into that lane. A worker owns its lease and checks its fencing version before side effects; a replacement reconciles uncertain operations before starting another mutation. + +```text +save(draft, params): + authorize; resolve an identical completed request receipt first + claim draft lane; reject closed drafts; reconcile pending Git operation + observe current draft head and current repository production head + synchronize if production differs from incorporatedProductionCommit + reread the head used for the save + recheck entry ifMatch and optional ifSchemaMatch on that snapshot + normalize set/delete (set wins); enforce input and secret guards + create tree + commit containing result and recoverable request metadata + update draft ref without force; on head race rebuild and recheck guards + persist result and emit editor invalidation + enqueue exact-commit overlay materialization; return committed result +``` + +A request with an existing receipt must not synchronize and apply its old mutation again. Batch/coalescer logic retains each caller's identity, key and guards. Coalescing cannot cause one failed apply to partially write, or report another caller's unrelated mutation as its own result. + +For synchronization, pin base B, draft D and production M to commit SHAs, not branch names that can move while reading. Build the tree from M, replacing only draft-edited or draft-deleted owned content paths. Compare B and D by blob identity: D == B takes M; otherwise D wins. This adopts production code and schemas and preserves other production repository paths. Refuse managed-draft code changes rather than silently treating them as content overrides. + +Use the [file-level rules](/next/draft-synchronization#file-level-draft-wins) to select existing blob IDs and deletions for all paths. No JSON bodies are read for synchronization and there is no size-based merge fallback. Large files use the same rule as small ones. Input syntax and ordinary size policy still apply when writing and materializing assets. + +Create a [Git commit with multiple parents](https://docs.github.com/en/rest/git/commits#create-a-commit) D and M, using the computed tree and metadata identifying M as the incorporated production commit. Update the draft ref non-forced. A competing ordinary append makes this fail; rerun against the fresh head with bounded retries. GitHub's ref update is not a general compare-and-swap with an expected-SHA argument: restrict managed branches to append-only Studio mutations, reconcile unexpected ref resets, and stop on divergent history rather than force it. + +If the ref update succeeds but Studio crashes before updating its database, recover the incorporated base and receipts from the reachable commit metadata. If a provider response is lost, first check whether that candidate commit is the head or an ancestor of the current head. Never repeat a mutation solely because an HTTP request timed out. + +The primitive `commitFiles` currently creates a single-parent commit; do not assume its existing rewrite mode supplies these merge semantics. Extend the provider contract and its tests before replacing automatic synchronization. Keep the old coding-session rebase flow isolated. + +## Publication and materialization sequence + +Publication freezes the head the editor confirmed, reconciles it with current production and applies only its content diff to production. If another editor saves meanwhile, preserve that later work as a subsequent draft; do not claim it was included in the confirmed publication. Follow branch protection and required checks, using a PR when needed. + +Schema compatibility and route diagnostics belong in `deco check` in CI. The current proposal does not add a second publish-time schema or route-ambiguity validator. Input syntax, operation limits, authorization, secret guards and snapshot integrity are still enforced. Successful preparation does not certify compatibility with every deployed build; code-first rollout and selecting a compatible rollback target remain operational requirements. + +```text +prepare(site, commit): + pin commit; reuse cached Git trees/blobs where available + stream normalized entries into a canonical snapshot with byte limits + compute revision; upload immutable asset and verify integrity + record ready release and enqueue promotion intent + never update a channel from the ingestion worker itself +``` + +Webhook receipt acknowledges only after durable enqueue. Deduplicate by provider delivery ID, and deduplicate preparation by site/app root/commit/format. A slow reconciliation sweep finds missed commits. Use the latest reconciled production head, not webhook arrival order; when several commits arrive, preparing only the newest is acceptable. Studio-initiated commits can enqueue directly, with webhook deduplication handling the later duplicate. + +Define canonical content hashing once in Blocks and import it in the CLI and materializer: recursively sorted object keys in JavaScript code-unit order, preserved array order, compact UTF-8 JSON with no trailing newline, and SHA-256 over the canonical `blocks` map. Emit keys in that order in the serializer rather than relying on object insertion order, particularly for numeric-looking keys; use JSON string escaping and number encoding, including the normalization of negative zero to zero. The format is versioned, and the snapshot envelope's revision is excluded from its own hash. Incremental materialization must produce the same bytes as the reference serializer; ship shared golden fixtures before allowing promotion. Reject values JSON cannot represent. Protocol request digests use a separate domain prefix and canonical request body. + +Release preparation, channel operations and rollback run in an independently deployed release-control service with its own durable state and credentials. The logical API namespace above may be routed to several services; rollback must not depend on the editing API being available. The delivery gateway remains read-only. + +Use configured R2 storage and CDN as the initial deployment target; keep the asset contract portable. The control plane writes assets. The delivery gateway has only read access and locally verifies scoped credentials using deployed verification keys. It does not query Studio's database per request. Token rotation/revocation must have a documented bounded propagation policy; if revocation state is consulted, serve that state from the isolated storage plane too. Private caching always happens behind authorization and is scoped to site and format/revision. + +## Draft overlay preparation + +Maintain the cumulative owned-file difference between the saved draft head and its internally recorded incorporated production commit. This control-plane metadata is necessary for Git synchronization; it is not sent as a `baseRevision` or imposed on the servers that render drafts. Normalize filename aliases using the shared key rule. Track deletions as tombstones and clear an override when the draft equals its incorporated production. Retain whole replacement entries for changed blocks; the draft wins simultaneous changes anywhere inside those files. + +Use write bodies from autosave and cached Git tree/blob identities to update the changed-entry index. On recovery or external updates, compare scoped tree metadata and read only missing changed bodies. Do not use a capped provider changed-file list as if it were complete: traverse the owned subtrees when comparison is truncated. Never crawl unrelated repo contents or rebuild a whole draft decofile merely to serve preview. + +Upload new changed-block assets first. Then upload the complete immutable overlay manifest, whose `set` references block hashes and whose `delete` enumerates cumulative deletions. Reuse unchanged blob assets across saves. The grant authorizes this manifest and its referenced blobs. Exclude secrets and enforce ordinary persistence guards before asset creation. The schema and `blocks.list` editor contract still describe the effective editable content; the overlay is a delivery optimization, not a replacement for that protocol shape. + +At request time, capture the server's existing normal production snapshot, then fetch the overlay and only missing changed blobs. Build a read-through immutable view for lookup and enumeration. No production revision is downloaded or refreshed just to satisfy a draft. Bound caches of blobs, overlays and composed views; composed-view keys include the local production revision and overlay version. References and deletion handling use that same view for every resolve in the client. For non-HTTP consumers the client lifetime has the same capture rule. + +Keep publication separate: it reconciles the draft with repository production and prepares a full release. A preview is not a promise that its inherited blocks match the later publication, especially during rollback holds when delivered content differs from Git. + +## Promotion, rollback and recovery + +Use a durable per-channel lane with expected generations and stored desired manifests. It owns the only credential allowed to write channel objects. Preparation workers cannot promote directly. Every deliberate publish/resume/rollback gets a new ordered intent; superseded preparation may finish and retain its asset but cannot promote. + +For an immutable ready target, store the next generation and desired manifest durably, then write the channel object and confirm it. The data plane considers the object authoritative. A lost storage-write response is reconciled by rereading the stored manifest. A retry of the same operation returns the same generation, rather than allocating another. + +Only one writer may perform channel writes at a time. A database lease alone cannot fence an expired worker's outstanding object-store upload. For the initial R2 deployment, use a small promotion Worker with the R2 binding: `put` with `onlyIf: { etagMatches: observedEtag }` fences updates against the stored manifest; a failed condition returns `null`. Bootstrap with a conditional create, not an unconditional upload. [R2 conditional operations](https://developers.cloudflare.com/r2/api/workers/workers-api-reference/#conditional-operations) document these primitives. Test them against the live provider as well as local emulation. A different provider adapter must supply equivalent conditional writes; do not ship a check-then-unconditional-put sequence. Stop promotion on an unresolved write instead of allowing two writers to race. + +Rollback uses this same lane with a retained ready target and sets mode to `held`. Recovery, webhook replay and periodic reconciliation must respect the hold. Explicit resume creates a new intent against the latest production observation. Record the delivery/Git mismatch and offer a separate Git revert; GitHub downtime does not block rollback. + +The SDK retains the greatest observed channel generation, discards stale asynchronous fetch completions, and accepts an older revision selected by a newer generation. In-flight page clients keep their original snapshot. Emit the cache-invalidation callback on generation changes even when content hashes repeat. Never substitute newer branch changes for an immutable overlay version. Apply it to the local production captured by this client; do not fetch or await a matching base. + +## Proposed initial defaults + +Keep these values in versioned configuration and expose protocol limits through `describe`. They are starting defaults for implementation and load testing, not a guarantee that raw JSON byte size equals heap usage. Changing a public limit requires updating this table and its acceptance tests. + +| Setting | Initial default | +|---|---| +| Synchronization file bodies downloaded | 0; only scoped tree metadata and blob identities | +| Synchronization jobs concurrently | 1 per worker process; scale worker replicas with a global job cap | +| Preparation worker memory / accumulated read budget | 512 MiB container; 32 MiB input bytes per attempt, then checkpoint and yield; monitor actual heap and RSS | +| Protocol writes | 500 names, 1 MiB per entry, 8 MiB per request, 10 calls per batch | +| Schema / block-list / aggregate batch response | 16 MiB / 16 MiB / 32 MiB uncompressed; error instead of truncation | +| Prepared production snapshot | 8 MiB canonical block-map bytes; oversized historical sites need an explicit profiled tier before onboarding | +| Save and lifecycle request receipts | 24 hours; uncertain requests older than that require reread/reconciliation | +| External Git head-race retries | 3 attempts, jittered backoff; rate-limit responses wait for provider retry timing | +| Editor activity heartbeat / active window | 30 seconds / 2 minutes; closed drafts stop background synchronization | +| Hosted editor conditional fallback poll | 30 seconds and on focus; local protocol remains 2 seconds | +| Repository reconciliation | Every 5 minutes for connected sites; bounded concurrency and provider-budget-aware backoff | +| Channel manifest cache | At most 5 seconds; generation-based ETag, no negative caching | +| Immutable revision cache | 1 year with immutable keys; authorize private access before using shared byte caches | +| SDK release poll | 60 seconds with up to 10 seconds jitter, subject to the existing idle/Worker scheduling | +| Rollback retention | 30 days minimum; always retain live channel targets and explicit pins | +| Draft overlays / changed-blob cache | 8 MiB cumulative changed-block bytes per overlay; bounded cache, at most 3 composed views per site by default; unchanged production objects shared | +| Preview grants / discarded-draft cleanup | 1 hour grants; assets retained beyond every unexpired grant before cleanup | + +Track readiness lag, promotion lag, provider calls per active draft, file-level draft-win counts, job RSS, head retries and held channels. Test the 8 MiB runtime limit with fallback, old/new revisions and concurrent pinned readers on the target hosts before rollout. A parser or serializer can still have high allocation overhead below the byte limit; lower concurrency or limits based on measurements. + +The active-server propagation target at these defaults is preparation time plus up to roughly 75 seconds for manifest caching and polling/jitter, followed by any HTML cache delay. Scheduling and outages can extend it. Idle Workers have no elapsed-time guarantee until a request schedules refresh; their first response can serve the bundled fallback. Rollback follows the same rule. + +## Acceptance scenarios + +| Scenario | Required evidence | +|---|---| +| Editor opens a new project and saves | Internal branch created without branch UI; receipt returned only after committed files exist | +| Main changes footer while draft changes title | Both edits appear; recorded base advances to the incorporated production commit | +| Both change the same file, including different properties or deletion | Entire draft blob/deletion wins; audit identifies the production override | +| Large conflicting block | Same file-level selection as every other block; zero body downloads for synchronization | +| Missing size metadata or an asset read crosses its limit | Synchronization still uses blob IDs; writing/materializing assets enforces read limits without partial promotion | +| Autosave races with synchronization on different replicas | Durable coordination plus head retry preserves edits; stale guards return conflict | +| Save response lost, worker restarted | Same request key returns original commit/result; no duplicate mutation | +| Git ref advanced, database update interrupted | Recover receipts and incorporated base from reachable commit metadata | +| Managed branch was externally reset | Stop/reconcile explicitly; no force-push overwrite | +| Webhook duplicated, missed or delivered out of order | Idempotent preparation and reconciliation; older events cannot regress production | +| GitHub unavailable | Saving/preparation pause; prepared production assets and rollback continue serving | +| Asset upload fails or is incomplete | Previous manifest stays live; missing reads never fetch GitHub | +| Old job completes after rollback | Channel remains at the rollback generation in held mode | +| Manifest write succeeds but response is lost | Retry reconciles the same generation; an expired writer cannot overwrite a newer one | +| SDK revision B fetch finishes after rollback to A | The higher-generation A remains selected; in-flight pages may finish on their pinned B | +| Old preview link used after another save | Exact old overlay or draft error, never newer branch changes; inherited content follows local production | +| Local release changes while a draft renders | In-flight client keeps its pair; next client uses the new local release without downloading another full draft | +| Draft deletes a block that exists in production | Tombstone hides it from lookup and listing; absent override without a tombstone inherits production | +| Two servers have different local release revisions | Same overlay intentionally produces different inherited content; composed caches cannot cross those base identities | +| Repeated saves change one previously edited block | Fetch the small manifest and only changed-blob hashes missing from cache, not the full production or draft map | +| Private asset requested without a valid grant | No shared-cache bypass; tenant scope checked before bytes are served | +| Publish confirmed, then another save arrives | Confirmed content publishes; later save remains unpublished | +| GC runs during rollback or preview use | Channel targets, pins, referenced uploads and unexpired grants survive | +| Max-sized snapshot and simultaneous readers | Measured peak memory stays within the runtime budget; preparation cannot OOM the API | + +Run portable protocol conformance against the filesystem and GitHub adapters. Add focused integration tests for provider head races, durable recovery and object-store fencing, and E2E tests extending Fast Preview's existing Git-sync and decofile suites. Include SDK generation/cache tests; a happy-path editor demo alone does not establish these guarantees. + +## Rollout order + +1. Ship the shared protocol subpaths and golden hashing fixtures. Adapt existing Fast Preview reads/writes while keeping legacy routes during migration. +2. Add durable draft IDs, operation lanes and recoverable receipts. Put automatic synchronization behind a dedicated default-off flag, then enable file-level draft wins for managed content drafts. No property merge engine is required. +3. Add complete production snapshot and draft-overlay materialization and a shadow delivery channel. Compare outputs with current control-plane reads without changing production traffic. +4. Enable the isolated data plane for selected sites after cold-read, outage and memory tests. Keep the bundled last-good fallback; no data-plane GitHub fallback is allowed during rollout. +5. Enable channel promotion, rollback/hold/resume and retention after race/recovery tests pass. Make save, preview-ready, Git-committed and promoted status visible in the editor. +6. Remove migrated readers' dependence on the legacy branch-head endpoint; retain coding-session behavior and document rollback of the rollout itself. + +Track these phases in the [Studio and Deco API roadmap](/roadmap/platform). Implementation is complete when the acceptance scenarios pass, the deployed data plane cannot access GitHub, and operators can restore a retained release while GitHub and Studio's editing API are unavailable. diff --git a/docs/content/next/tanstack-start-descriptors.mdx b/docs/content/next/tanstack-start-descriptors.mdx new file mode 100644 index 00000000..28af7545 --- /dev/null +++ b/docs/content/next/tanstack-start-descriptors.mdx @@ -0,0 +1,257 @@ +--- +title: TanStack Start +nav: TanStack Start +group: Websites +order: 19 +description: Render Deco CMS pages with TanStack Start on Cloudflare Workers, streaming each block as a descriptor. +--- + +# TanStack Start + +Editors choose page URLs in [the site editor](/next/site-editor), so one catch-all route serves every URL with [`matchRoute`](/next/api-reference#matchroute-url-items). Start sends loader data to the browser as serialized data, and JSX can't make that trip, so here [blocks](/next/blocks) return descriptors (plain objects such as `{ component: "product-hero", props }`; see [Rendering](/next/rendering)). + +This page shows how to render Deco pages with TanStack Start on Cloudflare Workers, streaming each block as a descriptor. To render blocks as Server Components instead, see [Which mode to use](/next/rendering#which-mode-to-use) and the experimental [TanStack Start + RSC guide](/next/tanstack-start-rsc). + +**Prerequisites:** a TanStack Start app (see the [Start quick start](https://tanstack.com/start/latest/docs/framework/react/quick-start)); `npm install @decocms/blocks zod` and `npm install -D @cloudflare/vite-plugin wrangler` (`@decocms/blocks` includes the [`deco` CLI](/next/cli)); the [example project](/next/routing#the-example-project); and `PromoBanner`, `ProductHero` and `CartButton` from step 1 of the [Next.js guide](/next/nextjs). Enable `resolveJsonModule` in `tsconfig.json` so TypeScript accepts the JSON imports in `.deco/blocks.gen.ts`, the [content module](/next/content#the-content-module). + +## 1. Return descriptors from blocks + +```ts title=".deco/index.ts" +import type { Blocks, Seo } from "@decocms/blocks"; +import type { BlockDescriptor, ProductHeroProps, PromoBannerProps, ResolvedPage } from "../src/model"; + +export default { + page: (input: ResolvedPage<BlockDescriptor>) => input, + seo: (input: Seo) => input, + "promo-banner": (input: PromoBannerProps): BlockDescriptor => ({ component: "promo-banner", props: input }), + "product-hero": (input: ProductHeroProps): BlockDescriptor => ({ component: "product-hero", props: input }), +} satisfies Blocks; +``` + +These blocks return descriptors, not JSX, so they don't [fit](/next/schema#interchangeable-blocks) the built-in page's `sections: ReactNode[]`. The `page` key [replaces the built-in page](/next/built-in-blocks#change-a-built-in) with one that takes `ResolvedPage<BlockDescriptor>`, whose `sections` is a `BlockDescriptor[]`, and returns it as is. The built-in [`redirect`](/next/built-in-blocks#pages-and-redirects) block is added for you. Run `deco schema` and `deco content` before the dev server and the build (see [Run it before dev and build](/next/cli#run-it-before-dev-and-build)): + +```json title="package.json" +{ + "scripts": { + "predev": "deco schema && deco content", + "prebuild": "deco schema && deco content && deco check" + } +} +``` + +Commit `.deco/schema.gen.json` and gitignore `.deco/blocks.gen.ts`; don't edit either (see [The content module](/next/content#the-content-module)). + +## 2. Create the CMS + +```ts title="src/cms.ts" +import { createCMS } from "@decocms/blocks"; +import blocks from "../.deco"; +import content from "../.deco/blocks.gen"; + +// Serves the content module: the content of the commit this build was made from. +export const cms = createCMS({ blocks, content }); + +// The client for this request. Every page gets its client here, so this is the one place to change +// if requests ever need different content. +export const client = async (_request: Request) => cms.forRelease(); +``` + +`cms.forRelease()` returns the client your code calls `list` and `resolve` on, one per request (see [One revision per response](/next/releases-and-deployment#one-revision-per-response)). `client` takes the request even though this guide doesn't read it (hence the `_` that keeps unused-parameter lint rules quiet); [hosted drafts](/next/hosted-drafts#tanstack-start) read it, and the steps below stay the same. + +Telemetry is one more `createCMS` option, off until you say where it goes, such as `telemetry: { endpoint: env.OTLP_ENDPOINT }` for your own OpenTelemetry collector (see [Telemetry](/next/telemetry#choose-where-telemetry-goes)). On Workers it's sent after the response, inside `ctx.waitUntil`, so it never slows a page (see [How telemetry is sent](/next/telemetry-internals)). + + +## 3. Match the URL to a page + +This helper is shared by both TanStack guides. It resolves each block separately rather than calling [`c.resolve(page)`](/next/api-reference#client-resolve-target-options), so each block's promise streams to the browser on its own. `T` is what your blocks return: `BlockDescriptor` here, `ReactNode` in the [RSC guide](/next/tanstack-start-rsc), which reuses this file unchanged. The server function hands the incoming request to `client()`. Each block promise resolves to `{ value, failed }`: `failed` is `true` if the block failed, so no internal error details reach the browser, and `value` is `undefined` for a [hidden](/next/matchers-and-variants#hide-a-block) block. If an editor gave the whole `sections` list [variants](/next/matchers-and-variants), it's one `multivariate` block that resolves to a list (`T[]`), so it streams as one. + +```ts title="src/open-page.server.ts" +import { notFound, redirect } from "@tanstack/react-router"; +import { matchRoute, type Redirect, type Seo } from "@decocms/blocks"; +import { client } from "./cms"; +import type { StoredPage } from "./model"; + +export async function openPage<T>(href: string, request: Request) { + const c = await client(request); + const [pages, pagesError] = await c.list<StoredPage>("page"); + if (pagesError) throw pagesError; + const [redirects, redirectsError] = await c.list<Redirect>("redirect"); + if (redirectsError) throw redirectsError; + + const match = matchRoute(href, { routes: pages, redirects }); + if (match.kind === "not-found") throw notFound(); + if (match.kind === "redirect") throw redirect({ href: match.location, statusCode: match.status }); + + const page = match.entry; + // Variants of the whole list are one multivariate block: it resolves to the chosen list and streams as one. + const sections = Array.isArray(page.sections) ? page.sections : [page.sections]; + const blocks = sections.map((block, index) => ({ + key: `${href}:${index}`, + value: c.resolve<T | T[] | undefined>(block).then(([value, blockError]) => { + if (blockError) console.error(blockError); + return { value: value ?? undefined, failed: blockError !== null }; + }), + })); + + // Every block has started before SEO is awaited. + const [seo, seoError] = await c.resolve<Seo | undefined>(page.seo); // undefined when the page has no seo + if (seoError) throw seoError; + return { seo, blocks }; +} +``` + +## 4. Expose it through a server function + +A server function runs only on the server. During server rendering it's a plain call; in the browser, Start replaces it with a fetch to the server, so the CMS and the content never ship to the client. That makes it a public HTTP endpoint, so validate its input: here, a path that starts with `/`. + +```ts title="src/page.functions.ts" +import { createServerFn } from "@tanstack/react-start"; +import { getRequest } from "@tanstack/react-start/server"; +import { z } from "zod"; +import { openPage } from "./open-page.server"; +import type { BlockDescriptor } from "./model"; + +export const loadPage = createServerFn({ method: "GET" }) + .inputValidator(z.object({ href: z.string().startsWith("/") })) + .handler(({ data }) => openPage<BlockDescriptor>(data.href, getRequest())); +``` + +## 5. Render the catch-all route + +```tsx title="src/routes/$.tsx" +import { Suspense } from "react"; +import { Await, createFileRoute } from "@tanstack/react-router"; +import ProductHero from "../ProductHero"; +import PromoBanner from "../PromoBanner"; +import type { BlockDescriptor } from "../model"; +import { loadPage } from "../page.functions"; + +// Maps each descriptor to its component. Add a case per block type. +function View({ block }: { block: BlockDescriptor | BlockDescriptor[] }) { + if (Array.isArray(block)) return block.map((item, index) => <View key={index} block={item} />); // the chosen variant of the whole list + switch (block.component) { + case "promo-banner": + return <PromoBanner {...block.props} />; + case "product-hero": + return <ProductHero {...block.props} />; + } +} + +export const Route = createFileRoute("/$")({ + loader: ({ location }) => loadPage({ data: { href: location.pathname + location.searchStr } }), + head: ({ loaderData }) => ({ + meta: loaderData?.seo // without seo, the root route's defaults apply + ? [{ title: loaderData.seo.title }, { name: "description", content: loaderData.seo.description }] + : [], + }), + component: Page, + pendingComponent: () => <p>Opening page…</p>, + errorComponent: () => <p>The page could not be loaded.</p>, +}); + +function Page() { + const page = Route.useLoaderData(); + return ( + <main> + {page.blocks.map((block) => ( + <Suspense key={block.key} fallback={<p>Loading…</p>}> + <Await promise={block.value}> + {({ value, failed }) => { + if (failed) return <p role="status">This content is temporarily unavailable.</p>; + return value ? <View block={value} /> : null; // undefined when an editor hid the block + }} + </Await> + </Suspense> + ))} + </main> + ); +} +``` + +<Callout>**Keep block promises unawaited.** Start can send a promise inside loader data: the page goes out right away, and each promise's value streams to the browser when it resolves, where `Await` renders it. Awaiting them in `openPage` would make the page wait for its slowest block.</Callout> + +## 6. Configure Cloudflare Workers + +```ts title="vite.config.ts" +import { defineConfig } from "vite"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), tanstackStart(), react()], +}); +``` + +```jsonc title="wrangler.jsonc" +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "cms-rendering-example", + "main": "@tanstack/react-start/server-entry", + "compatibility_date": "2026-02-14", + "compatibility_flags": ["nodejs_compat", "no_handle_cross_request_promise_resolution"] +} +``` + +`nodejs_compat` enables the Node APIs Start relies on, such as `AsyncLocalStorage`. `no_handle_cross_request_promise_resolution` keeps Workers' older behavior for a promise created in one request and awaited in another, which this setup relies on: the CMS shares one content load across requests. See Cloudflare's [compatibility flags](https://developers.cloudflare.com/workers/configuration/compatibility-flags/). + +## How it works + +- **The server function becomes an RPC stub.** Import it statically from the route, so Start can replace it with a fetch call in the client build. See [Start server functions](https://tanstack.com/start/latest/docs/framework/react/server-functions). +- **What ships to the browser.** The view registry and its views, and the descriptors as data. The CMS, the block map and the content stay on the server. +- **Reading the request.** A block that needs headers or cookies calls `getRequest()` from `@tanstack/react-start/server`, like any other server code. + +## Deployment checklist + +- Workers need nothing special: the content module is bundled like any import and kept in memory per isolate. +- Keep both compatibility flags from step 6. + +## Edit in the site editor + +To edit this app's content in [the site editor](/next/site-editor) while you develop, run [`deco serve`](/next/cli#deco-serve) (`npx @decocms/blocks serve`) beside your dev server; its canvas opens your Vite dev app by default. Each save writes a file in `.deco/blocks` and your app reloads (see the recipe below); you commit the changes as usual (see [Edit on your machine](/next/site-editor#edit-on-your-machine)). + +### Reload on content changes + +`deco serve` rewrites `.deco/blocks.gen.ts` on every save. Blocks has no dev hook for that, so two pieces of your own code make a save show up. First, let `src/cms.ts` take the new content module without running again: + +```ts title="src/cms.ts" +// …the imports and client from step 2 +export const cms = createCMS({ blocks, content }); + +// createCMS adopts the new module for the same .deco folder and returns the same instance. Running +// this module again instead leaves server functions holding the old one, and the first request +// after a save fails. +if (import.meta.hot) { + import.meta.hot.accept("../.deco/blocks.gen", (next) => { + if (next) createCMS({ blocks, content: next.default }); + }); +} +``` + +Pass the same options you passed the first time. Second, only the server imports the content module, so Vite swaps it without touching open pages. Reload them, so the site editor's preview shows the saved content: + +```ts title="vite.config.ts" +import path from "node:path"; + +const contentModule = path.resolve(__dirname, ".deco/blocks.gen.ts"); + +export default defineConfig({ + plugins: [ + // …the plugins from step 6 + { + name: "reload-on-content", + apply: "serve", + hotUpdate({ file, server }) { + if (this.environment.name === "client" || path.resolve(file) !== contentModule) return; + server.environments.client.hot.send({ type: "full-reload" }); + }, + }, + ], +}); +``` + +The [RSC guide](/next/tanstack-start-rsc) reuses both unchanged. + +<Hosted to="/next/hosted#cloudflare-workers" label="Connect a Worker">**Publish and preview without a deploy.** A site ID and a Wrangler secret connect this Worker to the hosted Deco CMS: published content goes live without a rebuild, and drafts render on your site.</Hosted> + +<Small>Tested with Start 1.168.58, Router 1.170.39, React 19.2.7, Vite 7.3.6, Cloudflare Vite plugin 1.44.0, and Wrangler 4.110.0.</Small> diff --git a/docs/content/next/tanstack-start-rsc.mdx b/docs/content/next/tanstack-start-rsc.mdx new file mode 100644 index 00000000..6d36b90c --- /dev/null +++ b/docs/content/next/tanstack-start-rsc.mdx @@ -0,0 +1,103 @@ +--- +title: TanStack Start with React Server Components +nav: TanStack Start + RSC +group: Websites +order: 20 +description: Switch the TanStack Start guide to React Server Components, so blocks return JSX and only Client Components ship JavaScript. +--- + +# TanStack Start with React Server Components + +<Callout type="warning">**Experimental.** TanStack Start's RSC support is experimental and needs the extra Vite plugin configured in step 4.</Callout> + +You want view code that only the server needs to stay off the client, and you'd rather not keep a view registry in sync with your blocks. With React Server Components, blocks return JSX, the server renders it and streams the result in React's wire format (Flight), and only Client Components ship JavaScript. + +This page shows the four changes from the [TanStack Start guide](/next/tanstack-start-descriptors) that switch it to Server Components. It reuses that guide's content, CMS and `openPage`. + +## 1. Swap the block map + +Replace `.deco/index.ts` with the [Next.js guide](/next/nextjs#2-create-the-cms)'s `.deco/index.tsx`. It doesn't replace [`page`](/next/built-in-blocks#pages-and-redirects), because its blocks return JSX, which the built-in page already takes. Keep `src/cms.ts` and `src/open-page.server.ts` from the TanStack Start guide as they are. `deco schema` reads `.deco/index.tsx` as well as `.deco/index.ts`, so the scripts don't change. + +## 2. Render blocks on the server + +Replace `src/page.functions.ts` with this file. It's `.tsx` now, because it contains JSX. Each block renders under its own Suspense boundary as soon as it's ready, the streaming technique the [Next.js guide](/next/nextjs#7-stream-each-block-optional) walks through. + +```tsx title="src/page.functions.tsx" +import { Suspense, type ReactNode } from "react"; +import { createServerFn } from "@tanstack/react-start"; +import { getRequest } from "@tanstack/react-start/server"; +import { renderServerComponent } from "@tanstack/react-start/rsc"; +import { z } from "zod"; +import { openPage } from "./open-page.server"; + +async function BlockSlot({ value }: { value: Promise<{ value?: ReactNode; failed: boolean }> }) { + const { value: node, failed } = await value; + if (failed) return <p role="status">This content is temporarily unavailable.</p>; + return node ?? null; // undefined when an editor hid the block: render nothing +} + +export const loadPage = createServerFn({ method: "GET" }) + .inputValidator(z.object({ href: z.string().startsWith("/") })) + .handler(async ({ data }) => { + const page = await openPage<ReactNode>(data.href, getRequest()); + const content = await renderServerComponent( + <main> + {page.blocks.map((block) => ( + <Suspense key={block.key} fallback={<p>Loading…</p>}> + <BlockSlot value={block.value} /> + </Suspense> + ))} + </main>, + ); + return { seo: page.seo, content }; + }); +``` + +## 3. Render the route + +```tsx title="src/routes/$.tsx" +import { createFileRoute } from "@tanstack/react-router"; +import { loadPage } from "../page.functions"; + +export const Route = createFileRoute("/$")({ + loader: ({ location }) => loadPage({ data: { href: location.pathname + location.searchStr } }), + head: ({ loaderData }) => ({ + meta: loaderData?.seo // without seo, the root route's defaults apply + ? [{ title: loaderData.seo.title }, { name: "description", content: loaderData.seo.description }] + : [], + }), + component: () => <>{Route.useLoaderData().content}</>, + pendingComponent: () => <p>Opening page…</p>, + errorComponent: () => <p>The page could not be loaded.</p>, +}); +``` + +`renderServerComponent` serializes the tree in Flight. Client Components like `CartButton` travel as references the browser loads and hydrates. That only works with the RSC plugin from step 4; the `"use client"` directive marks the boundary, but the plugin does the work. Inside this tree, only values React's Flight format can serialize are allowed; serialization adapters you register with Start for loader data don't apply here. If you cache the result with TanStack Query, set `structuralSharing: false`, because Query would otherwise try to diff the RSC payload as plain data. See [Start Server Components](https://tanstack.com/start/latest/docs/framework/react/server-components). + +## 4. Configure Cloudflare Workers + +Server Components build as a separate Vite environment named `rsc`. Listing it under `childEnvironments` bundles it into the same Worker as server rendering, so one deploy serves both. Keep `wrangler.jsonc` from the TanStack Start guide. + +```ts title="vite.config.ts" +import { defineConfig } from "vite"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import react from "@vitejs/plugin-react"; +import rsc from "@vitejs/plugin-rsc"; + +export default defineConfig({ + plugins: [ + cloudflare({ viteEnvironment: { name: "ssr", childEnvironments: ["rsc"] } }), + tanstackStart({ rsc: { enabled: true } }), + rsc(), + react(), + ], + environments: { + rsc: { build: { outDir: "dist/server/rsc" } }, + }, +}); +``` + +To edit content while you develop, keep the `src/cms.ts` hot handler and the reload plugin from [Reload on content changes](/next/tanstack-start-descriptors#reload-on-content-changes); add the plugin to this config. + +<Small>Also requires `@vitejs/plugin-rsc` 0.5.30 and `react-server-dom-webpack` 19.2.7. If one repository builds both the descriptor and the RSC version, give each its own Vite config: the RSC plugins change how every component is bundled.</Small> diff --git a/docs/content/next/telemetry-internals.mdx b/docs/content/next/telemetry-internals.mdx new file mode 100644 index 00000000..a3c7f7b3 --- /dev/null +++ b/docs/content/next/telemetry-internals.mdx @@ -0,0 +1,47 @@ +--- +title: How telemetry is sent +nav: Telemetry internals +group: Under the hood +kind: internals +order: 7 +--- + +# How telemetry is sent + +You're contributing to the SDK, or wiring up a collector, and want to know why a collector that's down never slows a page. This page shows the wire format, how metrics are shaped, and how batches leave a server. What's sent and how to configure it is in [Telemetry](/next/telemetry). + +## The wire format + +Telemetry is OpenTelemetry over OTLP/HTTP, encoded as JSON, posted to the standard paths under the configured endpoint: `/v1/traces`, `/v1/metrics` and `/v1/logs`. JSON keeps a protobuf library out of the edge bundle. Bodies are gzipped. A failed send is retried only on `429`, `502`, `503` and `504`; any other error drops the batch. + +## Metrics + +Metric names follow the OpenTelemetry semantic conventions, such as `http.client.request.duration` for upstream calls. The SDK records no `http.server.*` metrics: the requests your site serves are your framework's to measure. Metrics use **delta temporality**: each batch carries what happened since the last one, because a Cloudflare Workers isolate can disappear at any moment and has no long-lived counter to report. Prometheus-style backends need the collector's `deltatocumulative` processor to turn them into cumulative counters. + +Every batch carries these resource attributes: + +| Attribute | Value | +|---|---| +| `service.name` | `OTEL_SERVICE_NAME`, else the site ID, else `decocms-site` | +| `service.version` | The deployed commit, read from the first variable your host sets (`DECO_COMMIT_SHA`, `WORKERS_CI_COMMIT_SHA`, `CF_PAGES_COMMIT_SHA`, `VERCEL_GIT_COMMIT_SHA`, `GITHUB_SHA`, `RENDER_GIT_COMMIT`, `SOURCE_VERSION`, `COMMIT_SHA`), else `unknown` | +| `deployment.environment.name` | `VERCEL_ENV` when set, else `development` when `NODE_ENV` is `development`, else `production` | +| `deco.site` | The site ID, when there is one | +| `deco.release` | The [revision](/next/releases-and-deployment#what-a-revision-is) being served | + +The standard `OTEL_RESOURCE_ATTRIBUTES` variable overrides any of them, for example `deployment.environment.name=preview`. + +## Sending in the background + +Measurements are aggregated in memory and sent in batches, never in front of a response. On Cloudflare Workers, which run no timers between requests, a batch goes out after the response, inside `ctx.waitUntil`. Elsewhere, it goes out on a timer that doesn't keep the process alive. A collector that's down or slow loses that one batch; nothing waits for it and nothing piles up. + +## Scrubbing + +The SDK removes `cookie`, `authorization` and similar headers, tokens and request and response bodies before anything is encoded. Scrubbing happens on the sending side, not in a collector, so a third-party endpoint gets the same guarantee as the hosted Deco CMS. + +## Several CMS instances + +The [instrumented fetch](/next/api-reference#createinstrumentedfetch-options) reports its measurements to the CMS in the same process, which sends them where its `telemetry` option points. The instrumented fetch reports to the most recently created CMS with telemetry; several sites in one process share one destination. + +## Analytics events + +[Analytics](/next/analytics) is separate from telemetry: it doesn't use the `telemetry` option or the OTLP path above. The script `AnalyticsScript` renders sends page views in the One Dollar Stats tracker's wire format: a JSON object with the page URL (without query string) and a list of events, each with its type, the referrer when it's from another site, and its properties. When the encoded payload is short, it goes as a `GET` with the JSON, base64-encoded, in the `?data=` parameter; otherwise it's sent with `navigator.sendBeacon` or a `POST`. diff --git a/docs/content/next/telemetry.mdx b/docs/content/next/telemetry.mdx new file mode 100644 index 00000000..9ca87dbc --- /dev/null +++ b/docs/content/next/telemetry.mdx @@ -0,0 +1,153 @@ +--- +title: Telemetry +nav: Telemetry +group: Monitoring +order: 21 +description: Send errors, metrics and traces from your servers to your own OpenTelemetry collector or to the hosted Deco CMS, with switches and sample rates kept in the CMS settings block. +--- + +# Telemetry + +Your search provider gets slow on Black Friday, and shelves start timing out. You want upstream latency and error rates in your own Grafana, or somewhere you can look at them without running a collector at all. + +Deco CMS measures what it sees, resolving content and calling APIs, and sends those measurements as OpenTelemetry to wherever you point it. It's open source, and it works with any collector. This page shows how to choose where telemetry goes, what's sent, and how editors and code share control of how much. + +## Choose where telemetry goes + +Pass `telemetry` to [`createCMS`](/next/api-reference#createcms-config). To send to your own collector, give it an OTLP/HTTP endpoint: + +```ts title="cms.ts" +import { createCMS } from "@decocms/blocks"; +import blocks from "./.deco"; +import content from "./.deco/blocks.gen"; + +export const cms = createCMS({ + blocks, + content, + telemetry: { + endpoint: process.env.OTLP_ENDPOINT!, // e.g. https://otel.example.com + headers: { authorization: `Bearer ${process.env.OTLP_TOKEN}` }, // optional + }, +}); +``` + +That works with the OpenTelemetry Collector, Grafana, Honeycomb, or anything else that accepts OTLP over HTTP. + +<Hosted to="/next/hosted-telemetry" label="Hosted telemetry">**No collector to run.** Pass your site's ID and token instead, `telemetry: { site, token }`, and the hosted Deco CMS collects your telemetry and shows it for your site.</Hosted> + +How the destination is chosen: + +- `telemetry: false` sends nothing. +- `telemetry: { … }` sends there. +- Without `telemetry`, the CMS uses `OTEL_EXPORTER_OTLP_ENDPOINT` (and `OTEL_EXPORTER_OTLP_HEADERS`) if they're set. Otherwise nothing is sent: in development, in tests, and anywhere you haven't configured it. + +The top-level `site` and `token` options load [hosted releases and drafts](/next/hosted#connect-your-site). They never turn telemetry on by themselves: telemetry goes only where `telemetry` (or the environment) points. + +<h2 id="whats-sent">What's sent</h2> + +| Measurement | Labels | Reported by | +|---|---|---| +| Upstream latency: each request to a third-party API, timed until its response headers arrive (`http.client.request.duration`) | `provider`, `operation`, `status_class` (2xx, 4xx, 5xx, error), `cached`, `retries` | The instrumented fetch, `createInstrumentedFetch`, which every [upstream client](/next/upstream-clients#what-a-client-is) uses | +| Error logs | error code, block type, provider | The SDK | +| Traces (off by default) | block resolution and upstream spans | The SDK, at `traceSampleRate` | + +That's everything the SDK measures. The requests your site serves and its page cache belong to your web framework, so measure them with its OpenTelemetry setup (Next.js has [`instrumentation.ts`](https://nextjs.org/docs/app/guides/open-telemetry); on Cloudflare Workers, turn on Workers observability). Point it at the same collector and both show up side by side. + +A retried request counts once, with the number of retries as the `retries` label, so retries don't inflate request counts. The `cached` label tells you whether a response came from a [cache](/next/caching#upstream-data), so hit rates show up next to latency. Page views from the browser aren't telemetry: they're [analytics](/next/analytics), a separate feature with its own section in the same settings block. + +## Telemetry settings are content + +How much is sent is a setting editors can see and change. It lives in the `telemetry` section of your site's [CMS settings](/next/built-in-blocks#cms-settings): the saved block named `CMS`, whose type is the built-in `cms-settings`, so it's always in the schema and [the site editor](/next/site-editor#settings) shows it as a form under **Settings**: + +```json title=".deco/blocks/CMS.json" +{ + "__resolveType": "cms-settings", + "telemetry": { + "enabled": true, + "metrics": true, + "errorSampleRate": 0.05, + "traceSampleRate": 0 + } +} +``` + +| Field | Default | What it does | +|---|---|---| +| `enabled` | `true` | Switches all telemetry off when `false`. | +| `metrics` | `true` | Sends upstream metrics. | +| `errorSampleRate` | `0.05` | The share of error logs sent (5%). | +| `traceSampleRate` | `0` | The share of requests traced. | + +The block and the section are optional: without them, the defaults above apply. `deco content` doesn't create the block; save it from the site editor, or add the file, when you want to change something. Switching telemetry off is a commit to this file, made in the site editor or by hand, and it ships like any other content. The destination and its credentials never go here: content is edited in the site editor and shipped in releases, so secrets stay in code. + +Telemetry follows the release your servers serve. A new release with a changed `telemetry` section takes effect when the server picks it up, and a [draft](/next/releases-and-drafts#drafts) never changes what's sent, even while someone previews it. A section with [variants](/next/matchers-and-variants#variants) is read outside any request, so pick between them with date rules, not request rules. + +## Sampling + +Telemetry is sampled and aggregated, so its cost stays flat as traffic grows: + +- **Metrics are aggregated** in memory, per server, and sent in batches: counts, sums and latency buckets per combination of labels, not one record per request. +- **Error logs are sampled** at `errorSampleRate`, so a burst of identical errors sends a few examples, not thousands. +- **Sending never delays a response.** Batches go out in the background, and a collector that's down only loses that batch (see [How telemetry is sent](/next/telemetry-internals#sending-in-the-background)). + +Code caps what content can raise. The sample rates in the `telemetry` section are limited by `limits` on the `telemetry` option, so an editor can't increase what leaves your servers for a third party: + +```ts +telemetry: { + endpoint: process.env.OTLP_ENDPOINT!, + limits: { errorSampleRate: 0.1, traceSampleRate: 0 }, // the defaults +}, +``` + +With these defaults, content can send at most 10% of error logs, and traces stay off until code raises `traceSampleRate`. + +## Privacy + +- Telemetry never carries request or response bodies, tokens, cookies or authorization headers. The SDK scrubs them before sending, so a third-party endpoint gets the same guarantee. +- Requests are labeled by route pattern (`/products/:slug`), never by the raw URL. +- Error logs are sampled and structured: an error's code, message and where it happened. + +## Your own tracing + +The `telemetry` option covers the measurements Deco CMS takes. To trace your own code with [OpenTelemetry](https://opentelemetry.io/), wrap each function in your [block map](/next/blocks#the-block-map) in a span named after its block type: + +```ts title=".deco/index.ts" +import { trace } from "@opentelemetry/api"; +import type { Blocks } from "@decocms/blocks"; +import { PromoBanner } from "../src/promo-banner"; + +const tracer = trace.getTracer("my-store"); + +// Runs a block function inside a span named after its block type. +function traced<P, R>(type: string, fn: (props: P) => R) { + return (props: P) => + tracer.startActiveSpan(type, async (span) => { + try { + return await fn(props); + } catch (error) { + span.recordException(error as Error); + throw error; + } finally { + span.end(); + } + }); +} + +export default { + "promo-banner": traced("promo-banner", PromoBanner), +} satisfies Blocks; +``` + +The wrapped function keeps its props type, so its form doesn't change, and it returns a `Promise`, which fits the same fields (return types are awaited). Make each request span the parent of these block spans and of your outbound HTTP spans, and add the served revision from [`client.revision()`](/next/api-reference#createcms-config) as an attribute. Keep credentials and customer data out of attributes, and export asynchronously, so a collector that's down never blocks rendering. + +## Troubleshooting + +| Symptom | What to check | +|---|---| +| Nothing reaches the collector | Check that `telemetry` is set (or `OTEL_EXPORTER_OTLP_ENDPOINT`), and that the [`telemetry` section](#telemetry-settings-are-content) of the `CMS` block, if you have one, isn't `enabled: false`. Metrics are sent in batches, so expect a short delay. | +| Only some errors arrive | Error logs are sampled at `errorSampleRate`, capped by [`limits`](#sampling). | +| Upstream calls missing | Only requests made through `createInstrumentedFetch` are measured; check that your client uses it (see [Calling APIs](/next/upstream-clients)). | +| Prometheus shows odd counters | Metrics use delta temporality; see [Metrics](/next/telemetry-internals#metrics). | +| Telemetry sent while only loading releases | Top-level `site` and `token` don't send telemetry. Look for `telemetry` or `OTEL_EXPORTER_OTLP_ENDPOINT` in your config and environment. | + +How batches are encoded and sent is [under the hood](/next/telemetry-internals). diff --git a/docs/content/next/troubleshooting.mdx b/docs/content/next/troubleshooting.mdx new file mode 100644 index 00000000..bb4298eb --- /dev/null +++ b/docs/content/next/troubleshooting.mdx @@ -0,0 +1,75 @@ +--- +title: Troubleshooting +nav: Troubleshooting +group: Production +order: 24 +description: Common symptoms, from UNKNOWN_BLOCK to missing telemetry, and what to check for each. +--- + +# Troubleshooting + +A page shows old content after a deploy, or a block comes back as an error instead of rendering. This page shows the first things to check, then common symptoms and what to check for each. + +## First checks + +Most problems come down to three facts: which [revision](/next/releases-and-deployment#what-a-revision-is) this server is serving, which entry was asked for, and which block type it names. Log [`await client.revision()`](/next/api-reference#createcms-config): that's the revision this server serves. The [content module](/next/content#the-content-module), `.deco/blocks.gen.ts`, holds the same value, so you can compare it with a fresh [`deco content`](/next/content#the-content-module) run on the commit you expect. To see an entry with references expanded but no function run, call [`client.resolve(target, { run: false })`](/next/api-reference#client-resolve-target-options). To see it exactly as stored, open `.deco/blocks/<name>.json`. + +## Symptoms + +### Errors + +| Symptom | What to check | +|---|---| +| [`UNKNOWN_BLOCK`](/next/api-reference#errors) | A [`__resolveType`](/next/blocks#functions-as-blocks) names something that is in neither the [block map](/next/blocks#the-block-map) nor the saved blocks. [Built-in blocks](/next/built-in-blocks) never cause it. Compare `__resolveType` with your block map's keys, including case. Add an [alias](/next/renames-and-migrations#rename-a-type-with-an-alias) for renamed types. | +| `NOT_FOUND` | A string target is always a saved block's name, never a block type. Confirm the entry exists in the served revision, that [`createCMS`](/next/api-reference#createcms-config)'s `content` is the content you expect, and that `deco content` ran after the file was added (see [The content module](/next/content#the-content-module)). To run a type directly, pass an inline block: `client.resolve({ __resolveType: "seo", … })`. | +| `CYCLE` | Follow the references in `error.path` and remove the one that leads back to an entry already being expanded (see [The rule in full](/next/how-resolution-works#the-rule-in-full)). | +| `UNKNOWN_BLOCK` right after a content change | The content uses a block type its code doesn't have yet, or no longer has. Ship the code in the same change, or revert the content commit. See [Backward compatibility](/next/checking#backward-compatibility). | +| Warning about an instance with different options | `createCMS` ran twice with the same content but different options. The first instance is kept. Create the CMS once, at module scope (see [One instance per process](/next/api-reference#one-instance-per-process)). | + +### Content and deploys + +| Symptom | What to check | +|---|---| +| My saved block is ignored | A block type probably has the same name, and the function wins. Run [`npx @decocms/blocks check`](/next/checking#what-deco-check-checks), which reports the collision, and rename the entry. | +| Blocks come back as JSON instead of running | You called [`client.list`](/next/api-reference#client-list-type-options) without `{ run: true }` (it returns saved blocks by default), or you passed `{ run: false }` to `resolve`. To run listed entries, pass `run: true`, or resolve each listed entry with `client.resolve(entry)` (a page comes back fully resolved). | +| A function runs more than once per request | A client runs each block once and reuses the result, but only within that client and for that block map object. Make one client per request, not per component, and create the block map at module level, not inside your handler. | +| Content changes mid-response | Use one client for the whole response; a client never changes revision. If you pass a custom `content` object, check that it never returns different content under an existing revision. | +| Old content after a deploy | Compare the revision your server logs (see above) with the one in `.deco/blocks.gen.ts` after running `npx @decocms/blocks content` on your latest commit. If they differ, the build didn't rerun `deco content`: check that [`prebuild`](/next/cli#run-it-before-dev-and-build) runs `deco schema && deco content && deco check` and that the build checked out the commit you expect. See [Deployment](/next/releases-and-deployment). | +| A scheduled campaign went live late, or still shows after it ended | A cached or statically rendered page keeps its variant. See [Pages with variants](/next/caching#pages-with-variants) and the [Next.js caching note](/next/nextjs#caching). | +| A new content file isn't found in development | Run `npx @decocms/blocks content`, or keep `npx @decocms/blocks content --watch` running beside your dev server. Only adding or removing a file needs it; edits to an existing file hot-reload. | + +### Rendering + +| Symptom | What to check | +|---|---| +| Serialization error when sending a block's result to the browser | JSON can't carry functions or React elements. Return descriptors, or render on the server with RSC (see [Rendering](/next/rendering)). | +| Hydration mismatch (React's browser render doesn't match the server's HTML) | Look for time-dependent or random rendering, and for request context the server and browser see differently. If the browser fetches data after the page loads, it may get a newer revision; render data that must match the HTML in the same response. | + +### CLI + +| Symptom | What to check | +|---|---| +| `no .deco/ found` when running a `deco` command | The command walks up from the current folder and found no `.deco/`. Run it inside your app, or pass `--root <dir>` (see [Finding the `.deco` folder](/next/cli#finding-the-deco-folder)). | +| Type errors in `.deco/index.ts` aren't reported | TypeScript's `include` patterns skip folders whose names start with a dot, so `.deco/index.ts` is only checked when an included file imports it (usually your `cms.ts`). To always check it, add `".deco"` to `include` in `tsconfig.json`. | +| `deco check` fails | It lists each problem under the file it's in. A missing required field or an out-of-range value: fix the saved block, or make the field optional in your type. `unknown block type`: add the type to your block map, add an [alias](/next/renames-and-migrations#rename-a-type-with-an-alias) if you renamed it, or fix the `__resolveType`. Run `npx @decocms/blocks schema` first, since it validates against `schema.gen.json` as it is. See [Checking content](/next/checking) and [Backward compatibility](/next/checking#backward-compatibility). | +| `deco: command not found` | In a terminal, run `npx @decocms/blocks <command>`. The short name `deco` works inside `package.json` scripts once `@decocms/blocks` is installed; if a script can't find it, run `npm install` (see [CLI](/next/cli)). | +| `Cannot find package 'typescript'` when running a `deco` command | The CLI uses your project's TypeScript. npm and Bun install it with `@decocms/blocks`; with another package manager, run `npm install -D typescript` (or its equivalent). | + +### Site editor + +| Symptom | What to check | +|---|---| +| Wrong fields in the editor | Regenerate the schema from the right `.deco/index.ts` (check [`--root`](/next/cli#finding-the-deco-folder) in a monorepo). Check the first parameter's type and its property JSDoc (see [From types to forms](/next/schema#from-types-to-forms)). | +| [The site editor](/next/site-editor) can't reach `deco serve` | Check that the server is still running: while it's down, the site editor shows that it's waiting for it and reconnects once it's back. Use the endpoint `deco serve` printed (`localhost` with its port). Allow the browser's prompt to let the site editor reach your machine (Local Network Access). See [Edit on your machine](/next/site-editor#edit-on-your-machine). | +| The site editor shows plain fields and a banner about the schema | The site has no schema yet (`.deco/schema.gen.json`), so each block opens as fields inferred from its JSON, grouped by `__resolveType`. Saving keeps every value you didn't touch. Run `npx @decocms/blocks schema` in the site's folder: the site editor picks the schema up on its own and shows the typed forms (see [No schema yet](/next/content-protocol#no-schema-yet)). | +| A form field in the site editor is a text box instead of a picker | The site editor never runs your code (see [What works without your code](/next/site-editor#what-works-without-your-code)), so options that would come from a function aren't available. Give the field a string-literal union, an enum or an `@options` list, and run `npx @decocms/blocks schema` (see [Widgets](/next/schema#widgets)). | +| A preview shows published content instead of the draft | The host may be outside your preview hosts: `cms.draftPointer` ignores drafts there and serves the release. Check the `preview` section of the `CMS` block and `preview.hosts` in `createCMS`, including your dev server's host and port (see [Allow previews per host](/next/releases-and-drafts#allow-previews-per-host)). | + +### Telemetry + +| Symptom | What to check | +|---|---| +| No telemetry reaches your collector | Telemetry goes only where the [`telemetry` option](/next/telemetry#choose-where-telemetry-goes) points, or to `OTEL_EXPORTER_OTLP_ENDPOINT` when the option is left out. Check that it isn't `false` and that the `telemetry` section of the `CMS` block doesn't set `enabled` to `false`. Metrics are sent in batches and errors are sampled, so expect a delay and only a share of errors. More in [Telemetry troubleshooting](/next/telemetry#troubleshooting). | +| Upstream calls missing from telemetry | Only requests made through `createInstrumentedFetch` are measured; check that your client uses it (see [Calling APIs](/next/upstream-clients)). | + +Problems specific to the [hosted Deco CMS](/next/hosted), such as a release that hasn't reached a server, are in [Publishing without a deploy](/next/hosted-publishing#troubleshooting). diff --git a/docs/content/next/upstream-clients.mdx b/docs/content/next/upstream-clients.mdx new file mode 100644 index 00000000..feadb9a7 --- /dev/null +++ b/docs/content/next/upstream-clients.mdx @@ -0,0 +1,151 @@ +--- +title: Calling APIs +nav: Calling APIs +group: Content and data +order: 13 +description: Deco CMS doesn't fetch data. Sites call commerce, search and email platforms through thin, instrumented API clients, and this page shows how to use and write one. +--- + +# Calling APIs + +A product shelf on your homepage needs search results from your commerce platform. Deco CMS doesn't fetch them for you: your code calls the API, the same way it would without a CMS. + +Deco CMS gives you **upstream clients** for that: thin, typed API clients that send every request through the framework's instrumented fetch, so every call to a third-party API is measured the same way, and the measurements go wherever your [telemetry](/next/telemetry#whats-sent) goes. + +This page shows how to call a platform through its client, how to write a client for a service Deco doesn't ship one for, and how retries work. + +## What a client is + +Deco ships one client package per platform: VTEX, Shopify, Wake, Magento, Algolia, Resend and others. Each one is: + +- **Typed request functions**, one per API operation, with the platform's own request and response types (generated from its API description where it has one). +- **Built on the instrumented fetch**, [`createInstrumentedFetch`](/next/api-reference#createinstrumentedfetch-options), which times each request and labels it with the provider, the operation, the status class, whether it was cached, and how many retries it took. +- **Configured by your code.** A client takes its settings (account, keys, endpoint) as arguments; you read them from environment variables where you create it, or from a [`secret` block](/next/built-in-blocks#secrets) when editors should set a key without a deploy. + +Everything else lives in code your site owns: + +| Not in a client | Where it lives | +|---|---| +| Converters from a platform's types to shared commerce types | [Platform templates](/next/how-it-works#key-terms), the starter sites you copy and then own, and your code | +| React hooks for the cart, the user and the wishlist | Platform templates | +| Cart, session and sign-in flows | Platform templates, as your framework's server functions or route handlers | +| Website features: SEO helpers, sitemaps, redirect logic | Platform templates and your code. The framework itself ships the built-in `always`, `never` and `date` [matchers](/next/matchers-and-variants#matchers), `multivariate`, and [analytics](/next/analytics) for page views. | +| [Loaders and actions](/v7/loaders) (v7's data functions) that [the site editor](/next/site-editor) ran through `/deco/invoke` | Your code; see [Migrating from v7](/next/renames-and-migrations#loaders-actions-and-invoke). | + +The Salesforce client, `@decocms/apps-sfmc-personalization`, covers Salesforce Marketing Cloud Personalization (formerly Evergage); it replaces `@decocms/apps-salesforce`. + +## Call a client + +Without a client, you'd write the request by hand: + +```ts +const response = await fetch(`https://${account}.vtexcommercestable.com.br/api/catalog_system/pub/products/search?ft=linen+shirt`); +const products = await response.json(); +``` + +A client gives you the same call, typed and measured. Create it once, at module scope, and call it from wherever your app fetches data: a route loader, a server function, a route handler. A block function can call one too, since it's ordinary code; it gets its inputs from content and fetches what it needs. + +Per-shopper reads and anything that changes state, like adding to a cart, belong in your framework's server functions or route handlers. Block functions should only read; see [The rule in full](/next/how-resolution-works#the-rule-in-full). + +```ts title="src/commerce.ts" +import { createVtexClient } from "@decocms/apps-vtex"; + +export const vtex = createVtexClient({ + account: process.env.VTEX_ACCOUNT!, + appKey: process.env.VTEX_APP_KEY!, + appToken: process.env.VTEX_APP_TOKEN!, +}); + +// Anywhere on the server +const products = await vtex.search.products({ query: "linen shirt", count: 12 }); +``` + +The operation names here are illustrative; each client mirrors its platform's API. + +Caching upstream responses is your app's job, not the client's: pass a caching fetch as the client's `fetch` option (see [Upstream data](/next/caching#upstream-data)). + +## Retries and failures + +**Defaults per client.** The VTEX client turns on retries and a circuit breaker, which fails fast for a short time after repeated failures; other clients leave both off. + +A retried request is measured once, with the number of retries as a label (see [What's sent](/next/telemetry#whats-sent)). + +## Write a client + +When there's no client for the service you call, write one. It's a small module: a factory that takes the configuration, and one function per operation. + +```ts title="acme-search.ts" +import { createInstrumentedFetch } from "@decocms/blocks/fetch"; + +export interface AcmeSearchConfig { + endpoint: string; // e.g. https://api.acme-search.example + apiKey: string; +} + +export interface ProductHit { + sku: string; + name: string; + price: number; + image: string; + url: string; +} + +export class AcmeSearchError extends Error { + constructor(readonly operation: string, readonly status: number) { + super(`acme-search ${operation} failed with HTTP ${status}`); // no body, no key + } +} + +export function createAcmeSearch(config: AcmeSearchConfig, options: { fetch?: typeof fetch } = {}) { + // Every request through this fetch is timed and labeled as provider "acme-search". + const request = createInstrumentedFetch({ provider: "acme-search", fetch: options.fetch }); + + return { + async search(query: string, limit = 10): Promise<ProductHit[]> { + const url = new URL("/v1/search", config.endpoint); + url.searchParams.set("q", query); + url.searchParams.set("limit", String(limit)); + + const response = await request(url, { + operation: "search", // the operation label + headers: { authorization: `Bearer ${config.apiKey}` }, + }); + if (!response.ok) throw new AcmeSearchError("search", response.status); + + const body = (await response.json()) as { hits: ProductHit[] }; + return body.hits; + }, + }; +} +``` + +Then create it with your settings, once, and use it like any other client: + +```ts title="src/search.ts" +import { createAcmeSearch } from "./acme-search"; + +export const search = createAcmeSearch({ + endpoint: process.env.ACME_SEARCH_URL!, + apiKey: process.env.ACME_SEARCH_KEY!, +}); +``` + +A block function in your [block map](/next/blocks#the-block-map) can then fetch what it renders: + +```tsx title=".deco/index.tsx (excerpt)" +"product-shelf": async ({ title, query }: { title: string; query: string }) => { + const products = await search.search(query, 8); + return <ProductShelf title={title} products={products} />; +}, +``` + +A few rules keep clients consistent: + +1. **One provider name per service**, lowercase, such as `acme-search`. Dashboards group by it. +2. **Name every operation.** Use the API's own name for it (`search`, `getProduct`), never a URL with IDs in it, so the label has a small, fixed set of values. +3. **Take configuration as arguments.** Read environment variables where the site creates the client, not inside it, so the same client works on Node, Workers and in tests. +4. **Keep it thin.** Return the platform's types. Converting to your own types, caching policy and UI state belong to the site. +5. **Never put bodies, tokens or cookies in errors or logs.** Report the operation and the status. +6. **Accept a `fetch` option** for tests and caching (see [Upstream data](/next/caching#upstream-data)). For tests, pass a fake that returns canned responses, and assert on the requests it received. + +`createInstrumentedFetch` measures every request; the measurements go wherever [telemetry](/next/telemetry#choose-where-telemetry-goes) is configured, and nowhere when it's off (see [What's sent](/next/telemetry#whats-sent)). Its options are in the [API reference](/next/api-reference#createinstrumentedfetch-options). diff --git a/docs/content/v7/algolia.mdx b/docs/content/v7/algolia.mdx new file mode 100644 index 00000000..f6b036a0 --- /dev/null +++ b/docs/content/v7/algolia.mdx @@ -0,0 +1,86 @@ +--- +title: Algolia +group: Apps +order: 33 +description: A shared, configured Algolia v5 client for writing your own search loaders. +--- + +# Algolia + +`@decocms/apps-algolia` configures one [Algolia](https://www.algolia.com) search client per Worker from your decofile and hands it to your loaders. It doesn't ship search loaders of its own yet: you write the queries, and the app takes care of credentials and sharing the client. + +<Callout> + +**Experimental.** This is an initial scaffold. Not available yet: product list, listing page and suggestion loaders, indexing actions, and an analytics section. It has no registry entry, so configure it with `initAlgoliaFromBlocks` or `configureAlgolia`, as shown below. + +</Callout> + +## Installing + +The app uses version 5 of Algolia's JavaScript client, which relies only on the standard `fetch` and `crypto` APIs and so runs on Cloudflare Workers (version 4 doesn't). Install it next to the app; it's declared as an optional peer dependency, but the client entry points need it: + +```bash +bun add @decocms/apps-algolia algoliasearch@^5 +``` + +## Configuring + +Editors configure the app in a `deco-algolia` block: + +| Field | What it does | +|---|---| +| `applicationId` | Your Algolia application id. | +| `searchApiKey` | A search-only key, as plain text. | +| `adminApiKey` | An admin key, as a secret: encrypted, or a reference to an environment variable by `name`. | + +`initAlgoliaFromBlocks(blocks, blockKey?)` reads that block (pass a second argument if yours has another key), resolves the admin key, and configures the client. It's asynchronous and returns `false` when the block isn't there. Because `createSiteSetup`'s `initPlatform` doesn't wait for promises, await it in a server-only module your server entry imports: + +```ts title="src/setup/algolia.ts" +import { loadBlocks } from "@decocms/blocks/cms"; +import { initAlgoliaFromBlocks } from "@decocms/apps-algolia"; + +await initAlgoliaFromBlocks(loadBlocks()); +``` + +Or configure it directly with `configureAlgolia({ applicationId, searchApiKey, adminApiKey })`. Calling it again replaces the client. + +## Writing a search loader + +`getAlgoliaClient()` returns the shared client, creating it on first use. Every loader in the Worker gets the same instance, so they also share the client's in-memory request cache. + +```ts title="src/loaders/search.ts" +import { getAlgoliaClient } from "@decocms/apps-algolia/client"; + +export interface Props { + /** @title Search term */ + term: string; +} + +export default async function search(props: Props) { + const client = getAlgoliaClient(); + const { hits } = await client.searchSingleIndex({ + indexName: "products", + searchParams: { query: props.term, hitsPerPage: 12 }, + }); + return hits; +} +``` + +`getAlgoliaClient()` throws if `applicationId` is missing, or if there's neither an admin key nor a search key. The `Indices` type, from `@decocms/apps-algolia/types`, names the conventional index names: `products`, `products_price_asc`, `products_price_desc` and `products_query_suggestions`. + +<Callout type="warning"> + +**Keep the client on the server.** When an admin key is configured, the shared client uses it for every call. Use `getAlgoliaClient()` only in server code such as loaders, and return search results, never the client, to the browser. If browser code needs to query Algolia directly, give it the search-only key and create a separate client there. + +</Callout> + +A `client` loader (`@decocms/apps-algolia/loaders/client`) exists for content written for the earlier framework that asked for the client by name. For new code, call `getAlgoliaClient()` directly. + +## Observability + +Algolia's client owns its own transport and cache, so its calls don't go through the framework's instrumented fetch and don't appear in the upstream metrics. That's by design. Wrap your loaders' work in a span with `withTracing`, from `@decocms/blocks/sdk/observability`, if you want them traced (see [Observability](/v7/observability)). + +## Related + +- [Apps](/v7/apps): app status and secrets. +- [Loaders and actions](/v7/loaders): registering your loaders. diff --git a/docs/content/v7/apps-commerce.mdx b/docs/content/v7/apps-commerce.mdx new file mode 100644 index 00000000..9427480b --- /dev/null +++ b/docs/content/v7/apps-commerce.mdx @@ -0,0 +1,150 @@ +--- +title: Commerce types and utilities +nav: Commerce +group: Apps +order: 26 +description: The shared commerce vocabulary every platform app returns, the Cart v2 contract, and small helpers for prices, links, offers and analytics. +--- + +# Commerce types and utilities + +`@decocms/apps-commerce` is the platform-neutral layer under every commerce app. It doesn't talk to any backend. Instead it defines the shapes they all return (a product, a product page, a listing page, a cart), so a section written against these types works whether the data came from VTEX, Shopify or Wake. It also holds the Cart v2 contract, the app contract types, and a handful of helpers for prices, links and analytics. + +It ships no UI components. Images, pictures and JSON-LD components live in `@decocms/blocks/hooks` (see [Images, scripts and UI helpers](/v7/components)). + +```bash +bun add @decocms/apps-commerce +``` + +## The shared vocabulary + +The types follow [schema.org](https://schema.org), the same vocabulary search engines read, so the data a loader returns can also be rendered as structured data. Import them from `@decocms/apps-commerce/types`: + +```ts title="src/sections/ProductShelf.tsx" +import type { Product } from "@decocms/apps-commerce/types"; + +export interface Props { + /** @title Products */ + products: Product[] | null; +} + +export default function ProductShelf({ products }: Props) { + return ( + <ul> + {products?.map((p) => ( + <li key={p.productID}> + <img src={p.image?.[0]?.url} alt={p.name} width={200} height={200} /> + <a href={p.url}>{p.name}</a> + <span>{p.offers?.lowPrice}</span> + </li> + ))} + </ul> + ); +} +``` + +The types you'll use most: + +| Type | What it holds | +|---|---| +| `Product` | One sellable item: `productID`, `sku`, `name`, `image`, `offers` (an `AggregateOffer`), and optionally `isVariantOf` (the `ProductGroup` with its sibling variants), `brand`, `additionalProperty` and more. | +| `ProductDetailsPage` | What a product page needs: `product`, `breadcrumbList` and optional `seo`. | +| `ProductListingPage` | What a category or search page needs: `products`, `filters`, `breadcrumb`, `pageInfo`, `sortOptions` and optional `seo`. | +| `Filter` | Either a `FilterToggle` (a list of values with counts and URLs) or a `FilterRange` (a `min`/`max`). | +| `PageInfo` | Pagination: `currentPage`, `nextPage`, `previousPage`, and optionally `records` and `recordPerPage`. | +| `Suggestion` | Autocomplete results: `searches` and `products`. | +| `SiteNavigationElement` | A menu item with `children`, nested up to five levels. Use it instead of the deprecated `Navbar`/`NavItem`. | +| `Minicart` | A cart ready for a drawer: `original` (the platform's raw cart) plus a `storefront` view with `items`, `total`, `subtotal`, `discounts`, `currency`, `checkoutHref` and more. | +| `Offer`, `AggregateOffer`, `UnitPriceSpecification` | Prices, list prices, installments and availability. | + +**Money is always in major units:** `19.9` means 19.90 in the store's currency, never 1990 cents. + +## Analytics types and mappers + +The package also defines the GA4 event vocabulary: `AnalyticsItem` and the event types (`AddToCartEvent`, `ViewItemEvent`, `ViewItemListEvent`, `SelectItemEvent`, `BeginCheckoutEvent` and the rest), grouped in the `AnalyticsEvent` union. A `deco` event carries the page's active flags. + +To turn a `Product` into an `AnalyticsItem`, there are two mappers with the same name in different places. Neither reads the price from the product: you pass it in, usually from [`useOffer`](#helpers). They also don't produce identical output: + +| Import from | `price` in the result comes from | `item_variant` is | Extra | +|---|---|---|---| +| `@decocms/apps-commerce/sdk/analytics` | the `lowPrice` option | the product's `sku` | An `extend` callback to add custom fields, and `mapProductToAnalyticsItemList` for lists | +| `@decocms/apps-commerce/utils/productToAnalyticsItem` | the `price` option | the product's `name` | — | + +In both, `discount` is `listPrice - price` when you pass both options. Pick one mapper and use it everywhere, so the same product reports the same fields on every event. Both accept a `breadcrumbList`, which gives the most reliable category fields; without it they fall back to the product's `category` string. + +```ts title="src/sdk/analytics.ts" +import { mapProductToAnalyticsItem } from "@decocms/apps-commerce/sdk/analytics"; +import { useOffer } from "@decocms/apps-commerce/sdk/useOffer"; +import type { Product } from "@decocms/apps-commerce/types"; + +export function toItem(product: Product, index: number) { + const { price, listPrice } = useOffer(product.offers); + return mapProductToAnalyticsItem({ + product, + index, + quantity: 1, + lowPrice: price, + price, + listPrice, + }); +} +``` + +## Cart v2: sections and projection + +Cart v2 splits every cart operation into two independent choices, so a page only pays for the data it shows: + +- **`sections`**: what to ask the platform to compute. On VTEX these map to the order form's sections (`items`, `totalizers`, `shippingData`, `paymentData` and so on). +- **`projection`**: what the server sends back to the browser. + +| Projection | Returns | Typical use | +|---|---|---| +| `"none"` | `{ ok: true }` | Fire-and-forget writes | +| `"summary"` | `{ orderFormId, totalItems, total }` | A cart badge | +| `"summary+items"` | The summary plus slim items (name, image, price, quantity) | Add to cart, the default | +| `"minicart"` | A full `Minicart` | A cart drawer | +| `"raw"` | The platform's cart, untouched | Rare: debugging, migrations | + +Three section presets cover the usual cases: `SECTIONS_MINIMAL` (items, totals, messages), `SECTIONS_DRAWER` (what a drawer renders, including shipping and sellers) and `SECTIONS_FULL`. `defaultSectionsFor(projection)` picks the matching preset when you pass only a projection. The types and presets are exported from `@decocms/apps-commerce/types/cart`, and re-exported from `@decocms/apps-commerce/types`. + +VTEX is the first platform to implement Cart v2; see [VTEX](/v7/vtex#cart). + +## Helpers + +| Helper | Import from | What it does | +|---|---|---| +| `formatPrice(price, currency?, locale?)` | `@decocms/apps-commerce/sdk/formatPrice` | Formats a number as currency with a cached `Intl.NumberFormat`. Defaults to `"BRL"` and `"pt-BR"`. Returns `null` for `null`, `undefined` or non-finite values. | +| `formatPriceRange(value, currency?, locale?, separator?)` | `@decocms/apps-commerce/sdk/formatPrice` | Formats a `"min:max"` facet value as a price range. Returns the input unchanged if it can't parse it. | +| `relative(link, options?)` | `@decocms/apps-commerce/sdk/url` | Turns an absolute URL into a path plus query, for links. `stripSearchParams` removes keys such as `idsku`. | +| `useOffer(aggregateOffer)` | `@decocms/apps-commerce/sdk/useOffer` | Picks the price, list price, availability, seller and best installment out of an offer. | +| `useVariantPossibilities(variants, selected)` | `@decocms/apps-commerce/sdk/useVariantPossibilities` | Builds the variant matrix for a selector: property name, then value, then URL. | +| `parseRange(value)`, `formatRange(from, to)` | `@decocms/apps-commerce/utils/filters` | Parses `"10:50"` into `{ from: 10, to: 50 }` (or `null`), and back. | +| `canonicalFromBreadcrumblist(list)` | `@decocms/apps-commerce/utils/canonical` | The URL of the deepest breadcrumb item, for canonical links. | +| `getStateFromZip(cep)` | `@decocms/apps-commerce/utils/stateByZip` (default export) | A Brazilian state code from a postal code. | + +```ts +import { formatPrice } from "@decocms/apps-commerce/sdk/formatPrice"; +import { relative } from "@decocms/apps-commerce/sdk/url"; + +formatPrice(99, "USD", "en-US"); // "$99.00" +relative("https://www.example.com/p/shoe?idsku=1&color=red", { stripSearchParams: ["idsku"] }); // "/p/shoe?color=red" +``` + +<Callout>**`useOffer` and `useVariantPossibilities` aren't React hooks.** Despite the `use` prefix they're plain functions, so you can call them anywhere, including in loaders. `useOffer`'s installment text is always formatted in `pt-BR` and `BRL`; format it yourself from the returned `installment` for other locales.</Callout> + +## App contract types + +The types for writing an app live here too, so apps don't depend on the framework packages: + +- `@decocms/apps-commerce/app-types`: `AppDefinition`, `AppManifest`, `AppMiddleware`, `AppModContract`, `ResolveSecretFn`. +- `@decocms/apps-commerce/registry`: `AppRegistryEntry` and `AppRegistry`, the shape of each app's `*_REGISTRY_ENTRY`. +- `@decocms/apps-commerce/resolve`: `resolveApps(apps)`, which merges several app definitions into one manifest and chains their middleware (the first app runs outermost). +- `@decocms/apps-commerce/manifest-utils`: `extractHandlers(manifest)`, which flattens a manifest into one function per key. + +[Apps](/v7/apps) explains how the framework uses them. + +## Related + +- [Apps](/v7/apps): installing and configuring apps. +- [VTEX](/v7/vtex): the reference implementation of these types and of Cart v2. +- [SEO](/v7/seo): turning these types into structured data. diff --git a/docs/content/v7/apps-website.mdx b/docs/content/v7/apps-website.mdx new file mode 100644 index 00000000..338bdec3 --- /dev/null +++ b/docs/content/v7/apps-website.mdx @@ -0,0 +1,164 @@ +--- +title: Website app +nav: Website +group: Apps +order: 27 +description: "The generic, non-commerce half of a site: SEO sections, analytics tags, theme and fonts, video, and the secret and environment loaders." +--- + +# Website app + +`@decocms/apps-website` holds the pieces every site needs whatever it sells: the SEO section editors place on pages, the Google Tag Manager and GA4 tags, a theme and font loader, a video component, and the small loaders Studio uses for secrets and environment values. It's an [app](/v7/apps) like the commerce ones, configured from a website block in the decofile. + +```bash +bun add @decocms/apps-website +``` + +## What lives here, and what doesn't + +Many content types carry a `website/...` name, but not all of them come from this package. Pages, matchers, flags, redirects, and the `Lazy`/`Deferred` rendering wrappers are built into `@decocms/blocks` itself, so they work without installing anything: + +| `__resolveType` | Provided by | +|---|---| +| `website/pages/Page.tsx` | `@decocms/blocks` (see [Pages and routing](/v7/routing)) | +| `website/matchers/*`, `website/flags/multivariate*` | `@decocms/blocks` (see [Matchers and variants](/v7/variants)) | +| `website/loaders/redirect.ts`, `redirects.ts`, `redirectsFromCsv.ts` | `@decocms/blocks` (see [Pages and routing](/v7/routing)) | +| `website/sections/Rendering/Lazy.tsx`, `Deferred.tsx` | `@decocms/blocks` (see [Deferred sections](/v7/rendering)) | +| `website/sections/Seo/SeoV2.tsx`, `Seo.tsx` | This package, but page SEO is read by the binding (see [SEO sections](#seo-sections)) | +| `website/sections/Analytics/Analytics.tsx` | This package (render the component; see [Analytics](#analytics)) | +| `website/loaders/secret.ts`, `secretString.ts`, `environment.ts` | This package | +| `website/loaders/fonts/googleFonts.ts`, `fonts/local.ts` | This package | + +## Configuring + +The app's `configure` reads one thing from its block: `seo`, the site-wide SEO defaults. It stores them so the SEO section can fall back to them, and returns the app's loaders and sections. It never returns `null`, so the app installs even with an empty block. + +Unlike the commerce apps, this package has no `./registry` entry. Write one yourself, using the key your decofile gives the website block: + +```ts title="src/setup/apps.ts" +import type { AppRegistryEntry } from "@decocms/blocks-admin/apps"; +import * as websiteMod from "@decocms/apps-website/mod"; + +export const WEBSITE_ENTRY: AppRegistryEntry = { + blockKey: "<your website block key>", + module: async () => websiteMod, +}; +``` + +Add it to the array you pass to `autoconfigApps` (see [Apps](/v7/apps#installing-apps-with-autoconfig)). Without it, `website/loaders/*` blocks in your content have nothing to resolve to, and sections that use them render without that data. + +If you don't use autoconfig, set the SEO defaults directly with `configureWebsite`, from the package root: + +```ts title="src/setup.ts" +import { configureWebsite } from "@decocms/apps-website"; + +configureWebsite({ + seo: { + title: "My Store", + titleTemplate: "%s | My Store", + description: "Everything for your home.", + type: "website", + }, +}); +``` + +`getWebsiteConfig()` reads the defaults back; it throws if neither `configure` nor `configureWebsite` has run. + +The site SEO block fields (`SeoConfig`, from `@decocms/apps-website/types`): + +| Field | Default | What it does | +|---|---|---| +| `title` | — | Fallback page title. | +| `titleTemplate` | `%s` | A template where `%s` is replaced by the page title. | +| `description` | — | Fallback description. | +| `descriptionTemplate` | `%s` | Same, for the description. | +| `type` | `website` | Open Graph type: `website` or `article`. | +| `image` | — | Social sharing image; 1200×630 is recommended. | +| `favicon` | — | A 16×16 icon. | +| `themeColor` | — | The browser's theme color. | +| `noIndexing` | — | Ask search engines not to index the site. | + +## SEO sections + +Editors set a page's SEO by putting a `SeoV2` block (`website/sections/Seo/SeoV2.tsx`) in the page's SEO field. The framework treats that field specially: the binding resolves it eagerly and writes the page's `<head>` itself, so you don't render anything for it. The same SEO types placed in a page's list of sections are skipped and render nothing. [SEO](/v7/seo) covers that path. + +This package holds the section module behind those blocks. Its `loader` merges the page's own values over the site defaults and applies the templates: the page title, or else the site title, inserted into `titleTemplate`. `Seo` (v1) is deprecated in favour of `SeoV2`. + +The section renders the `Seo` component, which you can also use directly, for example in your own section. It emits the `<title>`, description, canonical link, robots, Open Graph and Twitter tags, and JSON-LD scripts, and relies on React 19 moving them into `<head>`. This section reads the product page its loader provides and passes the page's `seo` fields (`title`, `description`, `canonical`, `noIndexing`) to `Seo`: + +```tsx title="src/sections/Product/ProductSeo.tsx" +import Seo from "@decocms/apps-website/components/Seo"; +import type { ProductDetailsPage } from "@decocms/apps-commerce/types"; + +export interface Props { + page: ProductDetailsPage | null; +} + +export default function ProductSeo({ page }: Props) { + return <Seo {...(page?.seo ?? {})} titleTemplate="%s | My Store" />; +} +``` + +HTML in `title` and `description` is stripped. `robots` becomes `noindex, nofollow` when `noIndexing` is set. How SEO is put together for a whole page, including on TanStack and Next.js, is in [SEO](/v7/seo). + +## Analytics + +Three analytics pieces can run side by side; each has its own switch. + +| Piece | Where | On by default | What it does | +|---|---|---|---| +| `Analytics` component | `@decocms/apps-website/components/Analytics` | When rendered | Renders GTM containers (`trackingIds`) and GA4 tags (`googleAnalyticsIds`), and forwards the page's `DECO` events to `window.dataLayer`. | +| `OneDollarStats` component | `@decocms/apps-website/components/OneDollarStats` | Yes, once mounted | A lightweight analytics integration that records page views and DECO events, enriched with the visitor's A/B flags. | +| `Stats` component | `@decocms/blocks/hooks` | No | Deco's first-party collector. The TanStack root layout mounts it; it renders only when `DECO_ANALYTICS_ENABLED` is `"true"`. | + +Render the `Analytics` component from your own layout or a site section: an `Analytics` block (`website/sections/Analytics/Analytics.tsx`) placed in a page's sections is skipped by the resolver and renders nothing. The GTM and GA4 tags render only when `NODE_ENV` is `production`; in development you'll only see the event-forwarding snippet. Set `disableAutomaticEventPush` to stop the forwarding. + +`OneDollarStats` is meant to be mounted once, inside the root layout: + +```tsx title="src/routes/__root.tsx" +import { createRootRoute } from "@tanstack/react-router"; +import { DecoRootLayout } from "@decocms/tanstack"; +import OneDollarStats from "@decocms/apps-website/components/OneDollarStats"; + +export const Route = createRootRoute({ + component: () => ( + <DecoRootLayout siteName="my-store"> + <OneDollarStats /> + </DecoRootLayout> + ), +}); +``` + +| Variable | What it does | +|---|---| +| `ONEDOLLAR_ENABLED` | Set to `false` to turn `OneDollarStats` off. | +| `ONEDOLLAR_COLLECTOR` | Override the collector URL. The `collectorAddress` prop wins over it. | +| `ONEDOLLAR_STATIC_SCRIPT` | Override the tracker script URL. The `staticScriptUrl` prop wins over it. | + +## Theme, fonts and video + +`Theme` (`@decocms/apps-website/components/Theme`) injects font stylesheets and CSS custom properties. It takes `fonts`, `variables` (a list of `{ name, value }`) and an optional `colorScheme` of `light` or `dark`, which wraps the variables in a matching media query. + +The fonts can come from one of two loaders in this package: + +- `website/loaders/fonts/googleFonts.ts` fetches the Google Fonts stylesheet for the families and weights you list. +- `website/loaders/fonts/local.ts` builds `@font-face` rules from font files you upload. + +The resolver skips a `googleFonts.ts` block found in content, so to use Google Fonts, call the loader from your own code (it's exported at `@decocms/apps-website/loaders/fonts/googleFonts`) and pass its result to `Theme`. + +`Video` (`@decocms/apps-website/components/Video`) is a `<video>` wrapper that requires `width` and `height`, to avoid layout shift. With `forceOptimizedSrc` it routes the source through the image CDN. + +## Secret and environment loaders + +Two small loaders let content refer to values that shouldn't live in the decofile as plain text: + +- **`website/loaders/secret.ts`** takes `encrypted` and an optional `name`. This is the block Studio writes when an editor fills in a secret field. As a loader, it returns an object with a `get()` method: if an environment variable called `name` is set, `get()` returns it; otherwise it returns the stored value as-is, without decrypting it. `secretString.ts` is a deprecated variant. +- **`website/loaders/environment.ts`** takes `value` and an optional `name`, and returns the environment variable `name` if it's set, otherwise `value`. + +Apps don't go through these loaders. They receive their block as stored, with the secret still in its `{ encrypted, name }` form, and pass it to `resolveSecret`, which decrypts it with `DECO_CRYPTO_KEY`; see [Apps](/v7/apps#secrets). + +## Related + +- [SEO](/v7/seo): page SEO end to end. +- [Images, scripts and UI helpers](/v7/components): `Image`, `Picture` and `Stats`. +- [Apps](/v7/apps): installing apps. diff --git a/docs/content/v7/apps.mdx b/docs/content/v7/apps.mdx new file mode 100644 index 00000000..ef595e9b --- /dev/null +++ b/docs/content/v7/apps.mdx @@ -0,0 +1,173 @@ +--- +title: Apps +nav: Overview +group: Apps +order: 25 +description: What a Deco app is, how a site installs and configures one, and which apps exist today. +--- + +# Apps + +An **app** is a companion package, published as `@decocms/apps-*`, that brings everything a site needs to talk to one platform: loaders that fetch data, actions that change it, sometimes sections and middleware, and the configuration that holds credentials. Editors configure an app in Studio, as a block in the decofile; your code installs it once at boot. This page explains the contract every app follows, the two ways to wire one into a site, and how credentials are resolved. The pages after it cover each app. + +## Key terms + +<Terms> + <Term name="App">A package such as `@decocms/apps-vtex` that exports a `configure` function and a manifest of loaders and actions. See the [glossary](/v7/glossary).</Term> + <Term name="App block">The decofile block that holds an app's settings, such as `deco-vtex` or `deco-resend`. Its key is the app's **block key**.</Term> + <Term name="Manifest">The list of loaders, actions and sections an app provides, keyed by paths like `vtex/loaders/intelligentSearch/productListingPage` or `resend/actions/send`.</Term> + <Term name="Registry entry">A small object, `*_REGISTRY_ENTRY`, that tells the framework which block key belongs to which app module.</Term> + <Term name="Secret">A credential stored in the app block, either as plain text or encrypted. Apps read it through `resolveSecret`, which can also fall back to an environment variable.</Term> +</Terms> + +## The app contract + +Every app that can be configured from content has a `mod` module with one required export, `configure`. It receives the app block from the decofile and a secret resolver, and returns an **app definition**, or `null` when the block is missing something it needs (such as an account name or an API key): + +```ts title="The shape every app's mod module follows" +configure(block, resolveSecret): Promise<AppDefinition | null> +``` + +An app definition has a `name`, a `manifest` with `loaders`, `actions` and optionally `sections`, a `state` object (usually the resolved config), and optionally a `middleware` and `dependencies`. The types live in `@decocms/apps-commerce/app-types` (`AppDefinition`, `AppManifest`, `AppMiddleware`, `AppModContract`) if you write an app of your own. + +Some apps also export `handlers`, a map of extra keys to functions the framework registers alongside the manifest, such as Wake's sitemap handler. + +When the framework receives an app definition, it does four things with it: + +1. **Registers every loader and action.** A module's default export is registered at the module key (`shopify/loaders/ProductList`), and each named function export at `<moduleKey>/<fnName>` (`vtex/actions/checkout/addItemsToCart`). Every key also gets a `.ts` twin. The same functions become available to content (a block whose `__resolveType` is that key) and to [invoke](/v7/loaders) at `/deco/invoke/<key>`. +2. **Registers the manifest's sections**, if any. +3. **Records the app's state**, so loaders can read it per request. +4. **Registers the app's middleware**, if any. On TanStack the [Worker entry](/v7/tanstack) runs app middleware on every request automatically; the Next.js binding doesn't run it. + +## Installing apps with autoconfig + +The usual way to install apps is `autoconfigApps(blocks, registry)` from `@decocms/blocks-admin/apps` (also available at `@decocms/blocks-admin/apps/autoconfig`). You pass it the decofile and a list of registry entries. For every entry whose block key exists in the decofile, it loads the app module, calls `configure`, and registers the result. An app whose block isn't in the decofile is skipped. + +The apps that support autoconfig (VTEX, Shopify, Wake, Blog and Resend) each export a registry entry from their `./registry` subpath. There is no combined list: your site composes the array from the apps it uses. + +```ts title="src/setup/apps.ts" +import { autoconfigApps, type AppRegistry } from "@decocms/blocks-admin/apps"; +import { loadBlocks } from "@decocms/blocks/cms"; +import { VTEX_REGISTRY_ENTRY } from "@decocms/apps-vtex/registry"; +import * as vtexMod from "@decocms/apps-vtex/mod"; +import { RESEND_REGISTRY_ENTRY } from "@decocms/apps-resend/registry"; +import * as resendMod from "@decocms/apps-resend/mod"; + +const APP_REGISTRY: AppRegistry = [ + { ...VTEX_REGISTRY_ENTRY, module: async () => vtexMod }, + { ...RESEND_REGISTRY_ENTRY, module: async () => resendMod }, +]; + +await autoconfigApps(loadBlocks(), APP_REGISTRY); +``` + +Run this only on the server, after `createSiteSetup` has loaded the decofile, and keep it out of anything the browser bundle imports: the app modules carry server-only code and credential handling. (`autoconfigApps` also returns immediately if it runs in a browser.) On TanStack, a convenient place is a module that `src/worker-entry.ts` imports right after `./setup`, as the [VTEX page](/v7/vtex#wiring-the-worker) shows. + +<Callout type="warning"> + +**Pass app modules statically.** Each registry entry ships with a lazy `module: () => import("./mod")`. That works under `vite dev` but can fail to resolve in a production Worker bundle. When it does, autoconfig logs `[autoconfigApps] failed to configure app "<blockKey>"` and skips the app, so its loaders never register and the sections that use them render empty. Spread the entry and replace `module` with a static import, as above. + +</Callout> + +Autoconfig runs again whenever the decofile changes, for example after a publish from Studio, so an editor who changes an app's settings doesn't need a deploy. On TanStack it also runs once more on the first request: a Cloudflare Worker only sees its environment variables (including `DECO_CRYPTO_KEY`) inside the request handler, so encrypted credentials can only be decrypted then. The Worker entry does this for you. + +To check that every app your content uses has an entry, list the app namespaces the decofile references: + +```bash +grep -rhoE '"(vtex|shopify|wake|commerce|website|blog|resend|algolia|magento|salesforce)/[^"]+"' .deco/blocks/ | sort -u +``` + +## Configuring apps by hand + +Several apps also expose a direct configuration function, for sites that prefer to wire things explicitly or for apps that have no registry entry yet: + +| App | Direct configuration | +|---|---| +| VTEX | `configureVtex(config)`, or `initVtexFromBlocks(blocks)` | +| Shopify | `configureShopify(config)`, or `initShopifyFromBlocks(blocks)` | +| Wake | `configureWake(config)`, or `initWakeFromBlocks(blocks)` | +| Magento | `configureMagento(config)`, or `await initMagentoFromBlocks(blocks)` | +| Algolia | `configureAlgolia(config)`, or `await initAlgoliaFromBlocks(blocks)` | +| Blog | `configureBlog(config)` | +| Resend | `configureResend(config)` | +| Website | `configureWebsite(config)` | + +A common place for the `init*FromBlocks` helpers is the `initPlatform` option of `createSiteSetup`, which receives the decofile on the server: + +```ts title="src/setup.ts" +import { createSiteSetup } from "@decocms/blocks/setup"; +import { blocks } from "../.deco/blocks.gen"; +import { initVtexFromBlocks } from "@decocms/apps-vtex"; + +createSiteSetup({ + sections: import.meta.glob("./sections/**/*.tsx"), + blocks, + initPlatform: (blocks) => initVtexFromBlocks(blocks), +}); +``` + +The manual path configures the app's client, but it doesn't register its loaders and actions for you. Register them with `registerCommerceLoaders` from `@decocms/blocks/cms` (VTEX and Wake ship ready-made maps; see [Loaders and actions](/v7/loaders)). The helpers also don't call `resolveSecret`: `initVtexFromBlocks` and `initShopifyFromBlocks` only use credentials stored in the block as plain strings, and `initWakeFromBlocks` reads Wake's tokens from environment variables. Use autoconfig when credentials are encrypted. + +## Secrets + +Apps read credentials through `resolveSecret(value, envVarName?)`, from `@decocms/blocks/sdk/crypto`. It tries, in order: + +1. A non-empty plain string stored in the block. +2. An object with a `get()` method that returns a non-empty string. +3. An object with an `encrypted` field: hex AES-CBC ciphertext that Studio produces, decrypted with the key in the `DECO_CRYPTO_KEY` environment variable. +4. The environment variable named by `envVarName`, as a fallback. + +If nothing matches, it returns `null`, and most apps' `configure` then returns `null` too, which means the app isn't installed. Environment variables are looked up in `process.env`, then in a `.dev.vars` file in the working directory (Cloudflare's local-dev convention), then in the Worker's per-request environment. + +Each app chooses its fallback variable names. They're listed in the table below and on each app's page. + +## Reading app state in a loader + +On TanStack, the [Worker entry](/v7/tanstack) places every installed app's `state` on the request context before your code runs. Read it with `RequestContext.getAppState`, from `@decocms/blocks/sdk/requestContext`: + +```ts title="src/loaders/storeInfo.ts" +import { RequestContext } from "@decocms/blocks/sdk/requestContext"; +import type { VtexState } from "@decocms/apps-vtex"; + +export default async function storeInfo() { + const vtex = RequestContext.getAppState<VtexState>("vtex"); + return { account: vtex?.config.account ?? null }; +} +``` + +It returns `undefined` outside a request, or when the app isn't installed. The Next.js binding doesn't run app middleware, so there it also returns `undefined`; read the app's config from its client instead (for example `getVtexConfig()`). See [Request context](/v7/request-context). + +## Observability for commerce apps + +The commerce apps record upstream request timings and cache hits through two shared helpers in `@decocms/blocks`, but only if your site gives them the instrumented fetch. Call the app's setter once, at module scope in setup: + +```ts title="src/setup.ts" +import { setVtexFetch, createVtexFetch } from "@decocms/apps-vtex"; + +setVtexFetch(createVtexFetch()); +``` + +Without that call, VTEX, Shopify, Wake and Magento use a plain fetch with a timeout, and their traffic doesn't appear in your metrics. Salesforce is instrumented by default, and Algolia deliberately isn't, because its SDK owns its own transport. See [Observability](/v7/observability). + +## All apps + +| App | Package | Status | Autoconfig entry | Block key | Credentials | +|---|---|---|---|---|---| +| [Commerce](/v7/apps-commerce) | `@decocms/apps-commerce` | Shared types and helpers | — | — | — | +| [Website](/v7/apps-website) | `@decocms/apps-website` | Complete | No (write your own) | Your decofile's website block | — | +| [VTEX](/v7/vtex) | `@decocms/apps-vtex` | Complete | `VTEX_REGISTRY_ENTRY` | `deco-vtex` | `appKey`/`appToken`, or `VTEX_APP_KEY`/`VTEX_APP_TOKEN` | +| [Shopify](/v7/shopify) | `@decocms/apps-shopify` | Complete for the Storefront API | `SHOPIFY_REGISTRY_ENTRY` | `deco-shopify` | `storefrontAccessToken`, or `SHOPIFY_STOREFRONT_TOKEN` | +| [Wake](/v7/wake) | `@decocms/apps-wake` | Complete | `WAKE_REGISTRY_ENTRY` | `deco-wake` | `WAKE_TOKEN` only | +| [Magento](/v7/magento) | `@decocms/apps-magento` | Partial | No | `magento` | `apiConfig.apiKey` | +| [Salesforce Personalization](/v7/salesforce) | `@decocms/apps-salesforce` | Recommendations only | No | — (loader props) | — | +| [Algolia](/v7/algolia) | `@decocms/apps-algolia` | Experimental | No | `deco-algolia` | `adminApiKey`, `searchApiKey` | +| [Blog](/v7/blog) | `@decocms/apps-blog` | Complete | `BLOG_REGISTRY_ENTRY` | `deco-blog` | — | +| [Resend](/v7/resend) | `@decocms/apps-resend` | Complete | `RESEND_REGISTRY_ENTRY` | `deco-resend` | `apiKey`, or `RESEND_API_KEY` | + +Almost every site installs `@decocms/apps-commerce` and `@decocms/apps-website` next to its platform app. + +## Related + +- [Loaders and actions](/v7/loaders): how commerce loaders and invoke work. +- [Commerce types and utilities](/v7/apps-commerce): the shared vocabulary every commerce app returns. +- [Packages and exports](/v7/packages): every import path. diff --git a/docs/content/v7/architecture.mdx b/docs/content/v7/architecture.mdx new file mode 100644 index 00000000..ef672f30 --- /dev/null +++ b/docs/content/v7/architecture.mdx @@ -0,0 +1,89 @@ +--- +title: How v7 works +nav: How it works +group: Getting started +order: 2 +description: The request path, the authoring path and the package graph of a Deco Blocks v7 site. +--- + +# How v7 works + +A v7 site has two loops running through it. In the request loop, a visitor asks for a URL and the site turns stored JSON into a rendered page. In the authoring loop, a developer's TypeScript types become Studio forms, and what editors save becomes the JSON the request loop reads. This page walks through both, then shows how the packages split the work. + +## The request path + +When a request arrives, the framework binding (`@decocms/tanstack` or `@decocms/nextjs`) hands its path to the runtime in `@decocms/blocks`. The runtime holds the site's content in memory as the *decofile*: a flat map from block names to JSON. + +<Flow label="Request path"> + <FlowNode title="Find the page">The path is matched against every page block's `path` pattern, most specific first</FlowNode> + <FlowNode title="Resolve">Every `__resolveType` in the page's sections is followed: named blocks, matchers, commerce loaders</FlowNode> + <FlowNode title="Section loaders">Server functions attached to sections enrich their props</FlowNode> + <FlowNode title="Render">React renders the sections; deferred ones render a skeleton and load later</FlowNode> +</Flow> + +1. **Find the page.** A page block is a block with a `path` (a URL pattern such as `/:category/:slug/p`) and a list of `sections`. The runtime picks the most specific pattern that matches. See [Pages and routing](/v7/routing). +2. **Resolve.** Content is JSON, but some values in it stand for something else. A value with a `__resolveType` field names a section, another block, a matcher-guarded variant or a data loader. *Resolution* follows those names recursively until every value is plain data. A product shelf's `products` prop, for example, becomes the result of a VTEX search. See [Blocks and sections](/v7/model) and [Matchers and variants](/v7/variants). +3. **Section loaders.** After resolution, each section can have a server function that adds props (device, search params, its own data). See [Loaders and actions](/v7/loaders). +4. **Render.** The binding renders the sections as React. Sections an editor marked async in Studio render as a skeleton first and are fetched separately, so the first byte isn't held up by slow data. See [Deferred sections](/v7/rendering). + +On TanStack Start, all of this runs inside a Cloudflare Worker that also owns an edge cache, CMS redirects and the admin endpoints; see [The Worker request pipeline](/v7/request-pipeline). On Next.js, it runs inside Server Components. + +## The authoring path + +Editors never write JSON by hand. Studio builds a form for every section from its TypeScript `Props` type, and saves what editors enter as blocks. + +<Flow label="Authoring path"> + <FlowNode title="Your TypeScript">Sections with an exported `Props` type and JSDoc labels</FlowNode> + <FlowNode title={<><code>generate</code></>}>Writes `.deco/meta.gen.json` (the schema) and the section registry</FlowNode> + <FlowNode title="Studio">Reads the schema at `/live/_meta`, shows forms and live previews</FlowNode> + <FlowNode title="Decofile">Saved content: `.deco/blocks/*.json`, and the in-memory copy on the site</FlowNode> +</Flow> + +The `generate` command from `@decocms/blocks-cli` reads your sections, loaders and installed apps and writes generated files into `.deco/`. The most important one, `meta.gen.json`, is the JSON Schema Studio builds its forms from. In development the TanStack Vite plugin reruns the generators as you edit; on Next.js you run `generate` before `dev` and `build`. See [Schema generation](/v7/schema) and [Code generation](/v7/generate). + +Studio talks to your running site over a handful of HTTP endpoints, called the *admin protocol*: it reads the schema and the current content, renders previews of a section or page, calls loaders, and publishes. See [Deco Studio and the admin protocol](/v7/studio). + +## What happens when an editor publishes + +Publishing sends the new content to the site with `POST /.decofile`, either as the full decofile or as a delta of changed blocks. The instance that receives it swaps its in-memory decofile in one step, clears its loader cache and invalidates the schema ETag, so requests that start after the swap see the new content. + +What makes the change last depends on how the site is deployed: + +- **By default,** content ships with the code. The build bundles `.deco/blocks/` into the server, and a new instance starts from that bundled copy. A publish changes only the memory of the instance that received it, so content that has to survive a restart or reach every instance must be in `.deco/blocks/` in the deployed build. Studio commits each publish to your repository, so it becomes permanent with your next deploy. +- **With Fast Deploy** (TanStack on Workers, opt-in), content lives in Cloudflare KV keyed by deployment. A publish writes there, and the other Worker isolates running the same deployment pick it up on their next revision check (about every ten seconds) without a redeploy. See [Deploying and Fast Deploy](/v7/releases). + +## The packages and their graph + +The framework is five packages. The dependency graph only points one way, so the runtime never depends on a binding and the two bindings never depend on each other. + +```text +Package depends on +─────────────────────── ───────────────────────────────────────────── +@decocms/blocks nothing from Deco (the runtime) +@decocms/blocks-admin blocks +@decocms/blocks-cli blocks +@decocms/tanstack blocks, blocks-admin, blocks-cli +@decocms/nextjs blocks, blocks-admin +``` + +| Package | Role in the loops above | +|---|---| +| `@decocms/blocks` | Holds the decofile, finds pages, resolves blocks, runs section loaders and matchers. Knows nothing about TanStack, Next.js or Studio. | +| `@decocms/blocks-admin` | Implements the admin protocol endpoints and the admin half of setup (`createAdminSetup`). Also configures apps from their decofile blocks. | +| `@decocms/blocks-cli` | Runs at build time: `generate` and the migration commands. | +| `@decocms/tanstack` | Wires everything into TanStack Start and a Cloudflare Worker, including the edge cache and Fast Deploy. | +| `@decocms/nextjs` | Wires everything into the Next.js App Router. | + +The companion apps (`@decocms/apps-*`) build on `@decocms/blocks` and `@decocms/apps-commerce`. An app is configured from a block in the decofile and registers its loaders, actions and sections when the site starts. See [Apps](/v7/apps). + +Each package ships plain TypeScript source rather than a bundled build, so the runtime state (the decofile, the section registry) exists exactly once in your app. [How v7 is built](/v7/internals) explains why that matters. + +<Callout type="preview">**Next major.** The next version keeps the same content model but replaces sections and setup calls with plain typed functions and a smaller API. If you're curious how the two compare, see [how the next major works](/next/how-it-works).</Callout> + +## Next steps + +- [Blocks and sections](/v7/model): the content model in detail. +- [Content and the decofile](/v7/content): where content lives and how it reaches the runtime. +- [Pages and routing](/v7/routing): how a path finds a page. +- [Deco Studio and the admin protocol](/v7/studio): the endpoints Studio calls. +- [How v7 is built](/v7/internals): the package split and module state. diff --git a/docs/content/v7/blog.mdx b/docs/content/v7/blog.mdx new file mode 100644 index 00000000..71ea672c --- /dev/null +++ b/docs/content/v7/blog.mdx @@ -0,0 +1,183 @@ +--- +title: Blog +group: Apps +order: 34 +description: Write blog posts, categories and authors in Studio, and list, page, search and render them with SEO and structured data. +--- + +# Blog + +`@decocms/apps-blog` turns blog content written in Studio into loaders and sections for your site. Posts, categories and authors are ordinary blocks in the decofile, so they're versioned, previewed and published like the rest of your content. The app reads them straight from the decofile in memory, with no external service, and gives you loaders for post pages, listings, related posts and categories, SEO sections with structured data, and a set of sections for rich post bodies. + +```bash +bun add @decocms/apps-blog @decocms/apps-commerce @decocms/apps-website +``` + +## Key terms + +<Terms> + <Term name="Post">A block whose key starts with `collections/blog/posts` and whose value holds the post under a `post` field.</Term> + <Term name="Category">A block whose key starts with `collections/blog/categories`, holding the category under a `category` field.</Term> + <Term name="Live post">A post that's published, or scheduled with a go-live time that has passed. Only live posts appear in listings.</Term> + <Term name="Records adapter">Optional storage you provide for ratings, reviews and view counts. Without it those features return empty results.</Term> +</Terms> + +## The content model + +Each post is a block in the decofile: + +```json title=".deco/blocks/collections%2Fblog%2Fposts%2Fsummer-guide.json" +{ + "__resolveType": "blog/loaders/Blogpost.ts", + "post": { + "title": "The summer guide", + "excerpt": "Light layers for long days.", + "slug": "summer-guide", + "date": "2026-06-01", + "status": "published", + "categories": [{ "name": "Guides", "slug": "guides" }], + "content": "<p>…</p>" + } +} +``` + +The main `BlogPost` fields: + +| Field | What it holds | +|---|---| +| `title`, `excerpt`, `slug`, `date` | Required. `date` is a calendar date; `slug` is how the post is found and linked. | +| `status`, `scheduledDatetime` | Publication state (see below). | +| `content` | The body as rich text (HTML). | +| `sections` | An alternative body built from sections, which editors pick in Studio. | +| `image`, `alt`, `imageCarousel` | The cover image and an optional carousel. | +| `authors`, `categories` | Lists of `Author` (`name`, `email`, `avatar`, …) and `Category` (`name`, `slug`). | +| `dateModified`, `readTime`, `seo`, `extraProps` | Optional metadata, SEO overrides and free-form extra fields. | + +Ratings, reviews and view counts are added to posts at runtime when you provide a [records adapter](#ratings-reviews-and-views). + +### Publication and scheduling + +`status` is one of `draft`, `published`, `scheduled`, `archived`, `generating` or `awaiting_review`. A post is **live** when its status is `published` or missing (older posts have none), or when it's `scheduled` and `scheduledDatetime` has passed. A scheduled time without a time zone is read as UTC, and an unreadable one is never treated as live. Any other status, including ones added later, keeps the post out. + +Listings, related posts and category pages drop posts that aren't live, and posts without a `slug`, before they paginate. A single post's page still loads a post that isn't live, so editors can preview it, but marks it `noIndexing`. If you build your own listing, apply the same rule with `isLivePost(post)`, exported from the package root. + +## Configuring + +The app's settings live in a `deco-blog` block. Every field is optional: + +| Field | What it does | +|---|---| +| `pageSlug` | Your post route, such as `/blog/:category/:slug`. Used to preview posts in Studio on their real page. | +| `categorySlug` | Your category route, such as `/blog/:category`. | +| `canonicalBaseUrl` | The origin for canonical URLs and structured data, such as `https://www.example.com`. Defaults to the request's host. | +| `publisher` | The organization (`name`, `logo`, `url`) named in structured data. | +| `seo` | `titleTemplate` and `descriptionTemplate` (with `%s` for the page's value), plus other site SEO defaults. | + +Install it with the registry entry; `configure` always succeeds, even with an empty block: + +```ts title="src/setup/apps.ts" +import { autoconfigApps, type AppRegistry } from "@decocms/blocks-admin/apps"; +import { loadBlocks } from "@decocms/blocks/cms"; +import { BLOG_REGISTRY_ENTRY } from "@decocms/apps-blog/registry"; +import * as blogMod from "@decocms/apps-blog/mod"; + +const APP_REGISTRY: AppRegistry = [{ ...BLOG_REGISTRY_ENTRY, module: async () => blogMod }]; + +await autoconfigApps(loadBlocks(), APP_REGISTRY); +``` + +If you register loaders by hand instead, `createBlogLoaders()` returns them as a map for `registerCommerceLoaders`, and `configureBlog(config)` sets the same options: + +```ts title="src/setup/commerce-loaders.ts" +import { registerCommerceLoaders } from "@decocms/blocks/cms"; +import { configureBlog, createBlogLoaders } from "@decocms/apps-blog"; + +configureBlog({ pageSlug: "/blog/:category/:slug", canonicalBaseUrl: "https://www.example.com" }); +registerCommerceLoaders(createBlogLoaders()); +``` + +## Loaders + +Each key is registered with and without `.ts`. + +| Key | Props | Returns | +|---|---|---| +| `blog/loaders/BlogPostPage` | `slug` | A `BlogPostPage` (`post`, `seo`), or `null` | +| `blog/loaders/BlogpostListing` | `slug?` (a category), `count?`, `page?`, `sortBy?`, `query?` | A `BlogPostListingPage`: `posts`, `category`, `categories`, `pageInfo`, `seo` | +| `blog/loaders/BlogpostList` | `count?`, `page?`, `slug?`, `postSlugs?`, `sortBy?`, `query?` | `BlogPost[]` | +| `blog/loaders/BlogRelatedPosts` | `slug?` (one or more categories), `excludePostSlug?`, `count?`, `page?`, `sortBy?`, `query?` | `BlogPost[]` | +| `blog/loaders/GetCategories` | `slug?`, `count?`, `sortBy?` (`title_asc` or `title_desc`) | `Category[]` | +| `blog/loaders/BlogPostItem` | `slug` | One `BlogPost` | + +Lists default to 12 posts, page 1, newest first. When a prop is empty, the listing loaders read it from the page URL instead: `count`, `page`, `sortBy`, and `q` for a search term, which matches the title, excerpt and content. Props win over the URL. They return `null`, not an empty page, when nothing matches or the page is past the end, so handle `null` in your sections. + +A post page's content passes the slug from the route with `requestToParam`: + +```json title=".deco/blocks/pages-blog-post.json" +{ + "name": "Blog post", + "path": "/blog/:category/:slug", + "sections": [ + { + "__resolveType": "site/sections/Blog/BlogPost.tsx", + "page": { + "__resolveType": "blog/loaders/BlogPostPage.ts", + "slug": { "__resolveType": "website/functions/requestToParam.ts", "param": "slug" } + } + } + ] +} +``` + +<Callout type="warning">**Two sort names are inverted.** `title_asc` sorts Z to A and `view_desc` puts the least-viewed posts first; `title_desc` and `view_asc` are their opposites. The names are kept as they are so existing content keeps the order editors already chose. The date sorts behave as named.</Callout> + +## SEO sections + +Two sections render a page's SEO tags and structured data from a loader's result. Pass the loader's result as `jsonLD`, and optionally `title` and `description`: + +- **`SeoBlogPost`** (`@decocms/apps-blog/sections/Seo/SeoBlogPost`) for post pages. It emits a `BlogPosting` and a `BreadcrumbList`. +- **`SeoBlogPostListing`** (`@decocms/apps-blog/sections/Seo/SeoBlogPostListing`) for listings. It emits a `Blog` and a `BreadcrumbList`. + +Both apply the block's `titleTemplate` and `descriptionTemplate`, set the canonical URL from `canonicalBaseUrl` when configured, and mark posts that aren't live as `noIndexing`. They render through the website app's `Seo` component (see [Website app](/v7/apps-website#seo-sections)). + +## Post body sections + +For posts built from sections instead of one rich-text field, the app ships a set of body sections under `@decocms/apps-blog/sections/blocks/<Name>`: + +`Paragraph`, `Heading`, `List`, `Divider`, `Quote`, `Callout`, `Checklist`, `Steps`, `Stat`, `StatGroup`, `CardGroup`, `Comparison`, `Table`, `BlockImage`, `Video`, `Code` and `Cta`. + +They're styled with Tailwind classes, including theme tokens such as `text-accent`, so your Tailwind theme should define them. HTML they render is sanitized first. + +If your posts come from an external editor as a list of typed blocks, `blocksToSections(blocks, overrides?)` converts them into section references ordered by `position`. Pass `overrides` to map a block type to a section of your own. + +`Template` (`@decocms/apps-blog/sections/Template`) is the preview Studio shows for a post record. When `pageSlug` is set, it shows the post on its real page; otherwise it renders the post's fields and sections. + +## Ratings, reviews and views + +The app doesn't store ratings, reviews or view counts itself. To enable them, give it a **records adapter** once, at module scope in your setup, backed by whatever storage you choose. Every method is optional: + +```ts title="src/setup/blog.ts" +import { setBlogRecordsAdapter } from "@decocms/apps-blog"; +import { db } from "../db"; + +setBlogRecordsAdapter({ + listPostViews: () => db.postViews.all(), + incrementPostView: (id) => db.postViews.increment(id), +}); +``` + +| Method | Used for | +|---|---| +| `listRatings(slug)`, `upsertRating(input)` | Ratings and the `submitRating` action | +| `listReviews(options)`, `getReview(id)`, `createReview(input)`, `updateReview(id, patch)` | Reviews and the `submitReview` action | +| `listPostViews()`, `incrementPostView(id)` | View counts, the `view_*` sorts and the `submitView` action | + +Without an adapter, reads return empty lists, writes return `null`, `submitView` returns `{ count: 0 }`, and view sorts fall back to date order. Adapter errors are logged and treated the same way, so storage problems never break a page. + +The actions are reachable over [invoke](/v7/loaders) as `blog/actions/submitRating`, `blog/actions/submitReview` and `blog/actions/submitView`. To add ratings or reviews to loader results, call the extension functions in `@decocms/apps-blog/loaders/extensions/<BlogpostList|BlogpostListing|BlogpostPage>/<ratings|reviews>` on the loader's output; they make extra storage calls per post, so use them only where you show the data. + +## Related + +- [Website app](/v7/apps-website): the `Seo` component these sections use. +- [Pages and routing](/v7/routing): routes with parameters. +- [Content and the decofile](/v7/content): how blocks are stored. diff --git a/docs/content/v7/caching.mdx b/docs/content/v7/caching.mdx new file mode 100644 index 00000000..60b9b7ba --- /dev/null +++ b/docs/content/v7/caching.mdx @@ -0,0 +1,252 @@ +--- +title: Caching +group: Production +order: 37 +description: The cache layers of a v7 site, the cache profiles that configure them, and how to segment, debug and purge the edge cache. +--- + +# Caching + +A v7 storefront caches at several layers: whole pages at the edge, data from loaders in memory, upstream API calls from commerce apps, and route data in the browser. One set of named policies, [cache profiles](/v7/glossary#cache-profile), drives all of them, so a product page uses the same "product" timing everywhere. This page explains the layers, the profiles, how to keep personalized pages out of shared caches, and how to read and purge the edge cache. The edge cache is part of `@decocms/tanstack`'s Worker entry; the data caches work on both bindings. + +## The layers + +| Layer | What it caches | Where | +|---|---|---| +| **Edge cache** | Whole HTML responses, and the cacheable server-function responses that load deferred sections | The Worker entry (`createDecoWorkerEntry`), in the Cloudflare Cache API or your own [cache storage](#shared-cache-storage). TanStack only. | +| **Browser** | Responses, through `Cache-Control` | The visitor's browser | +| **Loader cache** | Results of loaders wrapped with `createCachedLoader`, and of cacheable [section loaders](/v7/glossary#section-loader) | Server memory, per isolate or instance | +| **Fetch cache** | Upstream `GET` calls made by commerce apps (VTEX, Magento) | Server memory, with in-flight de-duplication | +| **Client route cache** | TanStack Router loader data (`staleTime`, `gcTime`) | The browser, during client navigation | + +Every layer serves stale content while it refreshes in the background (**stale-while-revalidate**), and most also serve stale content when the upstream fails (**stale-if-error**). + +## Cache profiles + +A cache profile names a policy. There are seven: + +| Profile | Used for | Edge fresh / SWR / SIE | Browser fresh / SWR / SIE | Loader fresh / SIE | +|---|---|---|---|---| +| `static` | The home page and other rarely changing pages | 15 min / 2 h / 6 h | 2 min / 30 min / 2 h | 5 min / 30 min | +| `product` | Product detail pages | 5 min / 30 min / 2 h | 1 min / 10 min / 1 h | 30 s / 10 min | +| `listing` | Category and collection pages; the default | 2 min / 15 min / 1 h | 30 s / 5 min / 30 min | 1 min / 5 min | +| `search` | Search results | 1 min / 5 min / 30 min | 0 / 2 min / 10 min | 1 min / 3 min | +| `cart` | Not chosen by the built-in rules (cart paths resolve to `private`); available to your own `registerCachePattern` or `detectProfile` | not cached | not cached | not cached | +| `private` | Account, checkout, login and other personal pages | not cached | not cached | not cached | +| `none` | APIs and framework routes | not cached | not cached | not cached | + +"Fresh" is how long a cached copy is served as is, "SWR" how long after that it's served while refreshing, and "SIE" how long it can be served when the origin fails. + +### How a URL gets its profile + +The Worker picks a profile for each request. Unless you override it, `detectCacheProfile` from `@decocms/blocks/sdk/cacheHeaders` applies these rules in order: + +1. **Private paths → `private`.** Any path whose first segment (after an optional two-letter locale such as `/pt`) is one of: `cart`, `carrinho`, `checkout`, `account`, `myaccount`, `my-account`, `minha-conta`, `meus-pedidos`, `pedidos`, `orders`, `order-placed`, `login`, `logout`, `sair`, `cadastro`, `signup`, `register`, `profile`, `perfil`, `wishlist`, `favoritos`, `listadedesejos`, `lista-de-desejos`, `minha-lista`, `assinaturas`, `subscriptions`, `troca`, `trocas`, `devolucao`, `devolucoes`. Matching ignores case. +2. `/api/`, `/deco/` and `/_build` → `none`. +3. `/s`, `/s/…`, or any URL with a `q` query parameter → `search`. +4. A path ending in `/p` → `product`. +5. `/` → `static`. +6. Anything else → `listing`. + +Server-function requests made during client navigation inherit the profile of the page they load, so a product page's data is cached like the product page. + +### Change the rules + +All of these are module-level settings. Call them once, at module scope, in your setup module. + +```ts title="src/setup.ts" +import { + registerCachePattern, + registerPrivatePaths, + setCacheProfile, +} from "@decocms/blocks/sdk/cacheHeaders"; + +// Cache product pages at the edge for 10 minutes instead of 5. +setCacheProfile("product", { edge: { fresh: 600 } }); + +// Never cache these site-specific personal pages. +registerPrivatePaths(["/my-lists", "/returns"]); + +// Treat /collections/* as listing pages explicitly. +registerCachePattern({ + test: (pathname) => pathname.startsWith("/collections/"), + profile: "listing", +}); +``` + +- `setCacheProfile(name, overrides)` merges partial timings into a profile. Edge and browser times are in seconds; loader times in milliseconds. It refuses to make `cart`, `private` or `none` public, logging a warning and keeping the profile private, unless you first call `allowPublicPrivateProfile()` (also from `@decocms/blocks/sdk/cacheHeaders`). +- `registerPrivatePaths(prefixes)` adds private path prefixes. It can only make paths more restricted, which makes it the safe choice for personal pages. +- `registerCachePattern({ test, profile })` adds a rule evaluated before the built-in ones. A custom rule can never make a built-in private path public: if it would, the path stays `private`. + +On TanStack you can also decide per request with the Worker entry's `detectProfile(url)` option. Return a profile name, or `null` to fall through to the rules above. + +## Personalized pages: segments + +The edge cache stores one copy per **cache key**. If a page looks different for different visitors (a logged-in header, regional prices, a price table per sales channel), the key must say so, or one visitor's page is served to another. The Worker entry's `buildSegment` option describes the visitor's **segment**: + +```ts title="src/worker-entry.ts" +import "./setup"; +import handler, { createServerEntry } from "@tanstack/react-start/server-entry"; +import { createDecoWorkerEntry } from "@decocms/tanstack"; +import { extractVtexContext } from "@decocms/apps-vtex/middleware"; + +const serverEntry = createServerEntry({ fetch: handler.fetch }); + +export default createDecoWorkerEntry(serverEntry, { + buildSegment: (request) => { + const vtex = extractVtexContext(request); + const ua = request.headers.get("user-agent") ?? ""; + return { + device: /mobile|android|iphone/i.test(ua) ? "mobile" : "desktop", + loggedIn: vtex.isLoggedIn, + salesChannel: vtex.salesChannel, + regionId: vtex.regionId ?? undefined, + }; + }, +}); +``` + +The segment has this shape: + +```ts +type SegmentKey = { + device: "mobile" | "desktop" | "tablet"; + loggedIn?: boolean; + salesChannel?: string; + regionId?: string; + flags?: string[]; + [custom: string]: string | boolean | string[] | undefined; +}; +``` + +Rules: + +- **`loggedIn: true` always bypasses the edge cache.** Logged-in visitors always get a freshly rendered page. +- **Keep the segment as small as the content requires.** Every distinct value multiplies the number of cached copies. Include `regionId` only if prices or stock vary by region. +- **Without `buildSegment`**, the key varies by device only (set `deviceSpecificKeys: false` to drop that too), and the Worker logs a warning at boot: logged-in visitors would share anonymous pages, so wire it on any site with accounts. +- **Geography.** With the default `geoCacheKey: "auto"`, the Worker adds the visitor's region to the key only when some block in your content uses the location matcher (`website/matchers/location.ts`). Set `"country"`, `"region"`, `"city"` or `"off"` to choose explicitly. Don't add geography to `buildSegment` yourself. + +A/B tests that split traffic with the random matcher are handled for you: the visitor's assigned variants are part of the key (see [Matchers and variants](/v7/variants)). + +### Tracking parameters + +`utm_*`, `gclid`, `fbclid` and the other common tracking parameters are stripped before the key is built, so `/?utm_source=newsletter` reads the same entry as `/`. A request that carries tracking parameters can read a cached page but never writes one (the response carries `X-Cache-Store: skipped-tracking`). To add your own parameters to the list, call `registerTrackingParams` from `@decocms/blocks/sdk/urlUtils` in setup. To keep them in the key, set `stripTrackingParams: false`. + +### Cookies + +A response that sets a cookie is personal by definition, so the Worker doesn't cache a response with a `Set-Cookie` header, except for cookies in its **safe list**. Safe cookies are removed from the stored copy and kept on the live response. The default list is `vtex_is_session`, `vtex_is_anonymous`, `vtex_segment` and `_deco_bucket`; replace it with the `safeCookies` option. + +If your home page shows `X-Cache: BYPASS` with `X-Cache-Reason: private-set-cookie`, something sets a cookie on every response. Move it after the cache, into middleware or the browser. + +### Degraded pages + +When a [section loader](/v7/glossary#section-loader) fails, the section renders with its unloaded props and the page is marked **degraded** (`X-Deco-Degraded: true`). The Worker treats a degraded page like a server error: it doesn't cache it, and serves the last good copy if it has one. Sections whose failure shouldn't count (decorative shelves, recommendations) can be excluded with `registerNonCriticalSections` from `@decocms/blocks/cms`. + +## Reading the cache headers + +Every response the Worker handles carries headers that explain its cache decision: + +| Header | Values | +|---|---| +| `X-Cache` | `HIT` (fresh copy), `STALE-HIT` (stale copy while refreshing), `STALE-ERROR` (stale copy because the origin failed), `MISS` (rendered and stored), `BYPASS` (not cacheable) | +| `X-Cache-Reason` | Why it bypassed. See below. | +| `X-Cache-Profile` | The profile used. | +| `X-Cache-Segment` | A hash of the visitor's segment, when `buildSegment` is set. | +| `X-Cache-Version` | The deploy version in the key. | +| `X-Cache-Age` | Age of the cached copy, in seconds. | + +`X-Cache-Reason` values: + +| Value | Meaning | +|---|---| +| `logged-in` | The segment said `loggedIn: true`. | +| `private-set-cookie` | The response set a cookie outside the safe list. | +| `profile:<name>` | The profile isn't public (`cart`, `private`, `none`). | +| `non-cacheable:<name>` | A request outside the cacheable paths whose profile is `private`, `cart` or `none`. | +| `method:<METHOD>`, `bypass-path` | Not a `GET`, or a framework path such as `/deco/` or `/live/`. | +| `status:<code>` | The origin returned an error status. | +| `degraded` | A critical section loader failed. | +| `draft-preview` | The request carries a [draft preview](/v7/preview). | +| `matchers-override` | The request forces matcher results (used by Studio previews). | + +```bash +curl -sD- -o/dev/null https://www.example.com/ | grep -i '^x-cache' +``` + +## Purging + +Each deploy already gets a new cache namespace (see [Deploying and Fast Deploy](/v7/releases#deploying-a-tanstack-site-to-workers)). To drop specific pages without deploying, `POST /_cache/purge` with a bearer token: + +```bash +curl -X POST https://www.example.com/_cache/purge -H "Authorization: Bearer $PURGE_TOKEN" -H "Content-Type: application/json" -d '{"paths":["/","/summer-sale"]}' +``` + +The token is the value of the Worker's `PURGE_TOKEN` variable (rename it with `purgeTokenEnv`, or pass `purgeTokenEnv: false` to disable purging, which then answers 404). The body: + +| Field | Type | What it does | +|---|---|---| +| `paths` | `string[]` | Required. The paths to purge. | +| `countries` | `string[]` | Also purge these geographic variants. | +| `salesChannels` | `string[]` | Segment sales channels to purge. Default `["1"]`. | +| `regionIds` | `string[]` | Segment regions to purge. | + +The Worker deletes every device, bot and segment variant of each path and answers `{ "purged": [...], "total": n }`. A wrong token gets 401. + +`POST /_cache/purge-loaders` (same token) clears the in-memory loader cache, but only in the isolate that receives it. It's not a global purge; a deploy is. + +## Caching loaders + +Wrap a loader with `createCachedLoader` to cache its results in memory, keyed by its props, with stale-while-revalidate, stale-if-error and de-duplication of concurrent calls: + +```ts title="src/setup/commerce-loaders.ts" +import { createCachedLoader } from "@decocms/blocks/sdk/cachedLoader"; +import { productDetailsPage } from "./loaders/productDetailsPage"; + +export const cachedProductPage = createCachedLoader("site/loaders/productDetailsPage", productDetailsPage, "product"); +``` + +The third argument is a profile name (recommended) or explicit options: `policy` (`"stale-while-revalidate"`, `"no-cache"` or `"no-store"`), `maxAge` (ms, default 60 000), `staleWhileRevalidate` (ms, default 300 000), `staleIfError` (ms, default 0) and `keyFn` (default `JSON.stringify` of the props). + +- The cache is capped at 32 MB per isolate. Change it with `DECO_LOADER_CACHE_MAX_BYTES` or `setLoaderCacheMaxBytes(bytes)` in setup. +- It's disabled when `DECO_CACHE_DISABLE=true` and in development (`NODE_ENV=development`). +- Commerce apps ship pre-wrapped loader maps that already use the right profiles (`createVtexCommerceLoaders`, `createWakeCommerceLoaders`). + +Section loaders can be cached too, with `export const cache = "listing"` in the section file or `registerCacheableSections` (see [Section conventions](/v7/sections)). Layout sections such as the header and footer have their own shared cache (see [Layout sections](/v7/sections#layout-sections)). + +## Shared cache storage + +By default, the edge cache uses the Cloudflare Cache API and the data caches live only in memory. The Worker entry's `cacheStorage` option gives them a shared store instead, so loader and section results survive new isolates: + +```ts title="src/worker-entry.ts" +import { createKVCacheStorage, type CacheKVNamespace } from "@decocms/blocks/sdk/cacheStorage"; + +export default createDecoWorkerEntry(serverEntry, { + cacheStorage: (env) => createKVCacheStorage(env.CACHE as CacheKVNamespace), + buildSegment, +}); +``` + +Bind `CACHE` to a KV namespace (separate from `DECO_KV`). `@decocms/blocks/sdk/cacheStorage` also has `createWebCacheStorage(cache, origin)` and `createMemoryCacheStorage(maxBytes)`, and you can implement the `CacheStorage` interface (`get`, `set(key, value, expiresAt)`, `delete`) on any store. Returning `null` from the option opts a request out. + +- Entries always expire: their lifetime is the profile's fresh time plus the longer of its SWR and SIE windows. +- Keys include the site, the deployment and the content revision, so a deploy or a publish moves to new keys and old ones age out. There's no distributed invalidation. +- Logged-in, draft and preview requests never read or write shared storage. +- Only JSON-compatible values are shared; anything else stays in memory. + +## The CDN in front of the Worker + +The Worker's cache key has dimensions the CDN in front of it can't see (segment, A/B cohort, bot), so by default the Worker sends `CDN-Cache-Control: no-store` on everything. The `cdnCacheControl` option relaxes that safely for one case: the default, `"serverfn-segment"`, lets the CDN cache server-function responses (deferred sections, client navigation data) whose URL carries a segment marker the server issued and verified. HTML documents are never CDN-cached by this option. `"no-store"` turns it off; `"match-profile"` sends profile headers, and is honored only when the cache key is the bare URL (no `buildSegment`, `deviceSpecificKeys: false`, `geoCacheKey: "off"`). + +Caching **HTML** at the CDN, so the Worker isn't invoked at all, requires CDN rules that decline personalized requests before they reach the cache. Before considering it, check that: + +- the Worker already caches your pages (`X-Cache` shows `HIT`/`MISS`, not `BYPASS`); +- `buildSegment` is wired and logged-in visitors bypass; +- your content doesn't use matchers finer than the key (city or coordinate location rules are not safe); +- a logged-in account page is never served from the CDN, which you test explicitly. + +## Related + +- [TanStack Start on Cloudflare Workers](/v7/tanstack): every `createDecoWorkerEntry` option. +- [Deploying and Fast Deploy](/v7/releases): deploy versioning. +- [Observability](/v7/observability): cache metrics. +- [Troubleshooting](/v7/troubleshooting#pages-always-show-x-cache-bypass) diff --git a/docs/content/v7/cli.mdx b/docs/content/v7/cli.mdx new file mode 100644 index 00000000..7a1dfc2d --- /dev/null +++ b/docs/content/v7/cli.mdx @@ -0,0 +1,286 @@ +--- +title: CLI reference +group: CLI +order: 24 +description: Synopsis, flags, defaults and exit codes for every command in @decocms/blocks-cli and @decocms/eitri. +--- + +# CLI reference + +`@decocms/blocks-cli` ships the command-line tools around a Deco site: migration and upgrade codemods, Fast Deploy content sync, a content pull bot, and observability configuration checks. This page lists each one with its flags, defaults and exit codes. The most-used command, `generate`, has its own page: [Code generation](/v7/generate). + +## Running the commands + +The package installs as a development dependency. Its commands are TypeScript files run through `tsx`, so run them with `npx -p`, which works whether or not the package is installed locally: + +```bash +npx -p @decocms/blocks-cli deco-upgrade-6-to-7 --help +``` + +Two scripts have no command name; run those by file path with `tsx`, as shown in their sections. Every named command accepts `-h` or `--help`. + +Several commands are **dry runs by default**: they print what they would change and write nothing until you pass `--write`. + +| Command | What it does | Writes without a flag? | +|---|---|---| +| `deco-migrate` | Migrates a Fresh/Deno site to TanStack Start, in place | Yes (use `--dry-run`) | +| `deco-post-cleanup` | Audits a migrated site for leftover code | No (`--fix` applies safe fixes) | +| `deco-htmx-analyze` | Inventories htmx usage before the rewrite | No | +| `deco-reconcile` | Exports source-repo changes made since the migration, as patches | Only its output folder | +| `deco-upgrade-6-to-7` | Moves an `@decocms/start` 6.x site onto the current packages | No (`--write`) | +| `deco-sync-blocks-to-kv` | Syncs content to Cloudflare KV for Fast Deploy | No (`--write`) | +| `deco-migrate-blocks-to-kv` | Seeds KV with a deployment's content once | No (`--write`) | +| `deco-sync-blocks-bot` | Pulls production content into `.deco/blocks` | Yes (use `--dry-run`) | +| `deco-cf-observability` | Writes the observability block in `wrangler.jsonc` | No (`--write`) | +| `deco-audit-observability` | Checks the observability block in `wrangler.jsonc` | No | +| `deco-eitri` | Scaffolds and generates an Eitri app's `.deco` | Yes | + +## Migration and upgrade + +### deco-migrate + +Migrates a Fresh/Preact/Deno storefront to TanStack Start, React 19 and Cloudflare Workers. It rewrites the source directory **in place**, so run it on a copy or a fresh branch. The phases are: analyze, scaffold, transform, cleanup, report (writes `MIGRATION_REPORT.md`), verify, bootstrap (installs dependencies and generates), compile, and a final cleanup audit. [Migrating from Fresh and Deno](/v7/migrate-from-fresh) explains the whole procedure. + +```bash +npx -p @decocms/blocks-cli deco-migrate --source ./my-store --dry-run --verbose +``` + +| Flag | Default | What it does | +|---|---|---| +| `--source <dir>` | `.` | Site to migrate. | +| `--dry-run` | off | Shows the changes without writing. | +| `--verbose` | off | Logs every file. | +| `--strict` | off | Exits 2 when the typecheck, build or cleanup audit reports errors. | +| `--with-build` | off | Also runs `vite build` in the compile phase. | +| `--no-compile` | off | Skips the compile phase. | +| `--no-cleanup-audit` | off | Skips the final cleanup audit. | + +An optional `.deco-migrate.config.json` at the source root adjusts which sections get section conventions: `{ "sectionConventions": { "extend": { "sync": [], "eagerSync": [], "listingCache": [], "staticCache": [] } } }`, or `"replace"` instead of `"extend"`. + +Exit codes: `0` success; `1` unexpected error; `2` unsupported source layout, a failed verification, or (with `--strict`) compile or audit errors. + +### deco-post-cleanup + +Audits a migrated site for dead code and boilerplate the framework now provides. Read-only unless you pass `--fix`. + +```bash +npx -p @decocms/blocks-cli deco-post-cleanup --source ./my-store --json +``` + +| Flag | Default | What it does | +|---|---|---| +| `--source <dir>` | `.` | Site to audit. | +| `--fix` | off | Applies the mechanical fixes for the rules marked safe. Other rules stay report-only. | +| `--json` | off | Prints the findings as JSON. | +| `--strict` | off | Exits 2 if there are warning-level findings. | + +### deco-htmx-analyze + +Read-only inventory of `hx-*` attributes in a Fresh site, so you can size the rewrite to React before migrating. v7 has no htmx runtime. + +```bash +npx -p @decocms/blocks-cli deco-htmx-analyze --source ./my-store --top 10 +``` + +| Flag | Default | What it does | +|---|---|---| +| `--source <dir>` | current directory | Site to analyze. | +| `--json` | off | Prints the inventory as JSON. | +| `--top <n>` | `20` | How many files to list, by occurrence count. | + +### deco-reconcile + +A migration takes time, and the original site keeps changing meanwhile. `deco-reconcile` collects everything committed to the original repository since the migration cut and writes one patch per file, with suggested target paths, for you to port into the migrated repository. It writes nothing into the target tree except its output folder. + +```bash +npx -p @decocms/blocks-cli deco-reconcile --source ../my-store-fresh --target ../my-store --snapshot <cut-sha> +``` + +| Flag | Default | What it does | +|---|---|---| +| `--source <dir>` | required | Checkout of the original Fresh/Deno repository. | +| `--target <dir>` | required | Checkout of the migrated repository. | +| `--snapshot <sha>` | required | Last source commit already reconciled (the migration cut). | +| `--target-snapshot <sha>` | the commit that added `MIGRATION_REPORT.md`, else `HEAD` | The migration commit in the target. Later target commits are treated as hand fixes and reported as possible collisions. | +| `--out <dir>` | `<target>/.reconcile/<source-head>` | Output folder: `manifest.json` (with per-file `done` flags for resuming), `INDEX.md` and `patches/`. | +| `--verbose` | off | Logs every file. | + +Exit codes: `0` success; `2` bad arguments or a git failure. Feed the source head it reports back as `--snapshot` next time. + +### deco-upgrade-6-to-7 + +Upgrades a site that is already on TanStack Start from `@decocms/start` 6.x and `@decocms/apps` 5.x to the current split packages. It rewrites import paths (including renamed exports) and updates `package.json`. [Upgrading from @decocms/start 6.x](/v7/upgrade-from-start) is the full procedure. + +```bash +npx -p @decocms/blocks-cli deco-upgrade-6-to-7 --write +``` + +| Flag | Default | What it does | +|---|---|---| +| `--src-dir <dir>` | `src` | Source folder to rewrite. | +| `--write` | off | Applies the changes. Without it, lists the files that would change. | + +Exit codes: `0` success or dry run; `2` the source folder doesn't exist. + +## Fast Deploy content + +Both commands write to Cloudflare KV over the REST API. With `--write` they need credentials in the environment: `CF_ACCOUNT_ID` (or `CLOUDFLARE_ACCOUNT_ID`), `CF_API_TOKEN` (or `CLOUDFLARE_API_TOKEN`, with permission to edit Workers KV) and `CF_KV_NAMESPACE_ID` (or the `DECO_KV` namespace id read from `wrangler.jsonc`). Cloudflare Workers Builds provides the account and token variables itself. [Deploying and Fast Deploy](/v7/releases) shows where they go in a pipeline. + +### deco-sync-blocks-to-kv + +Writes the content snapshot for one deployment, and can mark that deployment live. + +```bash +npx -p @decocms/blocks-cli deco-sync-blocks-to-kv --write --all --deployment-id "$WORKERS_CI_COMMIT_SHA" +``` + +```bash +npx -p @decocms/blocks-cli deco-sync-blocks-to-kv --set-live --deployment-id "$WORKERS_CI_COMMIT_SHA" +``` + +| Flag | Default | What it does | +|---|---|---| +| `--deployment-id <id>` | none (required to write) | Deployment id, normally the commit SHA, that the content is stored under. | +| `--set-live` | off | Only records `<id>` as the live deployment. Reads no content; implies a write. | +| `--all` | off | Syncs even when git shows no content change. | +| `--since <ref>` | `HEAD~1` | Base ref for the content-change check. | +| `--blocks-dir <dir>` | `.deco/blocks` | Content folder. | +| `--retain <n>` | `10` | Deployment snapshots to keep; older ones are removed. | +| `--purge-url <origin>` | none | After syncing, calls `POST /_cache/purge` on this origin. | +| `--purge-token <token>` | `PURGE_TOKEN` env var | Bearer token for that purge. | +| `--write` | off | Performs the writes. | + +Use this command rather than writing KV yourself: the revision it stores must match the one the Worker computes byte for byte. + +Exit codes: `0` success, nothing to do, or dry run; `2` bad folder, missing credentials or arguments, or a failed verification. + +### deco-migrate-blocks-to-kv + +One-time seed of a deployment's content into KV, for turning Fast Deploy on for an existing site. + +```bash +npx -p @decocms/blocks-cli deco-migrate-blocks-to-kv --deployment-id <commit-sha> --write +``` + +| Flag | Default | What it does | +|---|---|---| +| `--deployment-id <id>` | none (required to write) | Deployment id to store the content under. | +| `--blocks-dir <dir>` | `.deco/blocks` | Content folder. | +| `--write` | off | Performs the writes. | + +Exit codes match `deco-sync-blocks-to-kv`. + +## Content sync + +### deco-sync-blocks-bot + +Pulls the production content (`GET <origin>/.decofile`) into `.deco/blocks/`, one file per block, so a scheduled job can open a pull request with what editors published. It protects some blocks by default: blocks matching `--deny`, and blocks holding encrypted secret references, are never overwritten or deleted. + +```bash +npx -p @decocms/blocks-cli deco-sync-blocks-bot --origin https://www.example.com --dry-run +``` + +| Flag | Default | What it does | +|---|---|---| +| `--origin <url>` | none | Site origin; the bot fetches `<origin>/.decofile`. | +| `--url <url>` | none | Full content URL, instead of `--origin`. | +| `--out <dir>` | `.deco/blocks` | Content folder to write. | +| `--deny <globs>` | `Site,site` | Block-name globs never overwritten. | +| `--allow-secret-blocks` | off | Also overwrites blocks that hold encrypted secrets. | +| `--prune` | off | Deletes local blocks missing upstream. | +| `--dry-run` | off | Reports without writing. | +| `--fail-on-plaintext-secret` | off | Exits 1 if a block it would write holds what looks like a raw credential. | +| `--max-bytes <n>` | `67108864` (64 MiB) | Largest response accepted. | +| `--timeout-ms <n>` | `60000` | Fetch timeout. | +| `--json` | off | Prints the report as JSON. | +| `--github` | off | Prints GitHub Actions annotations. | + +Exit codes: `0` done, with or without changes; `1` the plaintext-secret check failed; `2` usage, network or payload error (nothing written). + +## Observability configuration + +### deco-cf-observability + +Writes Cloudflare's observability block (logs and traces) into `wrangler.jsonc`. Without `--write` it prints a diff. + +```bash +npx -p @decocms/blocks-cli deco-cf-observability --write --traces-rate 0.01 +``` + +| Flag | Default | What it does | +|---|---|---| +| `--source <dir>` | `.` | Folder with `wrangler.jsonc`. | +| `--write` | off | Applies the change. | +| `--traces-rate <r>` | `0.1` | Head sampling rate for traces. | +| `--logs-rate <r>` | `1.0` | Head sampling rate for logs. | +| `--destination-logs <name>` | none | Also forwards logs to this Cloudflare destination. | +| `--destination-traces <name>` | none | Also forwards traces to this Cloudflare destination. | +| `--persist`, `--no-persist` | `--persist` | Keeps logs and traces in the Cloudflare dashboard. Drop it only when forwarding to a destination. | + +Pass `--traces-rate 0.01`. The default of `0.1` is above the rate `deco-audit-observability` accepts, so the audit flags a file written with it. + +Exit codes: `0` nothing to change (or written); `1` a change is needed and `--write` wasn't passed; `2` the file is missing or invalid. + +### deco-audit-observability + +Checks the observability block in `wrangler.jsonc`: that observability, logs and traces are on, that the trace sampling rate is at most `0.01`, that logs aren't under-sampled, and that data is kept somewhere. It also checks the rest of the telemetry wiring the migration scaffold sets up, such as the `version_metadata` binding, the tail consumer and the telemetry endpoint variables, so a `wrangler.jsonc` written by hand can get findings for those too. + +```bash +npx -p @decocms/blocks-cli deco-audit-observability --mode block --github +``` + +| Flag | Default | What it does | +|---|---|---| +| `--source <dir>` | `.` | Folder with `wrangler.jsonc`. | +| `--json` | off | Prints the findings as JSON. | +| `--mode <warn\|block>` | `warn` | `warn` always exits 0. `block` exits 1 when there's an error-level finding. | +| `--github` | off | Prints GitHub Actions annotations. | + +Exit codes: `0` no findings, or any findings in `warn` mode; `1` error findings in `block` mode; `2` the file is missing or can't be parsed. Use `--mode block` to make it a CI gate. + +## Eitri + +### deco-eitri + +Ships with `@decocms/eitri`, not `@decocms/blocks-cli`. See [Eitri apps](/v7/eitri). + +```bash +npx deco-eitri init +``` + +```bash +npx deco-eitri generate --root apps/mobile +``` + +- `deco-eitri init [--root <dir>]` creates `tsconfig.json` and `src/eitri-env.d.ts`, never overwriting existing files. +- `deco-eitri generate [flags]` runs [`generate`](/v7/generate) with `--platform eitri`, forwarding every flag. + +Exit codes: `0` success; `1` no command, an unknown command, or a generation failure. + +## Scripts without a command name + +These ship in `@decocms/blocks-cli` and run by path. + +### audit-secrets.ts + +Scans source for credentials written into loaders and actions, and for the secrets SDK (`@decocms/blocks/sdk/crypto`) imported from a `"use client"` file. + +```bash +npx tsx node_modules/@decocms/blocks-cli/scripts/audit-secrets.ts --source ./src --mode block +``` + +Flags: `--source <dir>` (default `.`), `--json`, `--mode warn|block` (default `warn`), `--github`. Exit codes: `0` no findings or `warn` mode; `1` error findings in `block` mode; `2` the source folder is missing. + +### tailwind-lint.ts + +Finds class issues when moving from Tailwind v3 to v4 and DaisyUI v4 to v5: renamed classes, arbitrary values that have a built-in equivalent (`px-[16px]` becomes `px-4`), and responsive variants written in an order that v4's cascade gets wrong. It reports by default; `--fix` rewrites the files. It scans `src/` unless you pass a folder. + +```bash +npx tsx node_modules/@decocms/blocks-cli/scripts/tailwind-lint.ts src/sections --fix +``` + +## Related + +- [Code generation](/v7/generate) covers `generate`. +- [Migrating from Fresh and Deno](/v7/migrate-from-fresh) and [Upgrading from @decocms/start 6.x](/v7/upgrade-from-start) walk through the migration commands in order. +- [Observability](/v7/observability) explains what the observability settings control. diff --git a/docs/content/v7/components.mdx b/docs/content/v7/components.mdx new file mode 100644 index 00000000..6ecf676d --- /dev/null +++ b/docs/content/v7/components.mdx @@ -0,0 +1,259 @@ +--- +title: Images, scripts and UI helpers +nav: Images & UI helpers +group: Rendering +order: 17 +description: The React components and helpers sections use most, such as Image and Picture, LazySection, inline scripts, device detection, cookies, analytics events, load-more pagination and JSON-LD. +--- + +# Images, scripts and UI helpers + +`@decocms/blocks` ships the building blocks most storefront sections need: an `Image` component that resizes through an image CDN, a lazy wrapper, safe inline scripts, device detection, cookie helpers, analytics events, load-more pagination and JSON-LD components. The components come from `@decocms/blocks/hooks`; the helpers each have their own `@decocms/blocks/sdk/*` path. + +## Images + +`Image` renders an `<img>` whose `src` and `srcset` point at resized versions of the original, so the browser downloads an image the size it displays: + +```tsx title="src/sections/Hero.tsx" +import { Image } from "@decocms/blocks/hooks"; +import type { ImageWidget } from "@decocms/blocks/types/widgets"; + +export interface Props { + title: string; + image: ImageWidget; +} + +export default function Hero({ title, image }: Props) { + return ( + <section> + <Image src={image} alt={title} width={1280} height={480} preload /> + <h1>{title}</h1> + </section> + ); +} +``` + +| Prop | Type | Default | What it does | +|---|---|---|---| +| `src` | `string` | required | The original image URL, usually from an `ImageWidget` prop. | +| `width` | `number` | required | Display width in CSS pixels. The `srcset` offers 1× and 2× this width. | +| `height` | `number` | none | Display height. Set it: it reserves the space (no layout shift), and without it the component logs a warning. | +| `fit` | `"cover" \| "contain" \| "fill"` | `"cover"` | How the image is cropped to `width` × `height`. | +| `preload` | `boolean` | `false` | For the page's main image (its Largest Contentful Paint). Adds a `<link rel="preload">`, and loads the image eagerly with high priority. Use it once per page. | +| `media` | `string` | none | Media query for the preload link, when the image only shows on some screens. | +| `sizes` | `string` | `"(max-width: 768px) 100vw, 50vw"` | The standard `sizes` attribute. Set it when the image isn't half the viewport wide on desktop. | + +Every other `<img>` attribute passes through. Without `preload`, images load lazily (`loading="lazy"`) and decode asynchronously. + +How the URL is rewritten depends on where the image lives: + +- **Images uploaded to Deco** go through the image CDN, `assets.decocms.com` by default, which resizes and converts them at the edge. +- **VTEX and Shopify images** are resized with the platform's own URL format, without the CDN. +- **`data:` URIs** are left alone. + +Two settings apply site-wide. Call them once, at module scope, in setup: + +```ts title="src/setup.ts (excerpt)" +import { registerImageCdnDomain, registerImageQuality } from "@decocms/blocks/hooks"; + +registerImageQuality("high"); +registerImageCdnDomain("assets.decocms.com"); +``` + +- `registerImageQuality` accepts `"low"`, `"medium"` or `"high"`. Unset, the CDN uses its own default, the lowest of the three. Other values, such as `"80"`, are silently treated as the default. The setting doesn't affect VTEX or Shopify images. +- `registerImageCdnDomain` changes the CDN host. You rarely need it; the default works for every site. + +`getOptimizedMediaUrl({ originalSrc, width, height, fit })` and `getSrcSet(src, width, height, fit)` build the same URLs, for a CSS background or an `og:image`. + +### Different images per screen + +`Picture` and `Source` serve a different image by media query, for example a tall banner on phones and a wide one on desktop: + +```tsx title="src/sections/Banner.tsx" +import { Image, Picture, Source } from "@decocms/blocks/hooks"; +import type { ImageWidget } from "@decocms/blocks/types/widgets"; + +export interface Props { + mobile: ImageWidget; + desktop: ImageWidget; + alt: string; +} + +export default function Banner({ mobile, desktop, alt }: Props) { + return ( + <Picture preload> + <Source media="(max-width: 767px)" src={mobile} width={430} height={590} /> + <Source media="(min-width: 768px)" src={desktop} width={1440} height={480} /> + <Image src={desktop} alt={alt} width={1440} height={480} /> + </Picture> + ); +} +``` + +With `preload` on `Picture`, each `Source` adds a preload link for its own media query. + +## Lazy rendering in the browser + +`LazySection` delays rendering part of a component until it scrolls near the viewport: + +```tsx +import { LazySection } from "@decocms/blocks/hooks"; + +<LazySection fallback={<div style={{ height: 400 }} />} minHeight={400}> + <ReviewsCarousel reviews={reviews} /> +</LazySection> +``` + +It differs from a [deferred section](/v7/rendering) in what it saves. A deferred section's data isn't fetched or sent with the page at all. `LazySection` only postpones rendering: its children's props are already on the page. Use it to keep heavy client components (carousels, maps, embeds) from rendering during hydration. + +| Prop | Default | What it does | +|---|---|---| +| `fallback` | nothing | Rendered until the content is visible. Give it a fixed height. | +| `rootMargin` | `"200px"` | How far ahead of the viewport to start rendering. | +| `minHeight` | none | Minimum height of the wrapper, to avoid layout shift. | +| `eager` | `false` | Render immediately, for content above the fold. | +| `className` | none | Class for the wrapper `<div>`. | + +## Inline scripts + +For small bits of JavaScript that must run before or without React (a scroll handler, a one-line initialization), use `inlineScript` from `@decocms/blocks/sdk/useScript`. It takes the script as a string and returns the props for a `<script>` element: + +```tsx title="src/sections/BackToTop.tsx" +import { inlineScript } from "@decocms/blocks/sdk/useScript"; + +const SCRIPT = (id: string) => + `document.getElementById("${id}").addEventListener("click", () => window.scrollTo({ top: 0 }))`; + +export default function BackToTop() { + return ( + <> + <button id="back-to-top" type="button">Back to top</button> + <script {...inlineScript(SCRIPT("back-to-top"))} /> + </> + ); +} +``` + +`useScript(fn, ...args)`, from the same path, is deprecated. It turns a function into a string with `fn.toString()`, and the server and browser builds can compile the same function differently, so the HTML from the server doesn't match what the browser renders and React reports a hydration error. Write the script as a string constant instead, as above. + +## Device detection + +Sections often render differently on phones. There are three ways to know the device, depending on where the code runs: + +- **In a component:** `useDevice()` from `@decocms/blocks/sdk/useDevice` returns `"mobile"`, `"tablet"` or `"desktop"`. On TanStack Start, `DecoPageRenderer` provides the device the server detected, so the server render and the browser agree. +- **In a section loader:** compose `withDevice()` or `withMobile()`, which add `device` or `isMobile` to the props. See [Loaders and actions](/v7/loaders). +- **In other server code:** `detectDevice(userAgent)` from `@decocms/blocks/sdk/detectDevice`. + +All three read the user agent, never the screen width, so the server and the browser reach the same answer. For layout differences that are purely visual, prefer CSS media queries: they need no detection at all, and cached pages stay the same for every device. + +## Class names + +`cn(...)` from `@decocms/blocks/sdk/cn` combines class names and resolves conflicting Tailwind utilities, so `cn("p-2", isLarge && "p-4")` gives `"p-4"` when `isLarge` is true. It accepts strings, arrays, objects and falsy values. `clx(...)`, from `@decocms/blocks/sdk/clx` or the same `cn` path, only joins the truthy strings, without merging. + +## Cookies + +`@decocms/blocks/sdk/cookie` has helpers for both sides: + +| Function | Where | What it does | +|---|---|---| +| `getCookie(name)` | Browser | Reads a cookie from `document.cookie`. Returns `""` if absent. | +| `setCookie(name, value, days)` | Browser | Sets a cookie on `/` that expires in `days`. | +| `deleteCookie(name)` | Browser | Deletes a cookie on `/`. | +| `getCookies(headers)` | Server | Parses a request's `Cookie` header into an object. | +| `getServerSideCookie(request, name)` | Server | Reads one cookie from a request. | +| `setResponseCookie(headers, cookie)` | Server | Appends a `Set-Cookie` header built from `{ name, value, maxAge?, expires?, path?, domain?, secure?, httpOnly?, sameSite? }`. | +| `deleteResponseCookie(headers, name, { path?, domain? })` | Server | Appends a `Set-Cookie` that clears the cookie. Match the original `path` and `domain`. | +| `decodeCookie(value)` | Either | Parses a URL-encoded JSON cookie value, or returns `null`. | + +To set a cookie from a loader or action, append to `RequestContext.responseHeaders`; see [Request context](/v7/request-context). On TanStack Start, a response that sets a cookie isn't cached at the edge unless the cookie is on the safe list (see [Caching](/v7/caching#cookies)). + +## Analytics events + +`useSendEvent` from `@decocms/blocks/sdk/analytics` returns two `data-` attributes that describe an analytics event and when to send it: + +```tsx title="src/components/ProductCard.tsx" +import { useSendEvent } from "@decocms/blocks/sdk/analytics"; +import type { Product } from "@decocms/apps-commerce/types"; + +export default function ProductCard({ product }: { product: Product }) { + const viewEvent = useSendEvent({ + on: "view", + event: { name: "view_item", params: { item_id: product.productID } }, + }); + return <article {...viewEvent}>{product.name}</article>; +} +``` + +Both bindings' root layouts include a small script that watches for these attributes. It sends `"view"` events when at least half of the element becomes visible (once per element), and `"click"` events on click. Each event is pushed to `window.dataLayer` (for Google Tag Manager) and to `window.DECO.events`. The script waits while the page is being prerendered by [speculation rules](/v7/speculation-rules), so prerendered pages that are never opened don't send events. `"change"` is accepted by the type but the script doesn't act on it. + +`gtmScript(containerId)`, from the same path, returns the Google Tag Manager snippet as a string with the same prerender guard. Render it with `inlineScript`. The commerce event types and mappers (`view_item`, `add_to_cart` and the rest) are in [Commerce types and utilities](/v7/apps-commerce). + +### Deco's analytics collector + +`Stats` from `@decocms/blocks/hooks` loads Deco's first-party analytics collector. It renders nothing unless the environment variable `DECO_ANALYTICS_ENABLED` is exactly `true`. On TanStack Start, `DecoRootLayout` already renders it, so turning analytics on is only the environment variable. On Next.js, render `<Stats />` in your root layout. `DECO_ANALYTICS_ORIGIN` and `DECO_ANALYTICS_SITE_KEY` are in the [configuration reference](/v7/configuration). + +## Load more + +`useLoadMore` from `@decocms/blocks/hooks` implements a "load more" button for listing pages. It keeps the pages loaded so far and fetches the next one by calling a loader through [invoke](/v7/loaders), with the next page's URL so the loader keeps the current filters and sorting: + +```tsx title="src/components/ProductGallery.tsx" +"use client"; + +import { useLoadMore } from "@decocms/blocks/hooks"; +import type { ProductListingPage } from "@decocms/apps-commerce/types"; + +export default function ProductGallery({ page, url }: { page: ProductListingPage; url: string }) { + const { pages, loadMore, loading, hasMore } = useLoadMore( + page, + "vtex/loaders/intelligentSearch/productListingPage.ts", + url, + ); + const products = pages.flatMap((p) => p.products ?? []); + + return ( + <> + <ul>{products.map((p) => <li key={p.productID}>{p.name}</li>)}</ul> + {hasMore && ( + <button type="button" onClick={loadMore} disabled={loading}> + {loading ? "Loading…" : "Load more"} + </button> + )} + </> + ); +} +``` + +- The second argument is the loader's key. The loader must be callable through `/deco/invoke`; commerce loaders are once you pass them to `setInvokeLoaders` (see [Loaders and actions](/v7/loaders)). +- `hasMore` comes from the last page's `pageInfo.nextPage` (or a top-level `nextPage`). +- The third argument resets the list when it changes. Pass the current URL, so changing a filter starts over from the new first page. +- It also returns `error`, set when a fetch fails. + +## JSON-LD + +`ProductJsonLd`, `PLPJsonLd` and `BreadcrumbJsonLd` from `@decocms/blocks/hooks` render schema.org structured data as a `<script type="application/ld+json">`, from the commerce types: + +```tsx +import { BreadcrumbJsonLd, ProductJsonLd } from "@decocms/blocks/hooks"; + +<ProductJsonLd product={page.product} url={url} /> +<BreadcrumbJsonLd breadcrumb={page.breadcrumbList} /> +``` + +`PLPJsonLd` takes `{ page, url }` for a product listing page. `seoMetaTags(props)` returns meta tag objects for a custom `<head>`. For SEO across a whole page, see [SEO](/v7/seo). + +## Hydration tips + +The server renders each page to HTML, and React then *hydrates* it in the browser, expecting to produce the same markup. When the two differ, React reports a hydration error and may re-render the whole page. The usual causes, and the fix for each: + +- **Values that differ between server and browser**, such as `Date.now()`, `Math.random()`, `window` or `localStorage` in the render. Read them in an effect, or render that part only in the browser. On TanStack Start, wrap it in `ClientOnly` from `@tanstack/react-router`, or branch on `useHydrated()` from the same package. A whole section can be marked `export const clientOnly = true` on either binding (see [Section conventions](/v7/sections)). +- **Scripts built from functions.** Use `inlineScript` with a string, not `useScript`. +- **Device checks on screen width.** Use `useDevice()`, which reads the user agent. +- **Generated ids.** Use React's `useId`. `useId` from `@decocms/blocks/sdk/useId` is the same without colons, for use in CSS selectors. + +`suppressHydrationWarning` hides the error without fixing it. The root layouts already set it on `<html>` and `<body>`, where browser extensions add attributes; don't add it elsewhere. + +## Next steps + +- [SEO](/v7/seo): page titles, metadata and structured data. +- [Section conventions](/v7/sections): `clientOnly`, `sync` and skeletons. +- [Commerce types and utilities](/v7/apps-commerce): the product types these components use. diff --git a/docs/content/v7/configuration.mdx b/docs/content/v7/configuration.mdx new file mode 100644 index 00000000..5b06bdb4 --- /dev/null +++ b/docs/content/v7/configuration.mdx @@ -0,0 +1,245 @@ +--- +title: Configuration reference +nav: Configuration +group: Reference +order: 45 +description: Every environment variable, binding and setup option a v7 site reads, in one place. +--- + +# Configuration reference + +This page collects every environment variable and Cloudflare binding v7 reads, and the options of the setup functions, route configs and Worker entry. Each table links back to the page that explains the feature. On Cloudflare Workers, set variables under `vars` in `wrangler.jsonc` (or as secrets for sensitive values); on Next.js and in Node tooling, set them in the process environment. + +## Environment variables and bindings + +### Runtime + +| Name | Read by | Default | What it does | +|---|---|---|---| +| `NODE_ENV` | all packages | | `development` turns on dev behaviour: no loader cache, no auth on `POST /.decofile`, stack traces in invoke errors, observability off. | +| `DECO_PREVIEW` | `@decocms/blocks` | | `true` makes `isDevMode()` return true outside development. | +| `DECO_SITE_NAME` | `@decocms/tanstack`, observability, Vite plugin | | The site's name: infers its Deco-hosted preview hosts, names the service in telemetry, and is passed to `generate --site` in development. | +| `DECO_SITE` | `@decocms/blocks/middleware` | `storefront` | The site name in `buildDecoState`. | +| `DECO_CRYPTO_KEY` | `@decocms/blocks/sdk/crypto` | | Key that decrypts secrets stored encrypted in the decofile (app credentials). A secret. See [Apps](/v7/apps). | + +### Studio and the admin protocol + +| Name | Read by | Default | What it does | +|---|---|---|---| +| `DECO_RELEASE_RELOAD_TOKEN` | `@decocms/blocks-admin` | | Required for `POST /.decofile` outside development: the `Authorization` header must equal it exactly. Without it, publishes get 401. See [Deco Studio and the admin protocol](/v7/studio). | + +### Draft preview + +| Name | Read by | Default | What it does | +|---|---|---|---| +| `DECO_ALLOWED_PREVIEW_HOSTS` | `@decocms/blocks` | the Site block's `previewHosts` | Comma-separated request hosts (with port) allowed to render drafts. Replaces the Site block's list. `none` turns draft preview off. | +| `DECO_PREVIEW_API_DOMAINS` | `@decocms/blocks` | Studio's domains and `localhost` | Comma-separated domains drafts may be fetched from. A leading `.` matches subdomains. | + +See [Previews and draft preview](/v7/preview). + +### Fast Deploy (TanStack) + +| Name | Kind | Default | What it does | +|---|---|---|---| +| `DECO_FAST_DEPLOY` | variable | | `1` or `true` turns Fast Deploy on, together with `DECO_KV`. | +| `DECO_KV` | KV binding | | The namespace holding content snapshots. | +| `DECO_DEPLOYMENT_ID` | variable | `BUILD_HASH`, then the build-time hash | The deployment whose snapshot this Worker reads and writes. Passed per deploy. | +| `DECO_SEEDED_DEPLOY` | build variable | | Set by deployment pipelines that seed KV before activation; makes `decoVitePlugin` stub bundled content out in `"auto"` mode. | +| `CF_ACCOUNT_ID`, `CF_API_TOKEN`, `CF_KV_NAMESPACE_ID` | CLI variables | `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`, the namespace in `wrangler.jsonc` | Credentials for `deco-sync-blocks-to-kv` and `deco-migrate-blocks-to-kv`. | + +See [Deploying and Fast Deploy](/v7/releases). + +### Caching + +| Name | Read by | Default | What it does | +|---|---|---|---| +| `BUILD_HASH` | `@decocms/tanstack` | the build-time hash | Version added to every edge cache key. Rename with `cacheVersionEnv`. | +| `PURGE_TOKEN` | `@decocms/tanstack` | | Bearer token for `POST /_cache/purge` and `/_cache/purge-loaders`. Rename with `purgeTokenEnv`. | +| `DECO_LOADER_CACHE_MAX_BYTES` | `@decocms/blocks` | 32 MB | Size cap of the in-memory loader cache. | +| `DECO_CACHE_DISABLE` | `@decocms/blocks` | | `true` disables the loader cache and shortens route cache times. | + +See [Caching](/v7/caching). + +### Observability + +| Name | Kind | Default | What it does | +|---|---|---|---| +| `DECO_OTEL` | variable | auto | `on`, `off`, or unset (on when deployed, off in development). | +| `DECO_OTEL_TRACES_ENDPOINT` | variable | | Collector endpoint for spans (OTLP/HTTP). | +| `DECO_OTEL_METRICS_ENDPOINT` | variable | | Collector endpoint for metrics. | +| `DECO_OTEL_LOGS_ENDPOINT` | variable | | Collector endpoint for logs. | +| `DECO_OTEL_HEADERS` | variable | | Extra OTLP headers, `k=v,k2=v2`. | +| `DECO_OTEL_AUTH_TOKEN` | secret | | `Authorization` header value for the collector. | +| `DECO_OTEL_TRACES_SAMPLING_RATE` | variable | `0.01` | Fraction of traces exported. | +| `DECO_OTEL_LOGS_MIN_LEVEL` | variable | `info` | Lowest log level posted. | +| `DECO_OTEL_ERROR_PROMOTION`, `DECO_OTEL_ERROR_PROMOTION_RATE` | variables | off, `0.1` | Export a share of unsampled error traces. | +| `DECO_ENV_NAME` | variable | `production` | `deployment.environment` on telemetry. | +| `DECO_METRICS` | Analytics Engine binding | | Metrics to Analytics Engine. | +| `CF_VERSION_METADATA` | `version_metadata` binding | | `service.version` on telemetry. | +| `OTEL_LOG_OUTGOING_FETCH` | variable | | `true` logs every outgoing fetch. | + +See [Observability](/v7/observability). + +### Analytics + +| Name | Read by | Default | What it does | +|---|---|---|---| +| `DECO_ANALYTICS_ENABLED` | `Stats` (`@decocms/blocks/hooks`) | | Must be exactly `true` for the first-party analytics tag to render. | +| `DECO_ANALYTICS_ORIGIN` | `Stats` | same origin | Origin the tag loads from. | +| `DECO_ANALYTICS_SITE_KEY` | `Stats` | | Site key, for sites not served through Deco's edge. | +| `ONEDOLLAR_ENABLED` | `OneDollarStats` (`@decocms/apps-website`) | enabled | `false` disables it. | +| `ONEDOLLAR_COLLECTOR`, `ONEDOLLAR_STATIC_SCRIPT` | `OneDollarStats` | | Collector and script URL overrides. | + +### Apps + +| Name | App | What it does | +|---|---|---| +| `VTEX_APP_KEY`, `VTEX_APP_TOKEN` | VTEX | Fallback credentials when the `deco-vtex` block has none. | +| `VTEX_RESILIENCE_DISABLED` | VTEX | `true` turns off retries and the circuit breaker in `createVtexFetch`. | +| `SHOPIFY_STOREFRONT_TOKEN` | Shopify | Fallback Storefront API token. | +| `WAKE_TOKEN` | Wake | The Storefront API token. Required; Wake reads it only from the environment. | +| `RESEND_API_KEY` | Resend | Fallback API key. | + +Apps whose credentials are stored as encrypted secrets also need `DECO_CRYPTO_KEY`. See each app's page under [Apps](/v7/apps). + +### Development + +| Name | Read by | What it does | +|---|---|---| +| `DECO_SITE_NAME` + `DECO_ENV_NAME` | Vite plugin | When both are set in `vite dev`, the plugin connects your local server to Studio through a tunnel. | +| `DECO_HOST` | Vite plugin | `false` selects the older tunnel relay. | +| `WORKERS_CI_COMMIT_SHA` | Vite plugin | Used as the build hash on Cloudflare Workers Builds. | + +## A complete `wrangler.jsonc` + +```jsonc title="wrangler.jsonc" +{ + "name": "my-store", + "main": "src/worker-entry.ts", + "compatibility_date": "2026-02-14", + "compatibility_flags": ["nodejs_compat", "no_handle_cross_request_promise_resolution"], + "kv_namespaces": [{ "binding": "DECO_KV", "id": "<your namespace id>" }], + "vars": { + "DECO_SITE_NAME": "my-store", + "DECO_ENV_NAME": "production", + "DECO_FAST_DEPLOY": "1" + }, + "observability": { + "enabled": true, + "logs": { "enabled": true, "head_sampling_rate": 1 }, + "traces": { "enabled": true, "head_sampling_rate": 0.01 } + }, + "version_metadata": { "binding": "CF_VERSION_METADATA" } +} +``` + +- `nodejs_compat` is required: request context uses `AsyncLocalStorage`. +- `no_handle_cross_request_promise_resolution` lets the framework's caches share an in-flight request across concurrent visitors; without it, the Worker can hang. +- Set `DECO_RELEASE_RELOAD_TOKEN`, `PURGE_TOKEN`, `DECO_CRYPTO_KEY` and `DECO_OTEL_AUTH_TOKEN` as secrets (`wrangler secret put`), not in `vars`. + +## `createSiteSetup` + +From `@decocms/blocks/setup`. See [Blocks and sections](/v7/model). + +| Option | Type | Default | What it does | +|---|---|---|---| +| `sections` | `Record<string, () => Promise<any>>` | required | Section modules keyed `./sections/<path>.tsx`, as `import.meta.glob` returns them. Registered as `site/sections/<path>.tsx`. | +| `blocks` | `Record<string, unknown>` | required | The decofile, usually `blocks` from `.deco/blocks.gen`. | +| `productionOrigins` | `string[]` | | Absolute URLs on these origins in content are made relative. | +| `customMatchers` | `Array<() => void>` | | Functions that register your own matchers. Built-in matchers are always registered. | +| `onResolveError` | `(error, resolveType, context) => void` | | Called when a loader or section fails during resolution. | +| `onDanglingReference` | `(resolveType) => any` | warns, returns `null` | Called for a loader or action key nothing registered. | +| `initPlatform` | `(blocks) => void` | | Runs with the content at setup and again whenever it changes, to configure a platform. | + +## `createAdminSetup` + +From `@decocms/blocks-admin/setup`. TanStack only; Next.js passes these to `createNextSetup`. See [Deco Studio and the admin protocol](/v7/studio). + +| Option | Type | Default | What it does | +|---|---|---|---| +| `meta` | `() => Promise<any>` | required | Loads the schema lazily, usually `() => import("../.deco/meta.gen.json").then((m) => m.default)`. | +| `css` | `string` | required | URL of your stylesheet for preview pages, from a `?url` import. | +| `fonts` | `string[]` | `[]` | Font stylesheet URLs for previews. | +| `previewWrapper` | `React.ComponentType` | | Wraps every preview, to provide context (TanStack: `PreviewProviders`). | +| `getCommerceLoaders` | `() => Record<string, (props, request?) => Promise<any>>` | | Loaders made invokable at `/deco/invoke`. | + +For a preview theme, body class or language, call `setRenderShell({ theme: "light", bodyClass, lang })` from `@decocms/blocks-admin`. + +## `createNextSetup` + +From `@decocms/nextjs/setup`. Returns `ensureSetup()`. See [Next.js App Router](/v7/nextjs). + +| Option | Type | Default | What it does | +|---|---|---|---| +| `sections` | `Record<string, () => Promise<any>>` | required | Section modules keyed `./sections/<path>.tsx`; use the generated `sectionImports`. | +| `blocks` | `Record<string, unknown>` | | Content, usually the generated block manifest. Merged over `blocksDir`. | +| `blocksDir` | `string \| false` | `".deco/blocks"` | Directory read at runtime. `false` when you pass `blocks`. | +| `conventions` | `{ meta, syncComponents, loadingFallbacks }` | | Section conventions from `.deco/sections.gen.ts`. | +| `meta` | `() => Promise<unknown>` | | The schema. Without it, `/live/_meta` returns 503. | +| `renderShell` | `{ css?: string; fonts?: string[] }` | | Preview stylesheet and fonts. | +| `previewWrapper` | `React.ComponentType` | | Wraps previews. | +| `productionOrigins`, `customMatchers`, `onResolveError`, `onDanglingReference` | | | As in `createSiteSetup`. | +| `extend` | `(blocks) => void \| Promise<void>` | | Runs last, with the loaded content. | + +## `cmsRouteConfig` and `cmsHomeRouteConfig` + +From `@decocms/tanstack`. Spread into `createFileRoute("/$")` and `createFileRoute("/")`. See [TanStack Start on Cloudflare Workers](/v7/tanstack). + +| Option | Type | Default | What it does | +|---|---|---|---| +| `siteName` | `string` | required (`/$`); `defaultTitle` (`/`) | Used in page titles. | +| `defaultTitle` | `string` | required | Title when a page has none. | +| `defaultDescription` | `string` | | Description when a page has none. | +| `ignoreSearchParams` | `string[]` | `["skuId"]` | Query parameters that don't trigger a reload. `/$` only. | +| `pendingComponent` | component | none | Shown during slow navigations. Without it, the previous page stays visible. | +| `pendingMs`, `pendingMinMs` | `number` | `200`, `300` | When the pending component shows, and for how long at least. | +| `errorComponent` | component | built-in error page | Shown when loading fails. | +| `ssr` | `boolean \| "data-only"` | full SSR | TanStack Start's SSR mode. `/$` only. | +| `resolveGlobals` | `boolean` | `true` | Merge the Site block's global sections and theme into every page. | + +## `createDecoWorkerEntry` + +From `@decocms/tanstack`: `createDecoWorkerEntry(serverEntry, options)`. + +| Option | Type | Default | What it does | +|---|---|---|---| +| `admin` | `{ handleMeta, handleDecofileRead, handleDecofileReload, handleRender, corsHeaders }` | | Admin protocol handlers from `@decocms/blocks-admin`. Without it, `/live/_meta`, `/.decofile` and `/live/previews` aren't served. | +| `buildSegment` | `(request) => SegmentKey` | | The visitor's cache segment. See [Caching](/v7/caching#personalized-pages-segments). | +| `detectProfile` | `(url) => CacheProfileName \| null` | built-in rules | Choose a cache profile per URL. | +| `deviceSpecificKeys` | `boolean` | `true` | Split the cache by mobile/desktop when there's no `buildSegment`. | +| `geoCacheKey` | `"auto" \| "off" \| "country" \| "region" \| "city"` | `"auto"` | Geography in the cache key. | +| `autoInjectGeoCookies` | `boolean` | `true` | Expose Cloudflare geolocation to matchers. | +| `safeCookies` | `string[]` | VTEX session cookies and `_deco_bucket` | Cookies that don't prevent caching. | +| `stripTrackingParams` | `boolean` | `true` | Remove tracking parameters from cache keys. | +| `bypassPaths` | `string[]` | `/_build`, `/deco/`, `/live/`, `/.decofile` | Never cached. Your entries are added to the defaults. | +| `extraBypassPaths` | `string[]` | `[]` | More paths never cached. | +| `staticPaths` | `string[]` | `["/fonts/"]` | Path prefixes of non-fingerprinted files (fonts, icons) that get long-lived immutable cache headers. | +| `fingerprintedAssetPattern` | `RegExp` | hashed files under `/assets/` | Assets cached for a year. | +| `cacheVersionEnv` | `string \| false` | `"BUILD_HASH"` | Variable with the deploy version for cache keys. | +| `purgeTokenEnv` | `string \| false` | `"PURGE_TOKEN"` | Variable with the purge token. `false` disables purging. | +| `cacheStorage` | `(env, request) => CacheStorage \| null` | Cache API + memory | Shared cache storage. | +| `cdnCacheControl` | `"serverfn-segment" \| "no-store" \| "match-profile" \| (profile) => string \| null` | `"serverfn-segment"` | `CDN-Cache-Control` policy. | +| `renderJson` | `boolean` | `true` | Serve `?renderJson`. See [Storefront as an API](/v7/storefront-api). | +| `asJson` | `boolean` | `true` | Serve `?asJson`. | +| `pageJsonCors` | `string[] \| "*" \| false` | `"*"` | CORS for page JSON. | +| `proxyHandler` | `(request, url) => Response \| null \| Promise<…>` | | Proxy paths to another origin (checkout, for example). | +| `previewShell` | `string` | built from the render shell | HTML for the empty preview frame. | +| `securityHeaders` | `Record<string, string> \| false` | `nosniff`, HSTS, referrer and permissions policies, `frame-ancestors` for Studio | Headers on HTML responses. | +| `csp` | `string[] \| false` | | Content Security Policy directives. | +| `cspMode` | `"report-only" \| "enforce"` | `"report-only"` | `enforce` adds a per-request nonce on uncached HTML. | +| `speculationRules` | `SpeculationRulesConfig` | off | See [Speculation rules](/v7/speculation-rules). | +| `observability` | `OtelOptions \| false` | on | See [Observability](/v7/observability). | +| `outboundUserAgent` | `string \| false` | `Deco/<version> (+https://deco.cx)` | `User-Agent` added to outgoing `fetch` calls that have none. | + +## `decoVitePlugin` + +From `@decocms/tanstack/vite`. + +| Option | Type | Default | What it does | +|---|---|---|---| +| `fastDeploy` | `boolean \| "auto"` | `"auto"` | Remove the bundled content from the server bundle (production builds only). `"auto"` does so only when `DECO_SEEDED_DEPLOY` is set. See [Deploying and Fast Deploy](/v7/releases#bundle-stub-mode-advanced). | + +## Related + +- [Packages and exports](/v7/packages) +- [CLI reference](/v7/cli): command flags. diff --git a/docs/content/v7/content.mdx b/docs/content/v7/content.mdx new file mode 100644 index 00000000..79a324d9 --- /dev/null +++ b/docs/content/v7/content.mdx @@ -0,0 +1,186 @@ +--- +title: Content and the decofile +nav: The decofile +group: Core concepts +order: 7 +description: How a v7 site stores content as a flat map of JSON blocks, how blocks refer to each other, and how content reaches the running site. +--- + +# Content and the decofile + +All of a site's content lives in one structure, the *decofile*: a flat map from block names to JSON. Pages, the sections on them, shared headers and footers, A/B variants and app settings are all entries in it. This page shows how the decofile is stored, the ways blocks refer to each other, and how content gets from the repository and from Studio into the running site. + +<Terms> + <Term name="Decofile">The site's content: a flat map of block name to JSON, stored as `.deco/blocks/*.json` and bundled into the build. See the [glossary](/v7/glossary#decofile).</Term> + <Term name="Named block">An entry in the decofile, such as `Header` or `pages-home`, that other content can refer to by name.</Term> + <Term name="Reference">A value whose `__resolveType` is the name of another block. It stands for that block, optionally with some props overridden.</Term> + <Term name="Revision">A hash of the current decofile. It changes whenever content changes and is used as an ETag and cache key.</Term> +</Terms> + +## One file per block + +On disk, each block is a JSON file in `.deco/blocks/`, named after the block (URL-encoded). The block's name is the file name without `.json`: + +```text +.deco/blocks/ +├── pages-home.json the block "pages-home" +├── pages-summer-sale.json the block "pages-summer-sale" +├── Header.json the block "Header" +├── Footer.json the block "Footer" +├── Site.json the block "Site" +└── deco-vtex.json the VTEX app's settings +``` + +At build time, `generate` merges these files into one map. That map is what the runtime holds in memory and what Studio reads and writes through `/.decofile`. The map is flat: there are no folders or types in the names, only what each block's JSON says it is. + +## Inline values and references + +A page's sections can be written inline, or they can point at a named block. Here's a header saved once as its own block: + +```json title=".deco/blocks/Header.json" +{ + "__resolveType": "site/sections/Header/Header.tsx", + "logo": "https://www.example.com/logo.svg", + "links": [ + { "label": "New in", "href": "/new" }, + { "label": "Sale", "href": "/summer-sale" } + ] +} +``` + +And a page that uses it by name, next to an inline Hero: + +```json title=".deco/blocks/pages-summer-sale.json" +{ + "__resolveType": "website/pages/Page.tsx", + "name": "Summer sale", + "path": "/summer-sale", + "sections": [ + { "__resolveType": "Header", "transparent": true }, + { + "__resolveType": "site/sections/Hero.tsx", + "title": "Summer sale", + "image": "https://www.example.com/summer.jpg" + }, + { + "__resolveType": "site/sections/ProductShelf.tsx", + "title": "Best sellers", + "products": { + "__resolveType": "vtex/loaders/intelligentSearch/productList.ts", + "props": { "query": "summer", "count": 12 } + } + }, + { "__resolveType": "Footer" } + ] +} +``` + +Three kinds of value appear in that page: + +- **A reference with an override.** `{ "__resolveType": "Header", "transparent": true }` means "the block named `Header`, with `transparent` set to `true`". The runtime takes the saved block and merges the extra props over it, for this use only. Editing `Header` in Studio changes it on every page that refers to it. +- **An inline section.** The Hero's props are written right there, so they belong to this page alone. +- **A loader call.** The shelf's `products` prop names a data loader, here one from the VTEX app, with its own props. During resolution the loader runs, and the shelf receives its result (a list of products) as `products`. See [Loaders and actions](/v7/loaders). + +The loader receives every field of this object except `__resolveType` as its first argument. This VTEX loader takes its query under `props`; a loader you write, like `storeHours` in [Loaders and actions](/v7/loaders), reads its fields directly. + +In Studio, a block saved under its own name is what editors see as a *saved* section (not to be confused with the Site block's global sections): change it once and every page that uses it changes, which is why the header and footer are usually saved. A section configured directly on a page is local to that page. + +Resolution follows these names recursively, so a referenced block can itself contain references and loader calls. A `__resolveType` that names a loader or action that isn't registered resolves to `null` with a warning in the log; `onDanglingReference` in `createSiteSetup` changes that behaviour. Any other name that matches no block is treated as a section key, so a misspelled block name shows up as a section with no registered component, which the renderer skips with a warning. + +## Secrets in content + +App settings sometimes need credentials, such as an API token. Studio stores fields typed as `Secret` encrypted, as an object with an `encrypted` value, so the credential itself never lands in your repository. At runtime the app decrypts it with the key in the `DECO_CRYPTO_KEY` environment variable. Studio encrypts with your site's key, so `DECO_CRYPTO_KEY` must hold that same key, as base64-encoded JSON with the AES-CBC `key` and `iv` bytes. Apps can also fall back to a plain environment variable when the field is empty (the VTEX app reads `VTEX_APP_KEY`, for example). Set `DECO_CRYPTO_KEY` as a secret in your hosting environment. See [Apps](/v7/apps). + +## The Site block + +A block named `Site` (or `site`) holds site-wide settings. The runtime reads two fields from it: + +- `seo`: default title, description and templates used when a page doesn't set its own. See [SEO](/v7/seo). +- `previewHosts`: the hosts allowed to render unpublished drafts, read once at setup. On Next.js it's read only from a block named `site`. See [Previews and draft preview](/v7/preview). + +On TanStack Start, its `global`, `theme` and `pageSections` entries are also rendered on every page; the route option `resolveGlobals: false` turns that off. + +## Redirects in content + +Redirects are blocks too. A block whose `__resolveType` is `website/loaders/redirect.ts` (one redirect) or `website/loaders/redirects.ts` (a list) declares `from`, `to` and a `type`: + +```json title=".deco/blocks/redirect-old-sale.json" +{ + "__resolveType": "website/loaders/redirect.ts", + "redirect": { "from": "/sale-2025", "to": "/summer-sale", "type": "permanent" } +} +``` + +`permanent` answers with 301, anything else with 302. A `from` that ends in `*` matches every path with that prefix, and a `*` in `to` is replaced with the rest of the path, so `/old/*` to `/new/*` sends `/old/shoes` to `/new/shoes`. There are no other patterns. The TanStack Worker applies redirects before rendering; see [Pages and routing](/v7/routing#redirects). + +<Callout> + +**Temporary or permanent?** Use temporary (302) while a redirect might still change. Browsers cache a 301 and keep following it even after you delete the rule. + +</Callout> + +### Redirects from a CSV file + +Redirects kept in a CSV file under `public/` and referenced by a `website/loaders/redirectsFromCsv.ts` block (its `from` field holds the file's path) are read by `generate` and turned into ordinary redirect blocks, so the site never reads the file at runtime. Write one rule per line, `from,to[,type]`: + +```text title="public/redirects.csv" +from,to,type +# spring sale ended +/spring-sale,/summer-sale,permanent +/old/*,/new/* +``` + +- A `from,to` header row is optional and skipped. +- Lines starting with `#` and blank lines are ignored. +- Values are split on commas, with no quoting, so a URL can't contain a comma. +- `permanent` or `301` gives a 301; anything else, or nothing, gives a 302. +- The path can be written `public/redirects.csv`, `static/redirects.csv` or `redirects.csv`; all resolve under `public/`. +- A missing file is a warning during `generate`, not an error. +- For the same exact `from`, a redirect block wins over a CSV row. + +## How content reaches the runtime + +The runtime keeps the decofile in memory. You rarely call these functions yourself, but knowing them explains how publishing works. They come from `@decocms/blocks/cms`: + +| Function | What it does | +|---|---| +| `setBlocks(blocks)` | Replaces the whole decofile in one step, recomputes the revision and notifies listeners. | +| `loadBlocks()` | Returns the current decofile (with any per-request draft or preview override applied). | +| `getRevision()` | The current revision hash. | +| `onChange((blocks, revision) => …)` | Calls you after every `setBlocks`. Returns an unsubscribe function. | + +Content arrives from three places: + +1. **At startup,** from the build. `createSiteSetup({ blocks })` calls `setBlocks` with the bundled content (`.deco/blocks.gen` on TanStack, the blocks manifest on Next.js). +2. **In development,** from your editor. The TanStack Vite plugin watches `.deco/blocks/` and applies each file change to the running server as a delta. On Next.js with the blocks manifest, block files are part of the module graph, so edits hot-reload the same way. +3. **From Studio,** when an editor publishes. Studio sends `POST /.decofile` with either the full decofile or a delta, an object whose only key is `blocks`, holding the changed blocks (`null` deletes one). The site merges it, calls `setBlocks`, clears its loader cache and invalidates the schema's ETag. See [Deco Studio and the admin protocol](/v7/studio). + +On TanStack Start with [Fast Deploy](/v7/releases), each Worker isolate also loads its deployment's published decofile from Cloudflare KV when it starts and checks for a newer revision about every ten seconds, so a publish reaches every instance without a deploy. + +<Callout type="warning"> + +**Read content inside the request, not at module load.** Code that calls `loadBlocks()` at the top level of a module captures the content that existed when the module loaded and never sees a publish: + +```ts +import { loadBlocks } from "@decocms/blocks/cms"; +import { loadRedirects, matchRedirect } from "@decocms/blocks/sdk/redirects"; + +// Don't: runs once, when the module is first imported +const redirects = loadRedirects(loadBlocks()); + +// Do: runs per request, so it sees the current content +export function findRedirect(path: string) { + return matchRedirect(path, loadRedirects(loadBlocks())); +} +``` + +The framework's own consumers (redirects, routing, apps) already rebuild on change. If you need to react to a publish, use `onChange`. + +</Callout> + +## Next steps + +- [Pages and routing](/v7/routing): how a page block is found for a URL. +- [Loaders and actions](/v7/loaders): the functions content can call. +- [Deco Studio and the admin protocol](/v7/studio): reading and publishing content over HTTP. +- [Deploying and Fast Deploy](/v7/releases): how content ships in production. diff --git a/docs/content/v7/eitri.mdx b/docs/content/v7/eitri.mdx new file mode 100644 index 00000000..79d69f15 --- /dev/null +++ b/docs/content/v7/eitri.mdx @@ -0,0 +1,133 @@ +--- +title: Eitri apps +nav: Eitri +group: Framework guides +order: 22 +description: Use @decocms/eitri to generate the schema and content snapshot Studio needs to author content for an Eitri mobile app. +--- + +# Eitri apps + +Eitri is a platform for building mobile apps that renders its components natively on the device. `@decocms/eitri` lets editors manage an Eitri app's content in [Deco Studio](/v7/studio). Unlike the web bindings, it renders nothing and ships no runtime: it only **generates** the files Studio reads, from your app's section types. Eitri fetches the content and renders it. + +## Who does what + +| Concern | Owner | +|---|---| +| Turning section `Props` types into a JSON Schema, and bundling content into a snapshot | `@decocms/eitri` | +| Authoring content: forms, page composition | Studio, reading the generated files | +| Fetching the content on the device and rendering sections | The Eitri platform and your app | + +Studio preview of Eitri apps isn't available yet. Editors author through Studio's forms; you see the result in the app. + +## Install and set up + +Add the package as a development dependency of your Eitri app: + +```bash +bun add -d @decocms/eitri +``` + +Then scaffold the configuration: + +```bash +npx deco-eitri init +``` + +`init` creates two files and never overwrites one that already exists: + +- **`tsconfig.json`**, extending `@decocms/eitri/tsconfig`. It's required: the schema generator needs a TypeScript configuration to read your `Props` types. +- **`src/eitri-env.d.ts`**, declarations for the `eitri-luminus`, `eitri-bifrost` and `eitri-commons` modules, so your editor and `tsc` accept those imports. Generation works without it. Delete it if you install real type definitions for those modules. + +If you already have a `tsconfig.json`, `init` skips it, so add the `extends` yourself. The minimal file is: + +```json title="tsconfig.json" +{ "extends": "@decocms/eitri/tsconfig", "include": ["src"] } +``` + +The shared configuration targets ESNext with bundler module resolution and `react-jsx`, allows JavaScript files and doesn't type-check them, and turns off `strict`. + +## Write a section + +An Eitri section is a Deco **section**: a component that is the default export of a file under `src/sections/`, with an exported `Props` type. JSDoc tags on the props become the Studio form, as described in [Schema generation](/v7/schema). + +```tsx title="src/sections/Banners/Hero.tsx" +import { Image, View } from "eitri-luminus"; + +export interface Props { + /** @title Hero image */ + image: string; + /** @title Alt text */ + alt?: string; + /** + * @title Publish date + * @format datetime + */ + publishAt?: string; +} + +export default function HeroBanner({ image, alt }: Props) { + return ( + <View> + <Image src={image} alt={alt} /> + </View> + ); +} +``` + +The section's key is `site/sections/Banners/Hero.tsx`, the same rule as on the web. For Eitri apps: + +- section files may be `.tsx`, `.ts`, `.jsx` or `.js`; +- Eitri's `@format datetime` is normalized to the standard `date-time`. + +## Generate + +```bash +npx deco-eitri generate +``` + +This runs the same [`generate`](/v7/generate) command web sites use, with `--platform eitri` added. On that platform only two generators run: + +| Output | What it is | +|---|---| +| `.deco/meta.gen.json` | The schema, self-contained: your section schemas plus the framework's own types (pages, matchers, the section picker), tagged with `framework: "eitri"`. Studio reads it as is. | +| `.deco/blocks.gen.json` | The content snapshot: every block in `.deco/blocks/` merged into one JSON object, the file the Eitri runtime consumes. A new app starts with an empty one; it fills as editors author content. A small `.deco/blocks.gen.ts` stub is written next to it. | +| `.deco/generate.digests.json` | The incremental-generation cache. Commit it. | + +The web-only outputs (the section registry, the loader map, the blocks manifest and the invoke server functions) aren't produced. + +Every `generate` flag passes through, so you can target a sub-app in a monorepo or force a rebuild: + +```bash +npx deco-eitri generate --root apps/mobile --force +``` + +`deco-eitri generate --help` prints the full flag list. If you pass your own `--platform`, it's left as is. Add a script so the team runs the same command: + +```json title="package.json (excerpt)" +{ "scripts": { "deco:generate": "deco-eitri generate" } } +``` + +## From code + +The package root exports the same operations as functions, for build scripts: + +```ts title="scripts/generate-deco.ts" +import { generateEitri, runEitriInit } from "@decocms/eitri"; + +runEitriInit({ root: "apps/mobile" }); +const exitCode = await generateEitri({ root: "apps/mobile", force: true }); +process.exitCode = exitCode; +``` + +| Function | What it does | +|---|---| +| `generateEitri(options?)` | Runs `generate` with `--platform eitri`. Options: `root`, `site`, `namespace` (default `"site"`), `force`, and `extraArgs` (raw flags). Resolves to the exit code, `0` on success. | +| `eitriGenerateArgs(options?)` | Returns the argument list `generateEitri` would pass, without running anything. | +| `runEitriInit(options?)` | Scaffolds the two files in `root` (default: the current directory). Returns `{ created, skipped }`, the file lists. | + +## Related + +- [Schema generation](/v7/schema) lists the JSDoc tags and widget formats. +- [Code generation](/v7/generate) covers every flag and the cache. +- [CLI reference](/v7/cli) has the `deco-eitri` synopsis next to the other commands. diff --git a/docs/content/v7/generate.mdx b/docs/content/v7/generate.mdx new file mode 100644 index 00000000..e0aceb11 --- /dev/null +++ b/docs/content/v7/generate.mdx @@ -0,0 +1,180 @@ +--- +title: Code generation +nav: generate +group: CLI +order: 23 +description: What the generate command produces from your content and source, when each generator runs, how its cache works, and every flag. +--- + +# Code generation + +`generate` turns your content and your TypeScript into the files the runtime and Studio read: the bundled decofile, the section registry, the loader map, the schema and, on TanStack, typed server functions for app actions. It's one incremental command that runs six generators and skips any whose inputs haven't changed since the last run. Run it before every build, and whenever you add a section, loader or block file. + +## Add the script + +`generate` ships with `@decocms/blocks-cli`, usually a development dependency. It has no binary of its own; run it through `tsx` by file path: + +```json title="package.json (excerpt, TanStack)" +{ + "scripts": { + "generate": "tsx node_modules/@decocms/blocks-cli/scripts/generate.ts --site my-store", + "build": "npm run generate && tsr generate && vite build" + } +} +``` + +On Next.js, run it before `dev` and `build`: + +```json title="package.json (excerpt, Next.js)" +{ + "scripts": { + "generate": "tsx node_modules/@decocms/blocks-cli/scripts/generate.ts", + "predev": "npm run generate", + "prebuild": "npm run generate" + } +} +``` + +Put every flag your site needs on this one line, so everyone runs the same command. + +During `vite dev`, the [TanStack Vite plugin](/v7/tanstack) runs parts of `generate` for you: it applies block edits live, regenerates sections, loaders and invoke when source files change, and rebuilds the schema after a short debounce. You still need the script for builds and CI. + +## The generators + +| Generator | Reads | Writes | Runs when | +|---|---|---|---| +| `blocks` | `.deco/blocks/*.json` | `.deco/blocks.gen.json` (the content) and `.deco/blocks.gen.ts` (a small stub) | `.deco/blocks` exists, and the site isn't Next.js (or `blocks.gen.json` already exists) | +| `manifest` | `.deco/blocks/*.json` | `.deco/blocksManifest.gen.ts` | `.deco/blocks` exists, and the site is Next.js (or the manifest already exists) | +| `sections` | `src/sections/**` | `.deco/sections.gen.ts` | `src/sections` exists | +| `loaders` | `src/loaders/**`, `src/actions/**` | `.deco/loaders.gen.ts` | always (an empty map when the folders are missing) | +| `invoke` | the VTEX app's action list | `src/server/invoke.gen.ts` | the VTEX app is installed, `@tanstack/react-start` is installed, and the site isn't Next.js | +| `schema` | every `.ts`/`.tsx` under `src/`, `tsconfig.json`, installed `@decocms/apps-*` packages | `.deco/meta.gen.json` | `src/sections` and `tsconfig.json` exist | + +`generate` decides the site is Next.js when `@decocms/nextjs` is in your `package.json` dependencies, and TanStack when `@decocms/tanstack` is. + +### blocks + +The **decofile** is the site's content: a flat map from block name to JSON, stored as one file per block in `.deco/blocks/`. Each filename is the block name URL-encoded, plus `.json`. The `blocks` generator merges them into `.deco/blocks.gen.json`. The `.ts` stub next to it exports an empty object; the TanStack Vite plugin replaces it with the JSON in server builds and keeps it empty in client builds. + +Two extras happen on the way: + +- **CSV redirects.** A block of type `website/loaders/redirectsFromCsv.ts` points at a CSV file in `public/`. Its rows are read into the snapshot, and redirects authored in Studio take precedence over the CSV. +- **Duplicate files.** When two files decode to the same block name, a fixed tie-break keeps one: a block with a `path` first, then the more URL-encoded file name, then the newer file. The generator prints which files to delete. + +### manifest + +Next.js sites get `.deco/blocksManifest.gen.ts` instead: a module that statically imports every block file. With `createNextSetup({ blocks, blocksDir: false })`, the bundler owns the content, so content edits hot-reload and builds include it. Adding or removing a block file needs a regeneration; editing one doesn't. See [Next.js App Router](/v7/nextjs). + +### sections + +Each file under `src/sections/` becomes a **section** with the key `site/sections/<path>`, for example `site/sections/Product/SearchResult.tsx`. The generator records each file's convention exports (`eager`, `layout`, `sync`, `cache`, `LoadingFallback`, `renderJson` and the rest) in `.deco/sections.gen.ts`, which setup passes to `applySectionConventions` or to `createNextSetup({ conventions })`. [Section conventions](/v7/sections) lists them all. + +With `--registry`, the file also exports `sectionImports`, a lazy map of every section keyed `./sections/<path>`. That stands in for Vite's `import.meta.glob` on Next.js. The registry is on by default for Next.js sites, and for sites whose existing `sections.gen.ts` already has it. Files ending in `.test`, `.spec`, `.stories` or `.gen` are skipped. + +### loaders + +Every file under `src/loaders/` and `src/actions/` with a default export is registered under `site/loaders/<path>` or `site/actions/<path>`, both with and without the `.ts` suffix. The map in `.deco/loaders.gen.ts` imports each file lazily. That's what lets content reference your loaders and lets [invoke](/v7/storefront-api) call them. [Loaders and actions](/v7/loaders) shows how the map is registered. + +- `--exclude` skips keys you wire by hand. It takes full keys, comma-separated, such as `site/loaders/search/legacySearch`; a match with or without `.ts` counts. +- `--prune-by-decofile <dir>` emits only the loaders that some block in that directory references with `__resolveType`. Use it only if nothing calls your loaders from code. + +### invoke (TanStack only) + +TanStack Start only turns `createServerFn(...)` calls into server functions when they're declared at the top level of a module. `invoke` reads the action list the VTEX app publishes and writes one top-level server function per action into `src/server/invoke.gen.ts`, each forwarding the platform's cookies back to the browser. Client hooks such as the VTEX cart call these. + +- Keep the file in `src/`. Moved elsewhere, the server half of the functions can't be found. +- Generic MasterData document operations are never generated as client-callable functions. Write a narrow action for the entity you need instead. See [VTEX](/v7/vtex). +- `--apps-dir` points at the app package when it can't be found under `node_modules`. + +### schema + +The **schema** is the JSON Schema of your sections, loaders and pages, which Studio turns into forms. The generator reads your `Props` types and JSDoc with the TypeScript compiler and writes `.deco/meta.gen.json`, already combined with the framework's own types so the file is complete on its own. [Schema generation](/v7/schema) covers the tags and widget formats. + +Its inputs are deliberately broad: any type a `Props` type references can change the schema, so every source file under `src/` and the source of every installed `@decocms/apps-*` package count. `--skip-apps` skips the schema pass over your site's own app files in `src/apps/`. + +## Order + +The generators run in two stages: + +1. `blocks`, `manifest`, `sections`, `loaders` and `invoke` run concurrently. +2. `schema` runs after them, because types can reach into the freshly written `invoke.gen.ts`. + +If any stage-1 generator fails, `schema` is skipped. + +## The cache + +`generate` skips a generator when nothing it reads has changed. It keeps two records. + +**The committed record, `.deco/generate.digests.json`.** One entry per generator: a hash over the content of every input file, the arguments, the installed `@decocms/*` versions and the CLI version. Content hashes don't depend on the machine, so a fresh clone or a CI run gets cache hits. Commit this file. A merge conflict in it is harmless: keep either side and run `generate` again. + +**The local memo, `.deco/.cache/stat-memo.json`.** It remembers each file's hash by size and modification time so unchanged files aren't rehashed. The folder carries its own `.gitignore`. It only saves time; it never decides whether a generator runs. + +A generator is skipped only when its record matches **and** all its outputs exist. Records are written only after a generator succeeds. `--force`, or deleting the digests file, rebuilds everything. + +## What to commit + +| File | Commit? | +|---|---| +| `.deco/blocks/*.json` | Yes. This is your content. | +| `.deco/generate.digests.json` | Yes. | +| `.deco/meta.gen.json` | Yes. Studio can read the schema on a fresh clone without a regeneration. | +| `.deco/*.gen.ts`, `src/server/invoke.gen.ts` | Yes. | +| `.deco/blocks.gen.json` | Optional. The migration scaffold ignores it, because one large single-line file conflicts on every content change; the build and the dev server regenerate it. | +| `.deco/.cache/` | No. Ignored automatically. | + +Don't give one of your own generated files a name ending in `blocks.gen.ts`: the Vite plugin replaces any module with that name in client builds. + +## Flags + +| Flag | Default | What it does | +|---|---|---| +| `--only <names>` | all | Considers only these generators (comma-separated). Also forces them to run when auto-detection would skip them. | +| `--skip <names>` | none | Never runs these. Wins over everything else. | +| `--force` | off | Ignores the cache. | +| `--dry-run` | off | Prints what would run, be skipped (and why), or stay disabled, then exits. | +| `--root <dir>` | current directory | Runs as if from `<dir>`. A bare path argument means the same. Use it for a sub-app in a monorepo. | +| `--blocks-dir <dir>` | `.deco/blocks` | Block files, for `blocks` and `manifest`. | +| `--sections-dir <dir>` | `src/sections` | Sections, for `sections` and `schema`. | +| `--loaders-dir <dir>` | `src/loaders` | Loaders, for `loaders` and `schema`. | +| `--actions-dir <dir>` | `src/actions` | Actions, for `loaders`. | +| `--apps-dir <dir>` | the installed VTEX app | Where `invoke` finds the app's action list. | +| `--registry`, `--no-registry` | auto | Turns the `sectionImports` registry on or off. | +| `--exclude <keys>` | none | Full loader keys to leave out of the loader map. | +| `--prune-by-decofile <dir>` | off | Emits only loaders referenced by blocks in `<dir>`. | +| `--site <name>` | `storefront` | Site name written into the schema. | +| `--namespace <ns>` | `site` | Namespace of your section and loader keys in the schema. | +| `--platform <name>` | `cloudflare` | Platform recorded in the schema. `eitri` runs only `schema` and `blocks`; see [Eitri apps](/v7/eitri). | +| `--skip-apps` | off | Skips the schema pass over `src/apps/`. | +| `-h`, `--help` | | Prints usage. | + +Generator names are `blocks`, `manifest`, `sections`, `loaders`, `invoke` and `schema`. `--only` and `--skip` also accept `blocks-manifest` for `manifest` and `meta` for `schema`. + +Some common runs: + +```bash +npx tsx node_modules/@decocms/blocks-cli/scripts/generate.ts --dry-run +``` + +```bash +npx tsx node_modules/@decocms/blocks-cli/scripts/generate.ts --only schema --force +``` + +```bash +npx tsx node_modules/@decocms/blocks-cli/scripts/generate.ts --root apps/storefront +``` + +## Output and exit codes + +Each generator logs one line, such as `[generate] sections 412ms (fresh)` or `[generate] loaders 3ms (cached)`, and a final `[generate] total … (N fresh, M cached)` follows. + +- Exit code `0`: every selected generator succeeded or was skipped. +- Exit code `1`: at least one failed, or `--root` doesn't exist. + +The individual generator scripts next to `generate.ts` are implementation details. Call `generate`, and use `--only` to run one generator. + +## Related + +- [Project structure](/v7/project-structure) shows where each generated file sits. +- [Schema generation](/v7/schema) explains what the schema generator reads from your types. +- [Section conventions](/v7/sections) lists the exports the sections generator records. +- [CLI reference](/v7/cli) covers the other commands in `@decocms/blocks-cli`. diff --git a/docs/content/v7/glossary.mdx b/docs/content/v7/glossary.mdx new file mode 100644 index 00000000..d3c47069 --- /dev/null +++ b/docs/content/v7/glossary.mdx @@ -0,0 +1,162 @@ +--- +title: Glossary +group: Reference +order: 46 +description: The terms used throughout the v7 docs, each with a short definition and a pointer to where it's explained. +--- + +# Glossary + +The words these docs use for Deco Blocks v7, in alphabetical order. Each entry is a short definition with a link to the page that explains it in full. + +## Action + +A server function that changes something, such as adding an item to a cart or sending an email. Actions are called by key through [invoke](#invoke). See [Loaders and actions](/v7/loaders). + +## Admin protocol + +The HTTP endpoints [Deco Studio](#deco-studio) calls on your running site: `GET /live/_meta` (the [schema](#schema-meta)), `GET` and `POST /.decofile` (read and publish content), `/live/previews/*` and `/deco/render` (previews) and `/deco/invoke` (run loaders and actions). Implemented by `@decocms/blocks-admin` and mounted by each [binding](#binding). See [Deco Studio and the admin protocol](/v7/studio). + +## App + +A companion package, `@decocms/apps-*`, that brings the loaders, actions, sections and configuration for one platform (VTEX, Shopify, a blog, an email provider). An app is configured from a [block](#block) in the [decofile](#decofile), such as `deco-vtex`. See [Apps](/v7/apps). + +## Binding + +The framework package that connects the runtime to an app framework: `@decocms/tanstack` for TanStack Start on Cloudflare Workers, or `@decocms/nextjs` for the Next.js App Router. The binding finds the page for a URL, renders it, and mounts the [admin protocol](#admin-protocol). See [How v7 works](/v7/architecture). + +## Block + +A named, typed piece of JSON content in the [decofile](#decofile) that the runtime can resolve: a [section](#section), a [page](#page-block), a [loader](#loader) call, a [matcher](#matcher), an [app](#app) configuration. A block's type is given by its [`__resolveType`](#resolvetype). See [Blocks and sections](/v7/model). + +## Bucket + +During a migration traffic split, the site that serves a visitor: `worker` (the new site) or `fallback` (the original). See [Going live after a migration](/v7/migration-cutover#shift-traffic-gradually). + +## Cache profile + +A named caching policy, one of `static`, `product`, `listing`, `search`, `cart`, `private` and `none`, applied consistently at the edge, browser, loader and client layers. The framework picks one from the URL unless you choose. See [Caching](/v7/caching). + +## Commerce loader + +A [loader](#loader) referenced from content by key, such as `vtex/loaders/intelligentSearch/productListingPage.ts`, and registered at setup with `registerCommerceLoaders` (apps provide ready-made maps). The resolver calls it when it meets that key and passes the page URL along. See [Loaders and actions](/v7/loaders). + +## Deco Blocks + +The framework these docs describe. "v7" means the current released version of its packages: `@decocms/blocks`, `@decocms/blocks-admin`, `@decocms/blocks-cli`, `@decocms/tanstack` and `@decocms/nextjs`. See [Deco Blocks v7](/v7/). + +## Deco Studio + +Deco's visual editor, also called Studio or the admin. It reads your [schema](#schema-meta) and content from your running site, shows editors forms and live previews, and publishes changes back over the [admin protocol](#admin-protocol). See [Deco Studio and the admin protocol](/v7/studio). + +## Decofile + +The site's content: a flat map from [block](#block) name to JSON. It's stored as one file per block under `.deco/blocks/`, committed to your repository, and bundled as `.deco/blocks.gen.json` by `generate`. See [Content and the decofile](/v7/content). + +## Deferred section + +A [section](#section) rendered first as a skeleton (its `LoadingFallback`) and loaded separately afterwards, when it nears the viewport. Editors mark a section deferred with the ⚡ toggle in Studio. Bots always get the full page. See [Deferred sections](/v7/rendering). + +## Deployment id + +The identifier of one deployed version of your code, normally its git commit sha, passed as `DECO_DEPLOYMENT_ID`. [Fast Deploy](#fast-deploy) keys content by it so each code version reads its own content. See [Deploying and Fast Deploy](/v7/releases). + +## Draft preview + +Rendering unpublished Studio content on the real site through a `?__draft=` link. It works only on allowed hosts, is never cached, and is marked `noindex`. `?__draft=off` leaves it. See [Previews and draft preview](/v7/preview). + +## Edge cache + +The cache in front of your pages on TanStack Start: the [Worker entry](#worker-entry) stores whole rendered responses in Cloudflare's cache, per cache key, so repeat requests skip rendering. See [Caching](/v7/caching). + +## ETag + +A version tag sent with a response. `/live/_meta` sends a hash of the [schema](#schema-meta) and answers `304` when Studio's `If-None-Match` still matches, so Studio skips downloading an unchanged schema. `/.decofile` sends the content [revision](#revision). See [Deco Studio and the admin protocol](/v7/studio). + +## Fast Deploy + +An opt-in feature of `@decocms/tanstack` that serves content from Cloudflare KV, so a Studio publish reaches every Worker isolate within seconds without a redeploy. It needs `DECO_FAST_DEPLOY=1`, a `DECO_KV` binding and `setupTanstackFastDeploy()`. See [Deploying and Fast Deploy](/v7/releases). + +## Invoke + +Calling a [loader](#loader) or [action](#action) by key over HTTP, at `POST /deco/invoke/<key>`, or from code through the `invoke` proxy in `@decocms/blocks/sdk/invoke`. See [Loaders and actions](/v7/loaders). + +## Island + +In Fresh, components hydrated one by one on an otherwise static page. v7 has no islands: it renders the page on the server and hydrates it with React. See [Migrating from Fresh and Deno](/v7/migrate-from-fresh). + +## Layout section + +A [section](#section), such as a header or footer, whose resolved output is cached for a few minutes and shared across pages, keyed by device. Register layout sections with `registerLayoutSections` or `export const layout = true`. A section whose output depends on cookies, location or query parameters shouldn't be one. See [Section conventions](/v7/sections). + +## Live controls + +`LiveControls`, the small script `DecoRootLayout` renders so Studio and a previewed page can talk to each other. See [Deco Studio and the admin protocol](/v7/studio#live-controls). + +## Loader + +A server function that fetches data. A loader is either called from content (a [commerce loader](#commerce-loader)) or attached to a section (a [section loader](#section-loader)), and site loaders can also be [invoked](#invoke) by key. See [Loaders and actions](/v7/loaders). + +## createCachedLoader + +The loader cache: `createCachedLoader`, which shares a [loader](#loader)'s results across requests in memory, keyed by its props. See [Loaders and actions](/v7/loaders#cache-a-loader). + +## Matcher + +A rule `(rule, context) => boolean` that content uses to choose between [variants](#variant-multivariate-flag): by device, cookie, date, host, path, query string, location, user agent or a random traffic split. Built-in matchers are registered by `createSiteSetup`; add your own with `registerMatcher`. See [Matchers and variants](/v7/variants). + +## Page block + +A [block](#block) with a `path` and a list of `sections`, whose name starts with `pages-` or whose `__resolveType` is `website/pages/Page.tsx`. The binding renders the page block whose `path` matches the URL. See [Pages and routing](/v7/routing). + +## Resolution + +Turning raw [decofile](#decofile) JSON into props ready to render, by following each [`__resolveType`](#resolvetype) recursively: dereferencing named blocks, choosing variants, calling commerce loaders. See [Blocks and sections](/v7/model) and, in depth, [How resolution works](/v7/walkthrough). + +## `__resolveType` + +The field that says what a JSON value in the decofile is: a [section](#section) key (`site/sections/Hero.tsx`), a loader key, a matcher key, or the name of another block to reuse. See [Blocks and sections](/v7/model). + +## Revision + +A hash of the whole [decofile](#decofile). It changes whenever any block changes, and is used to detect new content (by [Fast Deploy](#fast-deploy)) and as a cache key. See [Content and the decofile](/v7/content). + +## Schema (meta) + +The JSON Schema of your sections, loaders and pages, generated from their TypeScript types into `.deco/meta.gen.json` and served at `/live/_meta`. Studio builds its forms from it. See [Schema generation](/v7/schema). + +## Section + +A React component that editors can place on a page. Its key is `site/sections/<path>.tsx`, matching its file under `src/sections/`, and its props are described by an exported `Props` type. See [Blocks and sections](/v7/model). + +## Section loader + +A server function that runs after [resolution](#resolution) and before rendering, enriching one section's props: the section's own `loader` export, or one registered with `registerSectionLoaders`. See [Loaders and actions](/v7/loaders). + +## Segment + +The part of a visitor's identity that changes what a page looks like (device, logged in or not, sales channel, region), returned by the Worker entry's `buildSegment` and added to the edge cache key. See [Caching](/v7/caching). + +## Stale-while-revalidate (SWR) + +Serving a cached result that has expired while a fresh one loads in the background, so visitors don't wait for the refresh. See [Caching](/v7/caching). + +## Studio tunnel + +A development-only connection that lets [Deco Studio](#deco-studio) reach your local dev server. The [Vite plugin](#vite-plugin) starts it when `DECO_SITE_NAME` and `DECO_ENV_NAME` are both set. See [Configuration reference](/v7/configuration#development). + +## Variant (multivariate flag) + +A block holding several alternatives, each guarded by a [matcher](#matcher). The first alternative whose matcher matches is used; if none does, the block is dropped. Used for A/B tests, scheduled content and personalization. See [Matchers and variants](/v7/variants). + +## Vite plugin + +`decoVitePlugin()` from `@decocms/tanstack/vite`, which keeps server code out of the browser bundle and regenerates files in development. See [TanStack Start on Cloudflare Workers](/v7/tanstack#the-vite-plugin). + +## Widget + +A type from `@decocms/blocks/types/widgets`, such as `ImageWidget`, that tells the schema generator which Studio input to show for a prop. See [Schema generation](/v7/schema). + +## Worker entry + +`src/worker-entry.ts`, the file that calls `createDecoWorkerEntry`. It's the outermost handler of every request on TanStack Start: it serves the [admin protocol](#admin-protocol), the [edge cache](#edge-cache) and redirects. See [TanStack Start on Cloudflare Workers](/v7/tanstack#the-worker-entry). diff --git a/docs/content/v7/index.mdx b/docs/content/v7/index.mdx new file mode 100644 index 00000000..95e57758 --- /dev/null +++ b/docs/content/v7/index.mdx @@ -0,0 +1,124 @@ +--- +title: Deco Blocks v7 +nav: Overview +group: Getting started +order: 1 +description: What Deco Blocks v7 is, the packages it ships as, and where to start for a new site, a Next.js site or a migration. +--- + +# Deco Blocks v7 + +Deco Blocks is a framework for content-managed websites and storefronts. You write the building blocks of a page as React components in TypeScript; editors arrange them into pages in Deco Studio, Deco's visual editor; and your app serves the result on TanStack Start on Cloudflare Workers or on the Next.js App Router. These docs cover v7, the current released version of its packages. + +## What you build with it + +A v7 site has three ingredients: + +- **Sections.** A [section](/v7/glossary#section) is a React component that editors can place on a page, such as a hero banner, a product shelf or a footer. Its props are an exported `Props` type, and the framework turns that type into the form editors fill in. +- **Content.** Everything editors create (pages, the sections on them and their props, A/B variants, app settings) is stored as JSON in the [decofile](/v7/glossary#decofile): one file per [block](/v7/glossary#block) under `.deco/blocks/`, committed to your repository and edited through [Deco Studio](/v7/glossary#deco-studio). +- **A binding.** A [binding](/v7/glossary#binding) connects the runtime to your app framework. It finds the page for each URL, resolves its content, runs data loaders and renders the sections. + +Commerce and content integrations (VTEX, Shopify, Wake, a blog, transactional email and more) come as [apps](/v7/glossary#app): companion packages that add loaders, actions and sections for one platform, configured from a block in the decofile. + +[How v7 works](/v7/architecture) follows one request from URL to HTML and one edit from Studio to your site. + +## The packages + +The framework is five packages with a one-way dependency graph: `@decocms/blocks` depends on none of the others, and the two bindings never depend on each other. Every package ships its TypeScript source, so your bundler compiles it with your app. + +| Package | What it is | +|---|---| +| `@decocms/blocks` | The framework-agnostic core: the decofile, page lookup, content resolution, the section registry, matchers, and SDK utilities for caching, request context, images and more. | +| `@decocms/blocks-admin` | The [admin protocol](/v7/glossary#admin-protocol): the endpoints Deco Studio calls on your site, plus the admin half of site setup and app auto-configuration. | +| `@decocms/blocks-cli` | Build-time tooling: the `generate` code generator, migration and upgrade codemods, and Fast Deploy and observability CLIs. Usually a dev dependency. | +| `@decocms/tanstack` | The binding for TanStack Start on Cloudflare Workers: CMS routes, the Worker entry with its edge cache, a Vite plugin, deferred sections and [Fast Deploy](/v7/glossary#fast-deploy). | +| `@decocms/nextjs` | The binding for the Next.js App Router: Server Component pages, route handlers for Studio, an RSC preview page and one-call setup. | + +`@decocms/eitri` is a sixth, generation-only binding for Eitri mobile apps; see [Eitri apps](/v7/eitri). + +The apps: + +| Package | Integration | +|---|---| +| `@decocms/apps-commerce` | Shared commerce types (schema.org-shaped `Product`, listing and detail pages, cart), the app contract and portable helpers. Almost every site installs it. | +| `@decocms/apps-website` | SEO sections, analytics tags, theme and font loaders shared by every backend. | +| `@decocms/apps-vtex` | VTEX: catalog and search loaders, cart and account actions, hooks, checkout proxy. | +| `@decocms/apps-shopify` | Shopify Storefront API: product loaders, cart and customer actions. | +| `@decocms/apps-wake` | Wake Commerce: catalog loaders, cart and wishlist actions, sitemap. | +| `@decocms/apps-magento` | Magento. A partial port; see [Magento](/v7/magento). | +| `@decocms/apps-salesforce` | Salesforce Marketing Cloud Personalization recommendations. | +| `@decocms/apps-algolia` | A shared Algolia search client. Experimental; see [Algolia](/v7/algolia). | +| `@decocms/apps-blog` | Blog posts, categories and authors authored in Studio. | +| `@decocms/apps-resend` | Transactional email through Resend. | + +[Packages and exports](/v7/packages) lists every import path each package exposes. + +## Pick your path + +| You are… | Start here | +|---|---| +| Starting a new site on Cloudflare Workers | [Quickstart: TanStack Start on Cloudflare Workers](/v7/quickstart). This is the most complete binding: it adds the edge cache and Fast Deploy. | +| Starting a new site, or adding Deco to one, on Next.js | [Quickstart: Next.js App Router](/v7/quickstart-nextjs) | +| Inheriting an existing v7 site | [Project structure](/v7/project-structure), then [Blocks and sections](/v7/model) | +| Moving a Fresh/Deno Deco site to v7 | [Migrating from Fresh and Deno](/v7/migrate-from-fresh) | +| Upgrading a TanStack site from `@decocms/start` 6.x | [Upgrading from @decocms/start 6.x](/v7/upgrade-from-start) | +| Moving a Next.js site off `@decocms/start` 5.x | [Moving a Next.js site off @decocms/start 5.x](/v7/nextjs-from-start) | + +## Requirements + +- **React 19.** Every runtime package and app has `react` and `react-dom` `^19` as peer dependencies (the build-time CLIs don't). +- **Node.js 24 or later** for Node-based tooling and for Next.js. Page matching uses the native `URLPattern` API; on an older runtime it fails with an explicit error rather than returning 404s. +- **On Cloudflare Workers**, the `nodejs_compat` compatibility flag. Request-scoped state uses `AsyncLocalStorage`, which Workers provide only with that flag. +- **Next.js 15 or later** for `@decocms/nextjs`. **TanStack Start 1.x** and **Vite 6 or later** for `@decocms/tanstack`. + +## Install + +For TanStack Start: + +```bash +bun add @decocms/blocks @decocms/blocks-admin @decocms/tanstack @tanstack/react-start @tanstack/react-router @tanstack/react-query @tanstack/store react react-dom +``` + +```bash +bun add -d vite +``` + +For Next.js: + +```bash +bun add @decocms/blocks @decocms/blocks-admin @decocms/nextjs +``` + +The quickstarts take it from there. + +<Callout type="preview"> + +**Looking ahead.** The next major version of Deco Blocks is being designed in the open, with a smaller API built around plain functions. Read [how it works](/next/how-it-works) and follow the [Roadmap](/roadmap). Everything on these v7 pages describes what ships today. + +</Callout> + +## Next steps + +- [How v7 works](/v7/architecture): the request and authoring flows, and the package graph. +- [Quickstart: TanStack Start on Cloudflare Workers](/v7/quickstart) or [Quickstart: Next.js App Router](/v7/quickstart-nextjs). +- [Glossary](/v7/glossary): every term these docs use. + +## Further resources + +- [Deco Studio](https://studio.decocms.com): the visual editor your site connects to. +- [decocms/blocks on GitHub](https://github.com/decocms/blocks): source code, issues, runnable sites in `examples/` and Agent Skills for migrations in `.agents/skills/`. +- [TanStack Start](https://tanstack.com/start/latest), the [Next.js App Router](https://nextjs.org/docs/app) and [Cloudflare Workers](https://developers.cloudflare.com/workers/): the frameworks and platform v7 builds on. +- [@decocms/parity](https://www.npmjs.com/package/@decocms/parity): compares a migrated site with the original. +- [The Deco community on Discord](https://decocms.com/discord): ask questions and get help. + +### Training videos + +A five-part walkthrough of editing a site in Deco Studio, from the first settings to publishing a change. The videos are in Portuguese; each one has an English page that covers the same ground. + +| Video | Covers | Read instead | +| --- | --- | --- | +| [Visão Geral e Configurações](https://www.youtube.com/watch?v=Tz_QO3iAojs) | Overview and settings | [Deco Studio and the admin protocol](/v7/studio) | +| [Edição de Conteúdo](https://www.youtube.com/watch?v=MuAh3bhLhuA) | Editing content | [Content and the decofile](/v7/content) | +| [SEO e Redirects](https://www.youtube.com/watch?v=jh7OnXQtCa0) | SEO and redirects | [SEO](/v7/seo) · [Redirects in content](/v7/content#redirects-in-content) | +| [Segmentação e Agendamento](https://www.youtube.com/watch?v=RU_c8rjC40k) | Segments and scheduling | [Matchers and variants](/v7/variants) | +| [Rascunhos e Fluxo de Publicação](https://www.youtube.com/watch?v=kO3H5XHTznA) | Drafts and publishing | [Previews and draft preview](/v7/preview#draft-preview) | diff --git a/docs/content/v7/internals.mdx b/docs/content/v7/internals.mdx new file mode 100644 index 00000000..5d0b36ae --- /dev/null +++ b/docs/content/v7/internals.mdx @@ -0,0 +1,83 @@ +--- +title: How v7 is built +nav: Overview +group: Under the hood +kind: internals +order: 1 +description: Why v7 ships as separate packages of plain TypeScript source, how runtime state stays single, and which entry points are safe for the browser. +--- + +# How v7 is built + +You don't need this page to build a site. It explains the design decisions behind the packages: why they ship source instead of bundles, how the runtime keeps exactly one copy of its state, and why some imports are server-only. Knowing them helps when an error mentions a duplicate registry, `node:async_hooks` in a browser bundle, or an import that works on the server but not in a client component. + +## Separate packages, one direction + +v7 is five framework packages plus the companion apps: + +| Package | Role | Depends on | +|---|---|---| +| `@decocms/blocks` | The runtime: content, resolution, registries, SDK | no other Deco package | +| `@decocms/blocks-admin` | The admin protocol Studio talks to | `blocks` | +| `@decocms/blocks-cli` | Code generation and migration tools | `blocks` | +| `@decocms/tanstack` | The TanStack Start and Cloudflare Workers binding | `blocks`, `blocks-admin`, `blocks-cli` | +| `@decocms/nextjs` | The Next.js App Router binding | `blocks`, `blocks-admin` | + +Dependencies point one way. The runtime never imports a binding, and the two bindings never import each other. That keeps the runtime free of framework code (no TanStack or Next.js types in `@decocms/blocks`), so both bindings share one implementation of resolution, caching helpers and the admin protocol. + +When a feature needs something from both sides, it's split rather than given a dependency in the wrong direction. Setup is the example you've already met: `createSiteSetup` in `@decocms/blocks/setup` takes the runtime options, and `createAdminSetup` in `@decocms/blocks-admin/setup` takes the admin ones. + +## Source, not bundles + +Each package's `exports` map points every public path at a TypeScript source file, for example `@decocms/blocks/cms` at the `cms` folder's index file. There is no build step in the packages and no `dist/`. Your bundler (Vite, or Next's) compiles the package source together with your own code. + +This is deliberate. An earlier single-package version of the framework shipped a bundled build, and the bundler split shared modules into several output files. A module such as the section registry could then exist twice in the same server, and a section registered through one copy was invisible to code reading the other. Shipping source means each module is compiled once, by your bundler, as part of your app. + +## One copy of the runtime's state + +Even with source exports, a module can occasionally be evaluated more than once: some builds load server functions as separate chunks, for example. So the runtime doesn't keep its state in module variables. It keeps it on a single object, `globalThis.__deco`, which every copy of a module reads and writes: + +- the section registry, section options and synchronously registered sections, +- commerce loaders, custom matchers and section loaders, +- the layout, cacheable, SEO, eager and deferred section sets, +- the async rendering configuration, +- the loaded decofile and its revision. + +Each module creates its keys on first load if they don't exist yet and otherwise uses the ones already there. Setup can therefore run in any module copy and every other copy sees the result. Treat `globalThis.__deco` as private: read and change this state through the exported functions, never directly. + +## Request-scoped state and conditional exports + +`RequestContext` (see [Request context](/v7/request-context)) is built on `AsyncLocalStorage` from `node:async_hooks`, which exists on servers but not in browsers. Its storage is published as `@decocms/blocks/sdk/requestContextStorage` with conditional exports: + +```json title="@decocms/blocks package.json (excerpt)" +"./sdk/requestContextStorage": { + "workerd": "./src/sdk/requestContextStorage.ts", + "node": "./src/sdk/requestContextStorage.ts", + "browser": "./src/sdk/requestContextStorage.browser.ts", + "default": "./src/sdk/requestContextStorage.ts" +} +``` + +A bundler picks the first condition in the list that the build target has. Browser builds get a stub with the same shape that never holds a request. Server builds get the real implementation. + +The order matters. A Cloudflare Workers build activates `workerd`, `worker` and `browser` at once. If `browser` came first, a Workers deploy would get the stub, and cookies, abort signals and device detection would silently stop working in production with no build error. That's why `workerd` and `node` are listed before `browser`. + +## Server-only and client-safe entry points + +Some entry points pull in server-only modules (`node:async_hooks`, `node:fs/promises`). Importing any export from them in browser code drags the whole module graph along, because modules are evaluated per file, not per export. + +| Import | Safe in browser code | Use it for | +|---|---|---| +| `@decocms/blocks/cms` | No | Setup, loaders, resolution, registration: server code. | +| `@decocms/blocks/cms/client` | Yes | Section registry lookups (`getResolvedComponent`, `registerSection`), section loader mixins, schema helpers, `getDeferredTrigger`. | +| `@decocms/blocks/sdk/requestContext` | Yes | Request-scoped state. In the browser every accessor behaves as outside a request. | +| `@decocms/nextjs/routeHandlers` | Server only | The admin route handlers. The package root also exports client components, which a route handler must not import. | +| `@decocms/nextjs` | Mixed | Server pages and layouts. Client component files import only what they render. | + +On TanStack Start, `src/setup.ts` imports from `@decocms/blocks/cms` and still runs in the browser, because the router imports it. That works because the Vite plugin from `@decocms/tanstack/vite` replaces server-only modules with small stubs in the client build. Next.js has no such step and rejects those imports in client components, which is why `@decocms/blocks/cms/client` exists. Use it from any Client Component. + +## Next steps + +- [How resolution works](/v7/walkthrough): what the runtime does with a page's content, step by step. +- [The Worker request pipeline](/v7/request-pipeline): everything `createDecoWorkerEntry` does with a request. +- [Packages and exports](/v7/packages): every public import path. diff --git a/docs/content/v7/loaders.mdx b/docs/content/v7/loaders.mdx new file mode 100644 index 00000000..5d6aea38 --- /dev/null +++ b/docs/content/v7/loaders.mdx @@ -0,0 +1,237 @@ +--- +title: Loaders and actions +nav: Loaders & actions +group: Core concepts +order: 9 +description: The three kinds of server functions in a v7 site (loaders called from content, section loaders, and site loaders and actions) and how to call them over /deco/invoke. +--- + +# Loaders and actions + +Sections render props, but most real props come from somewhere: a product search, the visitor's device, the logged-in user. In v7 that work happens in server functions. A *loader* fetches data and an *action* changes something. This page covers the three places they plug in (content, sections, and your own `src/loaders` folder), how to call them from the browser, and how to cache them. + +<Terms> + <Term name="Loader">A server function that fetches data. Content can call it by key, or it can be attached to a section. See the [glossary](/v7/glossary#loader).</Term> + <Term name="Section loader">A server function that runs after resolution and before render, enriching one section's props. See the [glossary](/v7/glossary#section-loader).</Term> + <Term name="Action">A server function that changes something, such as adding to a cart or subscribing to a newsletter. See the [glossary](/v7/glossary#action).</Term> + <Term name="Invoke">Calling a loader or action by key over HTTP at `/deco/invoke/<key>`. See the [glossary](/v7/glossary#invoke).</Term> +</Terms> + +## Where each kind runs + +<Flow label="Where loaders run in a request"> + <FlowNode title="Resolution">Loaders named in content run as their values are resolved</FlowNode> + <FlowNode title="Section loaders">Each section's loader enriches its resolved props</FlowNode> + <FlowNode title="Render">The section renders with the final props</FlowNode> + <FlowNode title="In the browser">Loaders and actions called by key through `/deco/invoke`</FlowNode> +</Flow> + +| Kind | Who chooses it | Registered with | Typical use | +|---|---|---|---| +| Loader in content | The editor, in Studio | `registerCommerceLoaders`, with maps that apps provide | A shelf's products, a page's product details | +| Section loader | The developer, per section | `registerSectionLoaders` | Device, search params, data every instance of a section needs | +| Site loader or action | Either | `registerCommerceLoaders`, with the `siteLoaders` map `generate` writes from `src/loaders` and `src/actions` | Your own data sources and mutations | + +Despite its name, `registerCommerceLoaders` registers every loader content can call, commerce or not. + +## Loaders in content + +When a prop's value has a `__resolveType` that names a loader, resolution calls the loader and puts its result in the prop: + +```json title="A shelf whose products come from a loader" +{ + "__resolveType": "site/sections/ProductShelf.tsx", + "title": "Best sellers", + "products": { + "__resolveType": "vtex/loaders/intelligentSearch/productList.ts", + "props": { "query": "summer", "count": 12 } + } +} +``` + +The section sees `products` as the loader's return value, never the JSON above. Editors pick and configure the loader in Studio, which shows its props as a form. + +Loaders are found by key in a registry. Apps register theirs for you (for VTEX, `createVtexCommerceLoaders()`; see [VTEX](/v7/vtex)), and you can register any function with `registerCommerceLoaders` from `@decocms/blocks/cms`: + +```ts title="src/setup/commerce-init.ts" +import { registerCommerceLoaders } from "@decocms/blocks/cms"; +import { setInvokeLoaders } from "@decocms/blocks-admin"; +import { createVtexCommerceLoaders } from "@decocms/apps-vtex/commerceLoaders"; +import { siteLoaders } from "../../.deco/loaders.gen"; + +const COMMERCE_LOADERS = { + ...createVtexCommerceLoaders(), + ...siteLoaders, +}; + +registerCommerceLoaders(COMMERCE_LOADERS); +setInvokeLoaders(() => COMMERCE_LOADERS); +``` + +`registerCommerceLoaders` makes the loaders resolvable from content, and `setInvokeLoaders` makes the same map callable through `/deco/invoke`. On TanStack Start, import this file from `src/worker-entry.ts`, not from `src/setup.ts`, so the loaders and anything they import stay on the server. On Next.js, make the same two calls inside `createNextSetup`'s `extend` option, which runs after the core setup. + +Before a loader runs, the runtime prepares its props: + +- **Nested values are resolved first,** so a prop can itself be a route param or another loader's result. +- **Page context is injected.** The loader gets `__pagePath` (the page's path) and `__pageUrl` (its full URL, with tracking parameters such as `utm_*` and `gclid` removed). +- **URL search params fill gaps.** Each search param of the page URL is copied into the loader's props unless the content already set a prop of that name, so `?sort=price:asc` reaches a listing loader. `count` and `pageOffset` are converted to numbers. `page` is skipped, because listing loaders read it from `__pageUrl` themselves. + +If a loader throws, the error goes to your `onResolveError` handler (see `createSiteSetup`) and the prop becomes `null`. On TanStack Start the page is also marked *degraded*: it's still rendered, but it's served with an `X-Deco-Degraded` header so the edge cache doesn't store the broken version. A section loader that throws degrades the page the same way, and the section renders with its unenriched props. If some data is decorative and the page is fine without it, register its key (the loader key, or the section key for a section loader) with `registerNonCriticalSections`, and its failures no longer degrade the page. + +### Reuse a saved loader + +A loader call saved as its own named block, such as `Category listing`, can be referenced from several places on a page, for example the product grid and the SEO block. Within one page render it runs once, and every reference gets the same result. That applies only to references with identical JSON (no differing overrides). It doesn't apply to inline loader calls (an identical inline call runs again unless the loader is [cached](#cache-a-loader)), or to deferred sections, which run the loader again when they load. + +## Section loaders + +A section loader belongs to a section rather than to content: it runs for every instance of that section, after resolution and before render. Write it as a `loader` export in the section file. It receives the resolved props and the request, and returns the props the component renders: + +```tsx title="src/sections/Product/SearchResult.tsx" +import type { SectionProps } from "@decocms/blocks/types"; + +export interface Props { + /** @title Results per page */ + perPage?: number; +} + +export async function loader(props: Props, req: Request) { + const url = new URL(req.url); + return { ...props, query: url.searchParams.get("q") ?? "" }; +} + +export default function SearchResult({ query }: SectionProps<typeof loader>) { + return <h1>Results for “{query}”</h1>; +} +``` + +`SectionProps<typeof loader>` types the component's props as whatever the loader returns. + +A section's `loader` runs only when it's registered. Register it in setup with `registerSectionLoaders` from `@decocms/blocks/cms`, wrapping it in `withSectionLoader`. Common needs come as *mixins*, small ready-made section loaders, and `compose` chains them from left to right: + +```ts title="src/setup/section-loaders.ts" +import { + compose, + registerSectionLoaders, + withDevice, + withMobile, + withSearchParam, + withSectionLoader, +} from "@decocms/blocks/cms"; + +registerSectionLoaders({ + "site/sections/Product/SearchResult.tsx": compose( + withMobile(), + withSearchParam(), + withSectionLoader(() => import("../sections/Product/SearchResult")), + ), + "site/sections/ProductShelf.tsx": withDevice(), +}); +``` + +| Mixin | Adds to props | +|---|---| +| `withDevice()` | `device`: `"mobile"`, `"tablet"` or `"desktop"`, from the user agent | +| `withMobile()` | `isMobile`: `true` on phones and tablets | +| `withSearchParam()` | `currentSearchParam`: the value of `?q=` | +| `withSectionLoader(() => import(…))` | Whatever the section module's own `loader` returns. Does nothing if it has none, and logs and keeps the props if it throws. | + +<Callout type="warning">**A mixin on its own replaces the section's loader.** If a section exports a `loader` and you register only `withSearchParam()` for it, the section's loader never runs and its props silently go missing. Compose the mixins with `withSectionLoader(...)`, and put `withSectionLoader` last so the section's loader sees the mixins' props and has the final say.</Callout> + +Section loaders also get a third argument, a context object with `device`, an `invoke` proxy for calling other loaders, `response.headers` for setting cookies, and `getAppState(name)` for an app's configuration. + +Nested sections (a `Section` prop) get their section loaders run too. Section loaders can be cached per section with the `cache` and `layout` conventions; see [Section conventions](/v7/sections). + +## Site loaders and actions + +Put your own server functions in `src/loaders/` and `src/actions/`, one default export per file: + +```ts title="src/loaders/storeHours.ts" +export interface Props { + /** @title Store ID */ + storeId: string; +} + +export default async function storeHours(props: Props) { + const res = await fetch(`https://api.example.com/stores/${props.storeId}/hours`); + return (await res.json()) as { open: string; close: string }; +} +``` + +`generate` registers every such file in `.deco/loaders.gen.ts` under `site/loaders/<path>` (here `site/loaders/storeHours.ts`, also reachable without `.ts`). Spread `siteLoaders` into the map you register, as in the commerce-init example above, and the loader becomes available both to content (editors can pick it in Studio, with a form built from `Props`) and through invoke. Actions in `src/actions/` work the same way under `site/actions/<path>`. + +A loader file can also export `cache = "stale-while-revalidate"` so that identical calls running at the same time (several sections on one page asking for the same data, say) share one upstream call, and `cacheKey(props, req)` to say what makes two calls identical. Nothing is kept after the call settles; for a cache that lasts across requests, see [Cache a loader](#cache-a-loader). + +## Call loaders and actions over HTTP + +Loaders and actions are called by key at `/deco/invoke/<key>`, with their props as a JSON body. Use `POST`: + +```bash +curl -s -X POST http://localhost:5173/deco/invoke/site/loaders/storeHours.ts -H "Content-Type: application/json" -d '{"storeId":"42"}' +``` + +| Form | Request | Response | +|---|---|---| +| Single call | `POST /deco/invoke/<key>` with the props as the body (JSON, form data or URL-encoded) | The result as JSON | +| Batch | `POST /deco/invoke` with `{ "<name>": { "__resolveType": "<key>", …props }, … }`, or `{ "<key>": props, … }` | `{ "<name>": result \| { "error": … }, … }` | +| Field selection | `?select=name,offers` | Only those fields of the result (applied to each item of an array) | + +An unknown key answers 404, a handler that throws answers 500. On TanStack Start, headers a handler sets on `RequestContext.responseHeaders`, such as `Set-Cookie`, are copied onto the invoke response; see [Request context](/v7/request-context). + +From browser code, use the `invoke` proxy from `@decocms/blocks/sdk/invoke`, which turns the key into a property path: + +```ts title="src/components/StoreHours.tsx (excerpt)" +import { invoke } from "@decocms/blocks/sdk/invoke"; + +const hours = await invoke.site.loaders.storeHours({ storeId: "42" }); +``` + +With TanStack Query, `invokeQueryOptions(key, props)` from the same module returns ready-made query options. + +To make several calls in one request, use `batchInvoke` from the same module. Each entry names its loader in `__resolveType`, and each result comes back under the entry's name: + +```ts +import { batchInvoke } from "@decocms/blocks/sdk/invoke"; + +const { hours, brands } = await batchInvoke("/deco/invoke", { + hours: { __resolveType: "site/loaders/storeHours.ts", storeId: "42" }, + brands: { __resolveType: "site/loaders/featuredBrands.ts" }, +}); +``` + +An entry without `__resolveType` uses its name as the loader key. `batchInvoke` throws when the response isn't OK; a single call that fails comes back as `{ "error": … }` under its name, without failing the others. + +On TanStack Start sites with `@decocms/apps-vtex` installed, `generate` also writes `src/server/invoke.gen.ts`: typed TanStack server functions for the app's actions (adding to cart, updating the session), which client hooks such as the VTEX cart use. See [VTEX](/v7/vtex). + +## Cache a loader + +Loaders called on every page view can share results across requests with `createCachedLoader` from `@decocms/blocks/sdk/cachedLoader`. Give it a name, the loader and a cache profile (or explicit options): + +```ts title="src/setup/commerce-loaders.ts (excerpt)" +import { createCachedLoader } from "@decocms/blocks/sdk/cachedLoader"; +import storeHours from "../loaders/storeHours"; + +export const cachedStoreHours = createCachedLoader("site/loaders/storeHours.ts", storeHours, "static"); +``` + +| Option | Type | Default | What it does | +|---|---|---|---| +| `policy` | `"stale-while-revalidate" \| "no-cache" \| "no-store"` | required | `no-store` returns the loader unchanged. | +| `maxAge` | `number` (ms) | `60_000` | How long a result is fresh. | +| `staleWhileRevalidate` | `number` (ms) | `300_000` | How long a stale result is served while a fresh one loads. | +| `staleIfError` | `number` (ms) | `0` | How long a stale result is served when the loader fails. | +| `keyFn` | `(props) => string` | `JSON.stringify` | What makes two calls the same entry. | + +Passing a profile name (`"static"`, `"product"`, `"listing"`, `"search"`) uses that profile's loader timings. The cache is in memory per instance, capped in size, and turned off in development; [Caching](/v7/caching) covers profiles and shared storage. Apps already cache their own catalog loaders. + +<Callout type="warning"> + +**Don't cache visitor-specific loaders by props alone.** The cache key is the loader's name plus `keyFn(props)`. A loader that reads the visitor from the request (cookies, a session) instead of from its props shares one entry with every visitor, so one shopper's wishlist is served to the next. Leave those loaders uncached, or put the visitor's id in the props or in `keyFn`. + +</Callout> + +## Next steps + +- [Matchers and variants](/v7/variants): choose content per visitor. +- [Section conventions](/v7/sections): `cache` and `layout` for section loaders. +- [Request context](/v7/request-context): the request, abort signal and response headers inside a loader. +- [Apps](/v7/apps): the loaders and actions each app brings. diff --git a/docs/content/v7/magento.mdx b/docs/content/v7/magento.mdx new file mode 100644 index 00000000..056b80f4 --- /dev/null +++ b/docs/content/v7/magento.mdx @@ -0,0 +1,108 @@ +--- +title: Magento +group: Apps +order: 31 +description: "The partial Magento app: configuration, cart, user and wishlist loaders, a few actions, a shared response cache and an instrumented fetch." +--- + +# Magento + +`@decocms/apps-magento` is the start of a Magento integration. It gives you a configured, authenticated client for Magento's REST and GraphQL APIs, loaders for the visitor's cart, user and wishlist, actions for newsletter sign-up, stock alerts and the wishlist, and the shared caching and observability plumbing. You write the catalog loaders yourself on top of it. + +<Callout> + +**Partial.** This is an initial port. Not available yet: product page, listing, shelf and related-product loaders; cart actions (add, update, remove, coupons, simulation); checkout proxying; React hooks. The app also has no registry entry, so it can't be installed with `autoconfigApps`: configure it as shown below. Its exports map lists a `./hooks/*` path, but there are no hooks behind it. + +</Callout> + +```bash +bun add @decocms/apps-magento @decocms/apps-commerce +``` + +## Configuring + +The app reads a block keyed `magento`, with connection settings under `apiConfig`: + +| Field | What it does | +|---|---| +| `apiConfig.baseUrl` | Your Magento base URL, such as `https://store.example.com/`. | +| `apiConfig.apiKey` | The API token, sent as `Authorization: Bearer`. Plain text, or a secret block: its encrypted value is decrypted with `DECO_CRYPTO_KEY`, and the environment variable named by its `name` field is the fallback. | +| `apiConfig.storeId` | The store id; `1` by default. | +| `apiConfig.site` | The store code used in REST paths, as in `/rest/<site>/V1/...`. | +| `apiConfig.storeHeader`, `apiConfig.originHeader`, `apiConfig.currencyCode`, `apiConfig.useSuffix` | Optional store header, origin header (a secret, sent as `x-origin-header`), currency, and URL-suffix handling. | +| `features`, `cartConfigs`, `imagesConfig`, `pricingConfig` | Feature switches and cart, image and installment settings, available to your code through `getMagentoConfig()`. | + +### Waiting for configuration + +`initMagentoFromBlocks(blocks)` is asynchronous, because it may need to decrypt secrets. `createSiteSetup`'s `initPlatform` doesn't wait for promises, so if you start it there, a request can arrive before it finishes and fail with `configureMagento() must be called before loaders run`. Await it instead, in a server-only module your server entry imports: + +```ts title="src/setup/magento.ts" +import { loadBlocks, onChange } from "@decocms/blocks/cms"; +import { initMagentoFromBlocks, setMagentoFetch, createMagentoFetch } from "@decocms/apps-magento"; + +setMagentoFetch(createMagentoFetch()); + +await initMagentoFromBlocks(loadBlocks()); + +// Pick up settings an editor publishes later. +onChange((blocks) => { + void initMagentoFromBlocks(blocks); +}); +``` + +Secrets are resolved when that code runs. On Cloudflare Workers, environment values are visible at module scope only through `process.env` with the `nodejs_compat` flag; see [Apps](/v7/apps#secrets) for the lookup order. + +If you'd rather not depend on the block, call `configureMagento` synchronously with the values: + +```ts title="src/setup/magento.ts" +import { configureMagento } from "@decocms/apps-magento"; + +configureMagento({ + baseUrl: "https://store.example.com/", + apiKey: process.env.MAGENTO_API_KEY ?? "", + storeId: 1, + site: "default", +}); +``` + +## Calling Magento + +`magentoFetch(path, init?)` is the client every loader uses, and the one to use in your own loaders. Relative paths resolve against `baseUrl`. For requests to your Magento origin it adds the `Authorization` header (skip it with `authenticated: false`), the origin header and a `Referer`. Requests to any other origin get none of them, so your credentials only ever go to your own store. + +For reads that can be shared between visitors, wrap the call in `magentoCachedFetch(cacheKey, doFetch)`. It's the framework's shared stale-while-revalidate cache: successful responses stay fresh for three minutes, 404s for ten seconds, server errors aren't cached, and a stale copy is served for up to a day if Magento errors. Concurrent identical requests are merged. It returns the parsed JSON, or `null` for a cacheable non-2xx response. Encode any value that comes from the visitor before putting it in a path, as the example does. + +```ts title="src/loaders/magento/category.ts" +import { magentoCachedFetch, magentoFetch, getMagentoConfig } from "@decocms/apps-magento"; + +export default async function category(props: { id: string }) { + const { site } = getMagentoConfig(); + const path = `/rest/${encodeURIComponent(site)}/V1/categories/${encodeURIComponent(props.id)}`; + return magentoCachedFetch(path, () => magentoFetch(path)); +} +``` + +Call `setMagentoFetch(createMagentoFetch())` at module scope, as above, to measure and trace every call; without it, calls use a plain fetch with a timeout. See [Observability](/v7/observability). + +## Loaders and actions + +These are plain functions. Register the ones your content uses with `registerCommerceLoaders` (see [Loaders and actions](/v7/loaders)). + +| Function | Import | What it does | +|---|---|---| +| `cart(props, request)` | `@decocms/apps-magento/loaders/cart` | The visitor's cart, from the `dataservices_cart_id` cookie. `null` if there's no cart. | +| `user(props, request)` | `@decocms/apps-magento/loaders/user` | The signed-in customer, from the `PHPSESSID` session cookie, or `null`. | +| `wishlist(props, request)` | `@decocms/apps-magento/loaders/wishlist` | The customer's wishlist. | +| `features()` | `@decocms/apps-magento/loaders/features` | The `features` switches from the block. | +| `subscribe({ email })` | `@decocms/apps-magento/actions/newsletter/subscribe` | Newsletter sign-up. | +| `stockAlert({ product_id, name, email })` | `@decocms/apps-magento/actions/product/stockAlert` | Registers a back-in-stock alert. | +| `addItem({ productId }, request)`, `removeItem({ productId }, request)` | `@decocms/apps-magento/actions/wishlist/addItem`, `.../removeItem` | Wishlist changes. Need the `PHPSESSID` and `form_key` cookies. For `removeItem`, `productId` is the wishlist item's id, not the product's. | + +The session loaders take the request as their second argument. When you register them as commerce loaders, read it from [request context](/v7/request-context) and pass it on. + +`@decocms/apps-magento/utils/*` also exposes helpers for building GraphQL filters and sort orders from URLs, mapping Magento products to the shared [commerce types](/v7/apps-commerce) (`toProduct`, `toBreadcrumbList`, `toSeo`), and ignoring tracking parameters in cache keys. + +## Related + +- [Apps](/v7/apps): the app contract and secrets. +- [Loaders and actions](/v7/loaders): registering your own loaders. +- [Caching](/v7/caching): the cache layers. diff --git a/docs/content/v7/migrate-from-fresh.mdx b/docs/content/v7/migrate-from-fresh.mdx new file mode 100644 index 00000000..88c2fbc7 --- /dev/null +++ b/docs/content/v7/migrate-from-fresh.mdx @@ -0,0 +1,184 @@ +--- +title: Migrating from Fresh and Deno +nav: From Fresh/Deno +group: Upgrading +order: 42 +description: Move a Deco storefront from Fresh, Preact and Deno to TanStack Start, React 19 and Cloudflare Workers with the deco-migrate tooling. +--- + +# Migrating from Fresh and Deno + +Deco storefronts built before v7 run on Fresh (a Deno web framework) with Preact components, islands for interactivity and HTMX for partial page updates. v7 runs the same content on TanStack Start with React 19 on Cloudflare Workers. `@decocms/blocks-cli` ships a migrator, `deco-migrate`, that does most of the mechanical conversion, plus tools to inspect the site before and audit it after. This page explains what changes, how to run the tools, and what you finish by hand. + +Your content (`.deco/blocks/`) carries over unchanged: page blocks, section props and app configuration keep the same shape. + +## What changes + +| Fresh/Deno site | v7 site | +|---|---| +| Preact components | React 19 components. `class` becomes `className`, `for` becomes `htmlFor`, `ComponentChildren` becomes `ReactNode`. | +| Islands (selectively hydrated components) | Full server rendering followed by React hydration. Code splitting through `React.lazy` and Suspense. | +| HTMX partials (`hx-get`, `f-partial`) | React state and TanStack Router navigation. There's no HTMX runtime in v7; every `hx-*` use is rewritten. | +| Deno, import maps | Node-compatible code on Cloudflare Workers (with `nodejs_compat`), npm packages. | +| `@preact/signals` | A small signal backed by a TanStack store; components subscribe with `useStore`. | +| Sections that export `loader` and `action` | Sections are React components; their `loader` export still runs, through [section loaders](/v7/loaders). Commerce data comes from app loaders registered at setup. | +| A global `fetch` patched with the request's abort signal | Explicit `RequestContext.fetch` and instrumented fetches. | +| `deco-cx/apps` imports | `@decocms/apps-*` packages. Forks of the apps aren't supported; site-specific changes live in your own code. | +| Tailwind v3, DaisyUI v4 | Tailwind v4, DaisyUI v5. | +| `useMemo`, `useCallback` and `memo` for performance | The scaffolded `vite.config.ts` turns on the [React Compiler](https://react.dev/learn/react-compiler), which memoizes components for you, so you rarely write these by hand. Don't add new ones; leave the existing ones in place unless you test removing them (see [The Vite plugin](/v7/tanstack#react-compiler)). | + +## Before you start: take inventory + +Run the HTMX analyzer on the Fresh site. It's read-only and lists every `hx-*` attribute by kind and by file, so you know how much interactive behaviour must be rewritten by hand: + +```bash +npx -p @decocms/blocks-cli deco-htmx-analyze --source ../my-store-fresh +``` + +`--json` prints machine-readable output; `--top <n>` changes how many files it ranks (default 20). + +It also helps to count what you're migrating, so you can compare it with the report's *Section Analysis*, *Island Elimination* and *Loader Inventory* afterwards. From the Fresh site's root (if your sections live under `src/`, prefix the paths): + +```bash +for d in sections islands loaders actions; do echo "$d: $(find $d -type f \( -name '*.ts' -o -name '*.tsx' \) 2>/dev/null | wc -l)"; done +ls .deco/blocks/*.json | wc -l +``` + +## Run the migrator + +`deco-migrate` converts the site **in place**: it writes the new files into the source directory and deletes the old ones. Run it on a fresh branch or a copy of the repository, never on your only checkout. + +<Steps> +<Step> + +**Preview.** From the Fresh site's root, run a dry run to see every file it would write, change or delete: + +```bash +npx -p @decocms/blocks-cli deco-migrate --dry-run --verbose +``` + +</Step> +<Step> + +**Migrate.** + +```bash +npx -p @decocms/blocks-cli deco-migrate +``` + +Use `--source <dir>` to migrate another directory. For CI, add `--strict` (fail when type-checking reports errors) and `--with-build` (also run `vite build`). + +</Step> +<Step> + +**Read `MIGRATION_REPORT.md`.** The migrator writes it at the site root. It lists: + +- the files it scaffolded, transformed, deleted and moved +- **Manual Review Required**: items marked as errors, warnings or notes +- **Section Analysis**: how many sections have loaders (moved to `src/setup/section-loaders.ts`), and which it treats as layout or listing sections +- **Island Elimination**: wrapper islands are deleted and their imports repointed; standalone islands move to `src/components/` +- **Loader Inventory**: how many loaders map to `@decocms/apps-*` and which custom ones were placed in `src/setup/commerce-loaders.ts` +- **CSS Migration**, an **Always Check** list, **Known Issues** and **Next Steps** + +</Step> +</Steps> + +### What it does + +The migrator runs in phases: + +1. **Analyze.** Detects the layout (`sections/` at the root or under `src/`), the commerce platform, and patterns that need attention. A mixed or empty layout stops the run. +2. **Scaffold.** Writes the TanStack Start project: `package.json`, `tsconfig.json`, `vite.config.ts`, `wrangler.jsonc`, `src/setup.ts`, the routes (catch-all, home and admin routes), `src/server.ts`, `src/worker-entry.ts`, commerce loader wiring and styles. +3. **Transform.** Rewrites imports, JSX attributes, Fresh APIs, Deno-specific code, Tailwind classes and DaisyUI themes. +4. **Clean up.** Deletes Fresh artifacts (`islands/`, `routes/`, `deno.json`, `fresh.gen.ts` …) and moves `static/` to `public/`. +5. **Report.** Writes `MIGRATION_REPORT.md`. +6. **Verify.** Runs smoke checks on the output. +7. **Bootstrap.** Installs dependencies with Bun and runs the generators. +8. **Compile.** Type-checks, and builds with `--with-build`. `--no-compile` skips it. +9. **Audit.** Runs the post-migration cleanup audit (below). `--no-cleanup-audit` skips it. + +The run stops with exit code 2 if the source layout is *mixed* (both `sections/` and `src/sections/` exist, usually because a migration already ran partly on this checkout: restore it with git and start again) or *empty* (none of the expected directories exist, so `--source` points at the wrong folder). It also exits with 2 when the verify phase fails, or, with `--strict`, when the compile or the cleanup audit reports problems. + +Imports are rewritten to the v7 packages, for example `apps/commerce/types.ts` to `@decocms/apps-commerce/types`, `apps/vtex/…` to `@decocms/apps-vtex/…`, `apps/admin/widgets.ts` to `@decocms/blocks/types/widgets`, `@deco/deco/hooks` to `@decocms/blocks/sdk/useScript` and `@decocms/blocks/sdk/useDevice`, and `preact` to `react`. + +### Tune section conventions + +The migrator marks some sections with [section conventions](/v7/sections) based on their names, for example rendering a header synchronously or caching a shelf's loader. To adjust the lists for your site, add `.deco-migrate.config.json` at the source root: + +```json title=".deco-migrate.config.json" +{ + "sectionConventions": { + "extend": { + "eagerSync": ["Header"], + "sync": ["Hero"], + "listingCache": ["ProductShelf"], + "staticCache": ["Footer"] + } + } +} +``` + +Entries are section file names without the extension. `extend` adds to the built-in lists; `replace` uses only yours. `eagerSync` renders the section eagerly and bundles it synchronously, `sync` bundles it synchronously, `listingCache` and `staticCache` cache its loader with the `listing` or `static` profile. + +## What you finish by hand + +The migrator gets the project building; these need judgement: + +- **Platform hooks.** `useCart`, `useUser` and `useWishlist` must be wired to the app's hooks. For VTEX the migrator generates thin wrappers around the app's factories; check them against your components (see [VTEX](/v7/vtex)). +- **Interactive behaviour from HTMX and islands.** Rewrite each `hx-*` interaction as React state, a server function or an invoke call (see [Loaders and actions](/v7/loaders)). +- **Matchers.** Custom matchers move to `registerMatcher(key, (rule, ctx) => boolean)`, and code that used `MatchContext` uses the `MatcherContext` fields instead (see [Matchers and variants](/v7/variants)). +- **Deferred sections.** Check that sections editors marked async have a `LoadingFallback` with the final dimensions, and that cacheable sections are marked (see [Deferred sections](/v7/rendering)). +- **Search and other site-specific loaders.** +- **Signals.** Wherever a component reads `signal.value` during render, subscribe with `useStore(signal.store)` from `@tanstack/react-store`, or it won't re-render. + +Copy components over faithfully rather than rewriting them; fix only what the new runtime requires. + +### Tailwind v4 pitfalls + +- Tailwind v4 emits `px-*` as logical properties. An element that mixes `px-*` with `pl-*` or `pr-*` can resolve differently than before; use one style per element. +- DaisyUI v5 theme variables are OKLCH components, not hex colors. +- **Negative z-index.** The migrator turns `-z-{n}` into `z-0` on images, but a background layer with `-z-10` that isn't an image can disappear behind its parent when that parent creates a stacking context (an animation, transform or filter does). Use `z-0` on the background and `relative z-10` on the content. `grep -rn -- '-z-' src/` finds what's left. +- **Opacity utilities.** `bg-opacity-*` and `text-opacity-*` were removed in v4. The migrator rewrites them to the slash form (`bg-black/20`), but flags cases where the color and the opacity are in different class strings, for you to fix. + +The compile phase runs a real Tailwind compile of `src/styles/app.css`. To run that check again, use `npx @tailwindcss/cli -i src/styles/app.css -o /dev/null`. A separate class linter (breakpoint order, arbitrary values, v3 class renames) ships with the CLI: `npx tsx node_modules/@decocms/blocks-cli/scripts/tailwind-lint.ts`, with `--fix` to apply its fixes. + +## Audit the result + +`deco-post-cleanup` scans the migrated site for dead code and boilerplate the framework now provides (local shims, stale widget types, unused runtime files): + +```bash +npx -p @decocms/blocks-cli deco-post-cleanup +``` + +`--fix` applies the fixes that are safe to automate; `--json` prints machine-readable output; `--strict` exits with code 2 when there are warnings, for CI. Run it until it's clean, then run the site on the production build (`bun run preview`) and check real pages in a browser before deploying. Then work through [Going live after a migration](/v7/migration-cutover). + +## Changes that land on the old site after the cut + +A migration takes time, and the Fresh site keeps changing meanwhile. Re-running `deco-migrate` would overwrite your hand fixes. `deco-reconcile` instead produces one patch per file changed on the old site since the migration, for you to port one at a time: + +```bash +npx -p @decocms/blocks-cli deco-reconcile --source ../my-store-fresh --target ../my-store --snapshot <cut sha> +``` + +- `--snapshot` is the last source commit already migrated (the cut). +- `--target-snapshot` is the migration commit on the new site; it defaults to the commit that added `MIGRATION_REPORT.md`. Commits after it count as hand fixes, and the output flags files touched on both sides. +- `--out` sets the output directory (default `<target>/.reconcile/<source head>`). + +It writes nothing to the target except the output directory: `INDEX.md` for you, `manifest.json` (which also records which patches are done), and `patches/NNN-<file>.patch`. Content under `.deco/`, CI workflows, lockfiles and binary assets are skipped. To keep `.deco/blocks/` in sync while editors still publish to the old site, use `deco-sync-blocks-bot` (see [Deploying and Fast Deploy](/v7/releases#keeping-deco-blocks-in-sync-with-production)). To move shoppers to the new site a share at a time while the old one still runs, see [Shift traffic gradually](/v7/migration-cutover#shift-traffic-gradually). + +## Not available in v7 + +These capabilities of the Fresh-era framework have no v7 equivalent: + +- 103 Early Hints responses. +- A section-level `transformProps` hook. Use a [section loader](/v7/loaders) instead. +- The `runOnce`/release resolver and resolve-chain tracing. +- Some admin widget types (select, checkbox and radio groups, date picker, number range, dynamic and custom widgets). The supported ones are listed in [Schema generation](/v7/schema). +- Live preview updates pushed over a WebSocket. Studio refreshes the preview instead. + +## Related + +- [Going live after a migration](/v7/migration-cutover): checks, parity and a gradual traffic split. +- [Upgrading from @decocms/start 6.x](/v7/upgrade-from-start): if the site is already on TanStack Start. +- [CLI reference](/v7/cli): every flag of these tools. +- [Troubleshooting](/v7/troubleshooting) diff --git a/docs/content/v7/migration-cutover.mdx b/docs/content/v7/migration-cutover.mdx new file mode 100644 index 00000000..64cc20ad --- /dev/null +++ b/docs/content/v7/migration-cutover.mdx @@ -0,0 +1,150 @@ +--- +title: Going live after a migration +nav: "Fresh: going live" +group: Upgrading +order: 42.5 +description: Check a migrated storefront against the live Fresh site and move traffic to it gradually. +--- + +# Going live after a migration + +`deco-migrate` gets a Fresh storefront building on TanStack Start, but a site that builds isn't yet one you can send shoppers to. This page covers what to check before you merge, how to compare the new site with the live one, and how to shift traffic to it gradually, with a way back. + +<Terms> + <Term name="Original site">The Fresh/Deno storefront that serves shoppers today.</Term> + <Term name="Candidate">The migrated site, usually a pull request preview.</Term> + <Term name="Bucket">The group a visitor is assigned to during a traffic split: `worker` (the new site) or `fallback` (the original).</Term> +</Terms> + +## Before you merge + +<Steps> +<Step> + +**Work through the report.** Every item under *Manual Review Required* and *Always Check* in `MIGRATION_REPORT.md` needs a decision, and the hooks, loader mappings and other items in [What you finish by hand](/v7/migrate-from-fresh#what-you-finish-by-hand) must be done. Review the CSP, the proxy and `buildSegment` in `src/worker-entry.ts`. Then run `bun run generate`. + +</Step> +<Step> + +**Run the audit until it's clean.** + +```bash +npx -p @decocms/blocks-cli deco-post-cleanup +``` + +</Step> +<Step> + +**Check that Studio can edit the site.** With the dev server running, `curl -s http://localhost:5173/live/_meta` returns JSON, and an edit in Studio re-renders the preview. See [Deco Studio and the admin protocol](/v7/studio). + +</Step> +<Step> + +**Check `wrangler.jsonc`.** `compatibility_flags` must include `nodejs_compat` and `no_handle_cross_request_promise_resolution`. See [wrangler.jsonc](/v7/tanstack#wrangler-jsonc). + +</Step> +<Step> + +**Walk the main journeys by hand, on the production build** (`bun run preview`): + +- home → category → product → add to cart → checkout +- search → result → product +- sign in → account → sign out +- all of the above on a phone-sized screen +- a page fetched with a crawler user agent, to confirm its HTML has the full content (see [Eager requests](/v7/rendering#eager-requests)) + +</Step> +<Step> + +**Set secrets and dry-run the deploy.** Set each credential with `npx wrangler secret put <NAME>`, then run `npx wrangler deploy --dry-run`. + +</Step> +</Steps> + +## Compare with the live site + +[`@decocms/parity`](https://www.npmjs.com/package/@decocms/parity) is a command-line tool that loads the same pages and flows on two sites and reports where they differ: UI, SEO tags, console errors, Web Vitals and cache headers. You point `--prod` at the original site, which it treats as the source of truth, and `--cand` at the migrated one. It's in alpha, so expect its options to change. + +```bash +npx @decocms/parity run --prod https://www.example.com --cand https://my-store-pr-12.example.workers.dev --preset smoke +``` + +- **Presets.** `smoke` checks the home page on a mobile viewport in about 30 seconds. `full` runs the purchase journey on mobile and desktop plus visual and Web Vitals checks on several pages, for releases. `ci` is a smaller version of `full`, tuned for pipelines. +- **The purchase journey only.** `npx @decocms/parity journey --prod … --cand … --junit parity-results.xml --github` checks just the journey from home to checkout, step by step, writes a JUnit report and annotates the GitHub Actions run. It's the cheapest check to run on every pull request. +- **AI ranking is optional.** With `ANTHROPIC_API_KEY` or `OPENROUTER_API_KEY` set, or a signed-in local `claude` CLI, it uses a model to rank the issues it finds and to compare screenshots. Without one it still runs the checks and sorts issues by severity. + +Reports are written to `parity-output/`, so add it to `.gitignore`. Run `npx @decocms/parity --help` for the other commands. + +<Callout> + +**Fix the candidate, never the original.** The original is what shoppers see today, and parity measures the migration against it. + +</Callout> + +### The CI workflows the migrator writes + +`deco-migrate` also writes GitHub Actions workflows under `.github/workflows/`: `ci.yml`, `lockfile-check.yml`, `main-push-guard.yml`, `playwright.yml`, `perf.yml`, `react-doctor.yml`, `parity.yml` and `sync-blocks-bot.yml`. Two of them matter for going live: + +| Workflow | What you need to know | +|---|---| +| `parity.yml` | Runs parity's purchase journey on every pull request against the Cloudflare Workers Builds preview. It's advisory: it reports on the pull request and never blocks it. Remove its `continue-on-error: true` line to make it a gate. It does nothing until you set the repository variable `PARITY_PROD_URL` to the original site's URL. The `ANTHROPIC_API_KEY` secret is optional. | +| `sync-blocks-bot.yml` | Once a day, pulls the content that editors still publish on the original site into `.deco/blocks/` and opens a pull request. It does nothing until you set the repository variable `SYNC_BLOCKS_ORIGIN`. See [Keeping `.deco/blocks` in sync with production](/v7/releases#keeping-deco-blocks-in-sync-with-production). | + +## Shift traffic gradually + +During a migration you can serve the new site to a share of visitors and keep sending everyone else to the original. `withABTesting` from `@decocms/blocks/sdk/abTesting` wraps the Worker that `createDecoWorkerEntry` returns. Requests in the `worker` bucket go to your new site. Requests in the `fallback` bucket are proxied to the original, with the original's hostname rewritten to yours in `Location` and `Set-Cookie` headers and in text responses. + +```ts title="src/worker-entry.ts (excerpt)" +import { createDecoWorkerEntry } from "@decocms/tanstack"; +import { withABTesting } from "@decocms/blocks/sdk/abTesting"; + +const decoWorker = createDecoWorkerEntry(serverEntry, { + // …your options +}); + +export default withABTesting(decoWorker, { + shouldBypassAB: (_request, url) => url.pathname.startsWith("/checkout"), +}); +``` + +If your entry wraps the Worker in something else as well, such as `instrumentWorker` for [observability](/v7/observability), keep that as the outermost layer and pass it the result of `withABTesting`. + +The split is configured in a KV store, not in code, so you can change it without a deploy. Bind a [Workers KV](https://developers.cloudflare.com/kv/) namespace as `SITES_KV` and store one entry per hostname, under the hostname as its key: + +```json title="KV key: www.example.com" +{ "fallbackOrigin": "old.example.com", "abTest": { "ratio": 0.1 } } +``` + +- **`ratio`** runs from 0 to 1 and is the share of visitors sent to the new site. Without `abTest`, the ratio is 0 and everyone goes to the original. +- **`fallbackOrigin`** is the original site's hostname, not a URL: no `https://` and no path. +- Entries written by other tools may also contain `workerName`; `withABTesting` ignores it. + +How visitors are assigned: + +- A visitor's bucket comes from a hash of their IP address and is kept in the `_deco_bucket` cookie for a year, so they see the same site on every visit. +- The cookie records the ratio it was assigned under. Change `ratio` and every visitor is assigned again on their next request. +- For testing, `?_deco_bucket=worker` or `?_deco_bucket=fallback` forces a bucket. +- Responses that went through the split carry an `x-deco-bucket` header naming the bucket that served them. +- When the new Worker throws, the request is served from the original instead. +- With no `SITES_KV` binding, or no entry for the hostname, every request goes straight to the new site. So do requests for which `shouldBypassAB` returns `true`. + +| Option | Default | What it does | +|---|---|---| +| `kvBinding` | `"SITES_KV"` | The name of the KV binding to read. | +| `cookieName` | `"_deco_bucket"` | The cookie, and query parameter, that hold the bucket. | +| `cookieMaxAge` | `31536000` (one year) | The cookie's lifetime, in seconds. | +| `circuitBreaker` | `true` | Serve a request from the original when the new Worker throws. | +| `shouldBypassAB(request, url)` | none | Return `true` to always serve a request from the new site, for example commerce paths that must not be proxied. | +| `preHandler(request, url)` | none | Return a `Response` (for example a redirect) to answer before the split, or `null` to continue. | + +<Callout> + +**A migrated VTEX site already has the wrapper.** For VTEX, the migrator wraps your Worker entry with `withABTesting`, sets `shouldBypassAB` to the checkout proxy's paths (except `/login` and `/logout`), and leaves `SITES_KV` out of `wrangler.jsonc` on purpose, so the split does nothing until you add the binding and an entry for your hostname. On other platforms, add the wrapper yourself as shown above. + +</Callout> + +## Next steps + +- [Migrating from Fresh and Deno](/v7/migrate-from-fresh): the migration itself. +- [Deploying and Fast Deploy](/v7/releases): deploy the Worker and publish content without a redeploy. +- [Troubleshooting](/v7/troubleshooting) diff --git a/docs/content/v7/model.mdx b/docs/content/v7/model.mdx new file mode 100644 index 00000000..e776a091 --- /dev/null +++ b/docs/content/v7/model.mdx @@ -0,0 +1,183 @@ +--- +title: Blocks and sections +nav: Blocks & sections +group: Core concepts +order: 6 +description: What a block is, how to write a section that editors can configure, and how sections are registered and nested. +--- + +# Blocks and sections + +Everything an editor decides on a v7 site is stored as a *block*, a named piece of JSON. The most common kind of block points at a *section*, a React component you write. This page explains both: what a block looks like, how to write a section whose props Studio can edit, how sections get registered, and how a section can hold other sections. + +<Terms> + <Term name="Block">A named, typed piece of JSON in the decofile that the runtime can resolve: a section, a page, a loader call, a matcher, an app's settings. See the [glossary](/v7/glossary#block).</Term> + <Term name="Section">A React component editors can place on a page. Its props come from an exported `Props` type. See the [glossary](/v7/glossary#section).</Term> + <Term name="__resolveType">The field that says what a JSON value is: a section key, a loader key, a matcher key, or the name of another block.</Term> + <Term name="Section key">The name content uses for a section: `site/sections/` plus the file's path under `src/sections/`, such as `site/sections/Hero.tsx`.</Term> +</Terms> + +## A block is JSON with a type + +Here is a block that puts a Hero on a page: + +```json title="A section in content" +{ + "__resolveType": "site/sections/Hero.tsx", + "title": "Summer collection", + "subtitle": "Light layers for long days." +} +``` + +`__resolveType` names what this value is, and every other property is an input to it. When the runtime meets this value, it looks up the component registered as `site/sections/Hero.tsx` and renders it with `title` and `subtitle` as props. + +`__resolveType` can name more than sections. It can name a data loader (`vtex/loaders/intelligentSearch/productList.ts`), a matcher-guarded set of variants (`website/flags/multivariate.ts`), or another block in the decofile by its name. Any value anywhere in content can have one, so blocks nest: a section's `products` prop can be a loader block, and a page's list of sections is a list of section blocks. Turning all of these into plain props is called *resolution*; [Content and the decofile](/v7/content) shows the forms it takes, and [How resolution works](/v7/walkthrough) walks through the exact order. + +## Write a section + +A section is a file under `src/sections/` with a default-exported React component and an exported `Props` type: + +```tsx title="src/sections/Hero.tsx" +import type { ImageWidget } from "@decocms/blocks/types/widgets"; + +export interface CTA { + /** @title Label */ + label: string; + /** @title Link */ + href: string; +} + +export interface Props { + /** + * @title Title + * @description Shown in large type over the image. + */ + title: string; + /** @title Subtitle */ + subtitle?: string; + /** @title Background image */ + image: ImageWidget; + /** @title Button */ + cta?: CTA; +} + +export default function Hero({ title, subtitle, image, cta }: Props) { + return ( + <section style={{ backgroundImage: `url(${image})` }}> + <h1>{title}</h1> + {subtitle && <p>{subtitle}</p>} + {cta && <a href={cta.href}>{cta.label}</a>} + </section> + ); +} +``` + +Three things make this a good section: + +- **`Props` is exported.** The code generator reads it to build the form editors fill in, so every field an editor should control belongs in `Props`. Optional fields (`subtitle?`) become optional inputs. +- **JSDoc tags label the form.** `@title` and `@description` become the field's label and help text. Other tags set defaults, limits and formats; see [Schema generation](/v7/schema) for the full list. +- **Widget types pick the input.** `ImageWidget` is just `string` to TypeScript, but it tells Studio to show an image picker. The aliases in `@decocms/blocks/types/widgets` are: + +| Type | Studio input | +|---|---| +| `ImageWidget` | Image upload and picker | +| `VideoWidget` | Video upload | +| `HTMLWidget` | HTML editor | +| `RichText` | Rich text editor | +| `TextArea` | Multi-line text | +| `Color` | Color picker | +| `Secret` | Password field, for credentials | +| `TextWidget`, `ButtonWidget` | Plain text inputs | + +The section's key comes from its file path. `src/sections/Hero.tsx` is `site/sections/Hero.tsx`, and `src/sections/Header/Header.tsx` is `site/sections/Header/Header.tsx`. Content must use the key exactly, so moving or renaming a section file breaks the content that refers to it. + +Section files can also export flags that change how the section is loaded and cached, such as `export const layout = true` for a header shared by every page, or a `LoadingFallback` skeleton. Those are covered in [Section conventions](/v7/sections). + +## Register sections + +Sections are registered once, in setup, as a map from key to a lazy import. Each section is then loaded only when a page uses it. + +On TanStack Start, `createSiteSetup` takes Vite's `import.meta.glob` result. Its keys look like `./sections/Hero.tsx`, and setup turns them into `site/sections/Hero.tsx`: + +```ts title="src/setup.ts" +import { createSiteSetup } from "@decocms/blocks/setup"; +import { blocks } from "../.deco/blocks.gen"; + +createSiteSetup({ + sections: import.meta.glob("./sections/**/*.tsx") as Record<string, () => Promise<any>>, + blocks, +}); +``` + +On Next.js there's no `import.meta.glob`, so `generate` writes the same map as `sectionImports` in `.deco/sections.gen.ts`, and you pass it to `createNextSetup({ sections: sectionImports })`. See [Quickstart: Next.js](/v7/quickstart-nextjs). + +You can also register sections yourself with `registerSection(key, () => import("…"))`, or several at once with `registerSections({ [key]: () => import("…") })`, both from `@decocms/blocks/cms`. App setup uses `registerSections` to add an app's sections. + +A section can also be registered *synchronously*, bundled into the main chunk instead of loaded on demand, with `export const sync = true` (or `registerSectionsSync`). Sync registration is about rendering speed; resolution still uses the lazy registry, so every section must be in the lazy map too. `createSiteSetup` and `createNextSetup` take care of that. + +## Nest sections inside sections + +A section can take other sections as props, so editors can build a layout (a tab group, a two-column block) out of any sections they like. The schema generator turns a prop into a section picker when its type is written as `Section` (or `Section[]`) and that type is opaque. Declare the alias once in your project: + +```ts title="src/types/deco.ts" +// Any section. The schema generator shows a section picker for props typed `Section`. +export type Section = any; +``` + +Then type the prop with it, and render the value with `RenderSection` from `@decocms/blocks/hooks`: + +```tsx title="src/sections/TwoColumns.tsx" +import { RenderSection } from "@decocms/blocks/hooks"; +import type { Section } from "../types/deco"; + +export interface Props { + /** @title Left column */ + left: Section; + /** @title Right column */ + right: Section; +} + +export default function TwoColumns({ left, right }: Props) { + return ( + <div className="grid grid-cols-2"> + <RenderSection section={left} /> + <RenderSection section={right} /> + </div> + ); +} +``` + +A type named `Section` that has a concrete shape (say, a footer's `{ label; links }` columns) is treated as ordinary data and gets an inline form, not a picker. That's why the alias must be `any`: `Section` from `@decocms/blocks/types` describes the resolved `{ Component, props }` value and is the wrong type for a picker prop. + +In Studio, a `Section` prop shows a section picker. In content it holds a full section block: + +```json title="A TwoColumns block" +{ + "__resolveType": "site/sections/TwoColumns.tsx", + "left": { "__resolveType": "site/sections/Hero.tsx", "title": "Men", "image": "https://…/men.jpg" }, + "right": { "__resolveType": "site/sections/Hero.tsx", "title": "Women", "image": "https://…/women.jpg" } +} +``` + +Resolution turns each nested section into `{ Component, props }` (the component's key and its resolved props), and `RenderSection` renders that, loading the component if it hasn't been loaded yet. Pass `fallback` to show something while it loads. Nested sections get their [section loaders](/v7/loaders) run like top-level ones. + +In client components, import registry helpers (`getSection`, `getResolvedComponent`) from `@decocms/blocks/cms/client`, not from `@decocms/blocks/cms`, which is server-only. + +## When a section fails + +The bindings render every section inside an error boundary, so one section that throws doesn't take the page down: the rest of the page renders, and the failed section shows a fallback. Export an `ErrorFallback` component from the section file to replace the default one: + +```tsx title="src/sections/ProductShelf.tsx (excerpt)" +export function ErrorFallback({ error }: { error: Error }) { + return <div role="alert">Products are unavailable right now.</div>; +} +``` + +A section that isn't registered at all resolves to nothing, with a warning in the server log. If a section you added doesn't appear, check that its key in content matches the file path exactly. + +## Next steps + +- [Content and the decofile](/v7/content): named blocks, references and how content is stored. +- [Section conventions](/v7/sections): `layout`, `sync`, `LoadingFallback` and the other flags. +- [Schema generation](/v7/schema): every JSDoc tag and widget format. +- [Loaders and actions](/v7/loaders): give sections data. diff --git a/docs/content/v7/nextjs-from-start.mdx b/docs/content/v7/nextjs-from-start.mdx new file mode 100644 index 00000000..c463aef9 --- /dev/null +++ b/docs/content/v7/nextjs-from-start.mdx @@ -0,0 +1,211 @@ +--- +title: Moving a Next.js site off @decocms/start 5.x +nav: Next.js from 5.x +group: Upgrading +order: 43 +description: Replace the @decocms/start 5.x /core, /next and /node imports of a Next.js site with @decocms/blocks, @decocms/blocks-admin and @decocms/nextjs. +--- + +# Moving a Next.js site off @decocms/start 5.x + +Some Next.js App Router sites adopted Deco through prerelease 5.x versions of `@decocms/start`, importing from its `/core`, `/next` and `/node` entry points. Those entry points were withdrawn; v7 replaces them with `@decocms/blocks`, `@decocms/blocks-admin` and `@decocms/nextjs`. Most functions keep their names and signatures and only change import path. This page maps the old entry points to the new packages and walks through the five steps. + +It applies when `package.json` pins `@decocms/start` to a `5.x-next` prerelease, or when code imports `@decocms/start/next`, `@decocms/start/core` or `@decocms/start/node`. For a TanStack site on 6.x, see [Upgrading from @decocms/start 6.x](/v7/upgrade-from-start). + +## Where each entry point went + +| Old entry point | v7 | +|---|---| +| `@decocms/start/core` | `@decocms/blocks/cms` (server code) and `@decocms/blocks/cms/client` (Client Components) | +| `@decocms/start/next` | `@decocms/nextjs` (route handlers, preview page, page helpers) and `@decocms/blocks-admin` (protocol types) | +| `@decocms/start/node` | `@decocms/blocks/cms/loadDecofileDirectory`, for loading a directory of block files. Nothing else from `/node` has an equivalent. | + +Function by function: + +| Old | New | +|---|---| +| `registerSection`, `registerSectionsSync`, `getResolvedComponent`, `listRegisteredSections` from `/core` | Same names from `@decocms/blocks/cms`, or from `@decocms/blocks/cms/client` in a Client Component | +| `setBlocks`, `loadBlocks`, `setResolveErrorHandler`, `registerLayoutSections`, `registerSectionLoaders` from `/core` | Same names from `@decocms/blocks/cms` | +| (none) | `registerSeoSections` from `@decocms/blocks/cms`. A new call: without it, page SEO resolves to `{}`. | +| `loadCmsPage` from `/next` | `createDecoPage` from `@decocms/nextjs`, or your own wrapper over `resolveDecoPage`, `runSectionLoaders` and `extractSeoFromSections` (below) | +| `loadAllDecofileBlocks` from `/node` | The generated block manifest (recommended), or `loadDecofileDirectory` from `@decocms/blocks/cms/loadDecofileDirectory` | +| `createDecoAdminRouteHandlers` from `/next` | `createDecoRouteHandlers` from `@decocms/nextjs/routeHandlers`, plus `createDecoPreviewPage` from `@decocms/nextjs` | +| `/_watch` and `/fs/file/*` routes | Delete them. They served a live-editing channel that v7 doesn't have. | +| `/_healthcheck` and `/_ready` routes | Write them yourself (below). | + +## The five steps + +<Steps> +<Step> + +**Dependencies.** Remove the `@decocms/start` pin and add the v7 packages: + +```bash +bun add @decocms/nextjs @decocms/blocks @decocms/blocks-admin +``` + +Add `@decocms/blocks-cli` as a dev dependency to generate the block manifest and schema (see [Code generation](/v7/generate)). + +</Step> +<Step> + +**Section registration.** Change the import of `registerSection` and `registerSectionsSync` from `@decocms/start/core` to `@decocms/blocks/cms`. Nothing else changes. In files marked `"use client"`, import from `@decocms/blocks/cms/client` instead: the full `@decocms/blocks/cms` barrel is server-only. + +</Step> +<Step> + +**Setup and page resolution.** Rewrite your setup module on top of `@decocms/blocks/cms`. If your pages need no custom logic, `createNextSetup` from `@decocms/nextjs/setup` and `createDecoPage` from `@decocms/nextjs` replace most of it (see [Next.js App Router](/v7/nextjs)). If your site has its own wrapper layer, keep its function names and return shapes so page files don't change, and implement it as below. + +</Step> +<Step> + +**Admin routes and previews.** Mount the protocol catch-all and the preview page (below). + +</Step> +<Step> + +**Validate against a production build** with your real content and a section that is a Client Component (below). + +</Step> +</Steps> + +## Page resolution with your own wrapper + +`resolveDecoPage(path, context)` finds the page block for a path and resolves its content. It does **not** run section loaders, and it returns the page's SEO block as a resolved section rather than a plain object. A wrapper that replaces `loadCmsPage` does three things: + +```ts title="src/deco/page.ts" +import { cache } from "react"; +import { headers } from "next/headers"; +import { extractSeoFromSections, resolveDecoPage, runSectionLoaders } from "@decocms/blocks/cms"; +import { ensureSetup } from "./setup"; + +export const resolveCmsPage = cache(async (path: string) => { + await ensureSetup(); + + const h = await headers(); + const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost"; + const proto = h.get("x-forwarded-proto") ?? "https"; + const request = new Request(`${proto}://${host}${path}`, { headers: h }); + + const page = await resolveDecoPage(path, { + request, + url: request.url, + path, + userAgent: h.get("user-agent") ?? undefined, + }); + if (!page) return null; + + // Section loaders fill in data such as product lists. + const sections = await runSectionLoaders(page.resolvedSections, request); + const seoSections = page.seoSection ? await runSectionLoaders([page.seoSection], request) : []; + const seo = extractSeoFromSections([...seoSections, ...sections]); + + return { ...page, sections, seo }; +}); +``` + +- Passing the request in the context lets [matchers](/v7/variants) see the URL, cookies and user agent. +- `extractSeoFromSections` reads only sections registered with `registerSeoSections`. Register every `__resolveType` your page blocks use in their `seo` field. +- `findPageByPath` matches each page block's `path` field, not its key in the decofile, so block keys don't need to follow any naming rule. + +<Callout type="warning"> + +**`createDecoPage` runs no section loaders.** It resolves the page with an empty matcher context and renders. If your sections depend on loaders (commerce data, for example), write a wrapper like the one above instead of switching to `createDecoPage`. + +</Callout> + +### Loading blocks + +For Next.js the recommended source of content is the generated block manifest: `generate` writes `.deco/blocksManifest.gen.ts`, which imports every `.deco/blocks/*.json` file, and you pass it with `createNextSetup({ blocks, blocksDir: false })`. Next then bundles the content and hot-reloads it in `next dev`. If you set blocks yourself, `setBlocks(blocks)` takes the same map. + +If the site has a directory of block files that `generate` doesn't produce, `loadDecofileDirectory(dir)` reads it into one map: + +```ts title="src/deco/setup.ts" +import { setBlocks } from "@decocms/blocks/cms"; +import { loadDecofileDirectory } from "@decocms/blocks/cms/loadDecofileDirectory"; + +let setupPromise: Promise<void> | null = null; + +export function ensureSetup(): Promise<void> { + setupPromise ??= loadDecofileDirectory(".deco/blocks").then(setBlocks); + return setupPromise; +} +``` + +It reads the file system at runtime, so the directory must be deployed with the app. + +## Admin routes and the preview page + +Mount the protocol catch-all with `createDecoRouteHandlers` from the `/routeHandlers` subpath: + +```ts title="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, OPTIONS } = createDecoRouteHandlers({ setup: ensureSetup }); +``` + +Then the preview page, at this exact path: + +```tsx title="src/app/deco/preview/[[...path]]/page.tsx" +import { createDecoPreviewPage } from "@decocms/nextjs"; +import { ensureSetup } from "../../../../deco/setup"; + +export const dynamic = "force-dynamic"; + +export default createDecoPreviewPage({ setup: ensureSetup }); +``` + +Wrap `next.config` with `withDeco` from `@decocms/nextjs/config`; it routes `/live/_meta`, `/.decofile` and `/live/previews/*` to the catch-all. + +- **Never import the root `@decocms/nextjs` from `route.ts`.** Route handlers run under React's server build, and the root barrel includes Client Component code that can't load there. +- **Don't remove `"use client"` to make a preview render.** Preview requests redirect to `/deco/preview`, which renders through Next's own renderer and supports Client Components. The path is fixed by the framework. + +## Health and readiness routes + +v7 ships no health-check helpers. Write them as plain routes. Next treats a folder starting with `_` as private, so encode the underscore as `%5F`: + +```ts title="src/app/%5Fhealthcheck/route.ts" +export const dynamic = "force-dynamic"; + +export async function GET() { + return new Response("ok", { status: 200 }); +} +``` + +```ts title="src/app/%5Fready/route.ts" +import { loadBlocks } from "@decocms/blocks/cms"; +import { ensureSetup } from "../../deco/setup"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + await ensureSetup(); + const ready = Object.keys(loadBlocks()).length > 0; + return new Response(ready ? "ready" : "not ready", { status: ready ? 200 : 503 }); + } catch { + return new Response("not ready", { status: 503 }); + } +} +``` + +Folders starting with a dot work the other way around: keep the literal dot (`.well-known`); an encoded `%2E` isn't decoded and falls through to your catch-all. + +## Validate with a production build + +`next dev` doesn't exercise the same module graph as a production build. Before you ship: + +1. Run `next build` and `next start` with the site's real `.deco/blocks` content. +2. Create a preview of a section that is a Client Component and request `/live/previews/<block key>`. Expect a redirect to `/deco/preview/<block key>`, a 200, the component's initial markup, and no "Attempted to call … from the server" error. +3. Load real pages and check that section loader data and SEO tags are present. + +While the v7 packages are linked locally rather than installed from the registry, test with packed tarballs (`npm pack`) instead of symlinks: bundlers resolve packages differently under symlinks. + +## Related + +- [Next.js App Router](/v7/nextjs): the binding in full. +- [Quickstart: Next.js App Router](/v7/quickstart-nextjs) +- [Packages and exports](/v7/packages) diff --git a/docs/content/v7/nextjs.mdx b/docs/content/v7/nextjs.mdx new file mode 100644 index 00000000..d26f1632 --- /dev/null +++ b/docs/content/v7/nextjs.mdx @@ -0,0 +1,340 @@ +--- +title: Next.js App Router +nav: Next.js +group: Framework guides +order: 20 +description: The four surfaces of the Next.js binding in depth, how to render CMS pages as Server Components, and what the binding deliberately leaves out. +--- + +# Next.js App Router + +`@decocms/nextjs` connects a Next.js App Router site to the Deco Blocks runtime and to [Deco Studio](/v7/studio). It renders CMS pages as React Server Components, serves the admin protocol from one route handler, and renders Studio previews through Next's own RSC pipeline so Client Components work in them. This page covers each piece in depth. To wire a site from scratch, follow the [Next.js quickstart](/v7/quickstart-nextjs) first. + +The binding is RSC-native: it has no Vite plugin and no Cloudflare-specific code. It needs Next.js 15 or later, React 19 and Node 24 or later. + +```bash +bun add @decocms/nextjs @decocms/blocks @decocms/blocks-admin +``` + +## The four surfaces + +A Next.js site touches the binding in four places, each from its own import path. + +| Surface | Import from | Where it goes | +|---|---|---| +| `withDeco(nextConfig)` | `@decocms/nextjs/config` | `next.config.ts` (or `.js`) | +| `createNextSetup(options)` | `@decocms/nextjs/setup` | `src/deco/setup.ts` | +| `createDecoRouteHandlers({ setup })` | `@decocms/nextjs/routeHandlers` | `app/deco/[[...deco]]/route.ts` | +| `createDecoPreviewPage({ setup })` | `@decocms/nextjs` | `app/deco/preview/[[...path]]/page.tsx` | + +Pages and the root layout use `createDecoPage` and `DecoRootLayout` from the package root. + +## withDeco + +`withDeco` wraps your Next config and returns a new one. It works from `next.config.ts` with `import` and from `next.config.js` with `require`. + +```ts title="next.config.ts" +import type { NextConfig } from "next"; +import { withDeco } from "@decocms/nextjs/config"; + +const nextConfig: NextConfig = {}; + +export default withDeco(nextConfig); +``` + +It does three things: + +- **Rewrites the protocol URLs.** Studio calls `/.decofile`, `/live/_meta` and `/live/previews/*`. A Next route folder can't start with `.`, and a folder starting with `_` is private, so those URLs can't be route segments. `withDeco` rewrites them to `/deco/decofile`, `/deco/meta` and `/deco/previews/*`, where the catch-all route serves them. If your config already has `rewrites()`, Deco's rewrites go first (array form) or at the front of `beforeFiles` (object form). +- **Transpiles the Deco packages.** The packages ship TypeScript source, so `withDeco` adds `@decocms/blocks`, `@decocms/blocks-admin` and `@decocms/nextjs` to `transpilePackages`, keeping any you already list. +- **Marks draft requests.** Requests with a `?__draft` parameter or the draft cookie get `Cache-Control: no-store, private`, `Vary: Cookie` and `X-Robots-Tag: noindex, nofollow`. On dynamic responses Next overwrites `Cache-Control` and `Vary` with its own `no-cache, must-revalidate` values, so a shared cache must still check back with your server before reusing a draft response; `X-Robots-Tag` always gets through. See [Previews and draft preview](/v7/preview). + +## createNextSetup + +`createNextSetup(options)` is the Next.js counterpart of `createSiteSetup` plus `createAdminSetup` on TanStack. It returns a function, conventionally named `ensureSetup`, that performs the setup the first time it's awaited. + +```ts title="src/deco/setup.ts" +import { createNextSetup } from "@decocms/nextjs/setup"; +import blocks from "deco/blocksManifest.gen"; +import { loadingFallbacks, sectionImports, sectionMeta, syncComponents } from "deco/sections.gen"; + +export const ensureSetup = createNextSetup({ + blocks, + blocksDir: false, + sections: sectionImports, + conventions: { meta: sectionMeta, syncComponents, loadingFallbacks }, + meta: () => import("deco/meta.gen.json").then((m) => m.default), + productionOrigins: ["https://www.example.com"], +}); +``` + +The `deco/*` imports rely on a path alias in `tsconfig.json`: + +```json title="tsconfig.json (excerpt)" +{ + "compilerOptions": { + "paths": { "deco/*": [".deco/*"] } + } +} +``` + +All three generated files come from [`generate`](/v7/generate), which detects Next.js from your dependencies and produces the blocks manifest and the section registry instead of the TanStack outputs. + +| Option | Type | Default | What it does | +|---|---|---|---| +| `sections` | `Record<string, () => Promise<any>>` | required | Lazy section map keyed `./sections/<path>.tsx`. Each key is registered as `site/sections/<path>.tsx`. Use the generated `sectionImports`. | +| `blocks` | `Record<string, unknown>` | none | Decofile blocks, merged over anything read from `blocksDir`. | +| `blocksDir` | `string \| false` | `".deco/blocks"` | Directory of block JSON files read at setup. `false` skips the read. | +| `conventions` | `{ meta, syncComponents?, loadingFallbacks?, renderJsons? }` | none | Applies the [section conventions](/v7/sections) from the generated `sections.gen.ts`. | +| `meta` | `() => Promise<unknown>` | none | Loads the [schema](/v7/schema). Keep it a dynamic `import()`. Without it, `/live/_meta` answers 503 "Schema not initialized". | +| `renderShell` | `{ css?: string; fonts?: string[] }` | none | Stylesheet and font URLs loaded into Studio previews. | +| `previewWrapper` | `React.ComponentType` | none | Wraps every preview render, for providers your sections need. | +| `productionOrigins` | `string[]` | none | Absolute URLs on these origins inside content are rewritten to relative ones. | +| `customMatchers` | `Array<() => void>` | none | Functions that register your own [matchers](/v7/variants). | +| `onResolveError` | `(error, resolveType, context) => void` | none | Called when a block fails to resolve. | +| `onDanglingReference` | `(resolveType) => any` | warn and return `null` | Called when content references a loader or action that isn't registered. | +| `extend` | `(blocks) => void \| Promise<void>` | none | Runs last, with the loaded blocks. Use it for extra registration, such as section loaders or SEO sections. | + +The admin-side options (`meta`, `renderShell`, `previewWrapper`) load `@decocms/blocks-admin` lazily, only when you pass them. On Next, use these options instead of calling `createAdminSetup`. + +### When setup runs + +Setup is not an import side effect. Nothing happens until something awaits `ensureSetup()`, and three places must: + +1. the catch-all route, through `createDecoRouteHandlers({ setup: ensureSetup })`; +2. the preview page, through `createDecoPreviewPage({ setup: ensureSetup })`; +3. the root layout, with `await ensureSetup()`, because `createDecoPage` has no setup hook of its own. + +A successful setup is remembered for the life of the server instance, so later awaits are free. If setup fails, the call that triggered it rejects and the next call tries again. + +### Where content comes from + +There are two ways to give the runtime its content. + +**The static-import manifest (recommended).** `generate` writes `.deco/blocksManifest.gen.ts`, a module that statically imports every `.deco/blocks/*.json` file. Pass it as `blocks` with `blocksDir: false`. The bundler then owns the content: editing a block hot-reloads in `next dev`, and production builds include the JSON with no file tracing setup. Adding or removing a block file needs a regeneration; editing an existing one doesn't. Keep the manifest out of anything a Client Component imports. + +**Reading the directory.** With the default `blocksDir`, setup reads `.deco/blocks` from disk at startup. `next dev` then serves stale content until restart, and your deployment must ship the `.deco/blocks` folder alongside the server. + +In both cases, a Studio publish (`POST /.decofile`) replaces the content in memory on the running instance. A new deploy starts again from the content in the build. Next.js has no Fast Deploy: to make a publish permanent, commit the content and redeploy. See [Deploying and Fast Deploy](/v7/releases). + +## The catch-all route + +One route handler serves the whole admin protocol. + +```ts title="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, OPTIONS } = createDecoRouteHandlers({ setup: ensureSetup }); +``` + +It accepts both the public URLs (Next keeps the pre-rewrite URL on the request) and their `/deco/*` destinations: + +| Request | Response | +|---|---| +| `OPTIONS` anything | 204 CORS preflight, answered without running setup | +| `GET /.decofile` | The current content | +| `POST /.decofile` | Publish: replaces or patches the content in memory | +| `GET /live/_meta` | The schema, with an ETag. Other methods get 405. | +| `POST /deco/invoke/<key>` | Runs a loader or action. Invoke is POST-only on Next; other methods get 405. | +| `/deco/render` | Plain-HTML render of a section or page | +| `GET /live/previews/<path>` | 307 redirect to `/deco/preview/<path>`, query preserved | +| `POST /live/previews/<path>` | Plain-HTML render | +| anything else | 404 JSON | + +Every response carries CORS headers, because Studio calls the site from another origin. + +<Callout type="warning"> + +**Import only from `@decocms/nextjs/routeHandlers` in `route.ts`.** Route handlers run under React's server build, which ignores `"use client"`. The package root includes Client Component code and crashes there at import time, with errors like "createContext is not a function". The root import is correct in `page.tsx` and `layout.tsx`. + +</Callout> + +The package also exports older per-route handlers (`metaGET`, `decofileGET`, `decofilePOST`, `invokePOST`, `renderGET`, `renderPOST`). The catch-all is the supported way. + +## The preview page + +Studio previews sections and pages in an iframe. On Next they render through a real App Router page at the fixed path `/deco/preview`. + +```tsx title="app/deco/preview/[[...path]]/page.tsx" +import { createDecoPreviewPage } from "@decocms/nextjs"; +import { ensureSetup } from "../../../../deco/setup"; + +export const dynamic = "force-dynamic"; + +export default createDecoPreviewPage({ setup: ensureSetup }); +``` + +The page has to be a Next page. The plain-HTML renderer behind `/deco/render` uses `react-dom/server`, which can't run the client references Next creates for modules marked `"use client"`. Only Next's RSC renderer can combine your Server Components with Client Components and keep the hydration data, so interactive sections preview correctly only here. + +The path is not configurable: the catch-all always redirects preview `GET`s to `/deco/preview`. The page renders inside `data-theme="light"` unless your render shell sets a theme, wraps sections in your `previewWrapper`, and loads the `renderShell` CSS and fonts. + +<Callout type="warning"> + +Never remove `"use client"` from a section to make its preview render. If a section with hooks or event handlers fails to preview, check that the preview page is mounted and that the catch-all redirects to it. + +</Callout> + +## Rendering pages + +### createDecoPage + +`createDecoPage({ siteName })` returns a page component and its `generateMetadata` for an optional catch-all route. Assign the result, then export its parts; `export const { default } = …` is a syntax error. + +```tsx title="app/[[...slug]]/page.tsx" +import { createDecoPage } from "@decocms/nextjs"; + +const page = createDecoPage({ siteName: "My Store" }); + +export const generateMetadata = page.generateMetadata; +export default page.default; +``` + +For each request it resolves the page for the path, or calls `notFound()` when no page block matches. Eager sections render on the server. [Deferred sections](/v7/rendering) start resolving at once and stream in, each under its own `<Suspense>` boundary. Metadata (title, description, canonical, robots) comes from the page's SEO block merged over [SEO sections](/v7/seo); the page and its metadata share one resolution per request. + +The root layout awaits setup and renders the document: + +```tsx title="app/layout.tsx" +import type { ReactNode } from "react"; +import { DecoRootLayout } from "@decocms/nextjs"; +import { ensureSetup } from "../deco/setup"; + +export default async function RootLayout({ children }: { children: ReactNode }) { + await ensureSetup(); + return <DecoRootLayout siteName="my-store">{children}</DecoRootLayout>; +} +``` + +`DecoRootLayout` on Next renders `<html>` and `<body>` around `children`, plus the analytics bootstrap and Studio's live controls. Its props are `siteName` (required), `lang` (default `"en"`), `dataTheme` (default `"light"`), `bodyClassName` (default `"bg-base-200 text-base-content"`) and `account`. Unlike the TanStack version, it takes `children`. + +### What createDecoPage doesn't do + +`createDecoPage` is deliberately small. It: + +- **runs no section loaders**, so sections that fetch server data (commerce loaders attached to sections, section `loader` exports) get no data. This includes deferred sections: Next's `DecoPageRenderer` resolves their props but doesn't run their loaders; +- **resolves with an empty matcher context**, so matchers that read the URL, cookies or headers don't see the request; +- **skips site-wide SEO defaults and title templates**. + +If your pages need any of those, write your own wrapper on the runtime APIs. The one below runs the section loaders of the eager sections and passes the request to matchers. It's a starting point assembled from the runtime's exports, not a packaged API, so type-check it in your project: + +```tsx title="src/deco/page.tsx" +import { cache } from "react"; +import { cookies, headers } from "next/headers"; +import { + extractSeoFromProps, + extractSeoFromSections, + resolveDecoPage, + runSectionLoaders, +} from "@decocms/blocks/cms"; +import { ensureSetup } from "./setup"; + +export const loadPage = cache(async (pathname: string) => { + await ensureSetup(); + const [h, jar] = await Promise.all([headers(), cookies()]); + const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost"; + const proto = h.get("x-forwarded-proto") ?? "https"; + const request = new Request(`${proto}://${host}${pathname}`, { headers: new Headers(h) }); + + const page = await resolveDecoPage(pathname, { + url: request.url, + path: pathname, + userAgent: h.get("user-agent") ?? "", + cookies: Object.fromEntries(jar.getAll().map((c) => [c.name, c.value])), + request, + }); + if (!page) return null; + + const [sections, seoSections] = await Promise.all([ + runSectionLoaders(page.resolvedSections, request), + runSectionLoaders(page.seoSection ? [page.seoSection] : [], request), + ]); + + const pageSeo = seoSections[0] ? extractSeoFromProps(seoSections[0].props) : {}; + + return { + page, + sections, + seo: { ...extractSeoFromSections(sections), ...pageSeo }, + }; +}); +``` + +```tsx title="app/[[...slug]]/page.tsx (custom wrapper)" +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { DecoPageRenderer } from "@decocms/nextjs"; +import { loadPage } from "../../deco/page"; + +type Props = { params: Promise<{ slug?: string[] }> }; + +const pathOf = (slug?: string[]) => `/${(slug ?? []).join("/")}`; + +export async function generateMetadata({ params }: Props): Promise<Metadata> { + const result = await loadPage(pathOf((await params).slug)); + if (!result) return {}; + const { seo } = result; + return { + title: seo.title, + description: seo.description, + alternates: seo.canonical ? { canonical: seo.canonical } : undefined, + robots: seo.noIndexing ? { index: false, follow: false } : undefined, + }; +} + +export default async function Page({ params }: Props) { + const pathname = pathOf((await params).slug); + const result = await loadPage(pathname); + if (!result) notFound(); + return ( + <DecoPageRenderer + sections={result.sections} + deferredSections={result.page.deferredSections} + pagePath={pathname} + /> + ); +} +``` + +`extractSeoFromSections` reads only sections registered as SEO sections, either with `export const seo = true` (applied through `conventions`) or with `registerSeoSections([...])` from `@decocms/blocks/cms` in `extend`; other sections are skipped. The page's own SEO block is read directly with `extractSeoFromProps` and spread last, so its fields win, the same precedence `createDecoPage` uses. + +## Sections on Next + +Sections are ordinary React components in `src/sections/`. Each file there becomes a section keyed `site/sections/<path>`. A common pattern is a thin entry file that re-exports the real component: + +```ts title="src/sections/Hero.tsx" +export { default } from "../components/Hero/Hero"; +export type { HeroProps as Props } from "../components/Hero/Hero"; +``` + +- Next has no `import.meta.glob`, so the section map comes from `generate` as `sectionImports`. A hand-written map works too: keys `./sections/<path>.tsx`, values `() => import("./sections/<path>")`. +- Sections are Server Components by default. Mark a component `"use client"` when it needs state, effects or browser APIs; it renders inside server-rendered sections as usual. +- A Client Component that needs the section registry imports from `@decocms/blocks/cms/client`. The full `@decocms/blocks/cms` barrel is server-only. +- `export const clientOnly = true` renders the section only in the browser. + +## Draft preview + +Draft preview renders unpublished Studio content on the real site. It's off unless you allow hosts, through the Site block's `previewHosts` or the `DECO_ALLOWED_PREVIEW_HOSTS` env var. With it on, `createDecoPage` binds the draft before resolving, and drafted responses are never cached. Pages served by `createDecoPage` become dynamic once the feature is on, because reading the draft needs cookies; `rewriteToDraftRoute` from `@decocms/nextjs/middleware` sends drafted requests to a separate route so ordinary traffic stays static. The setup is in [Previews and draft preview](/v7/preview). + +## Folder names + +Two App Router rules matter for Deco routes: + +- A folder whose name starts with `_` is private and not routable. Encode the underscore as `%5F` to route it, for example `app/%5Fhealthcheck/route.ts` for `/_healthcheck`, or `app/%5Fdraft/[[...slug]]/page.tsx` for the draft route. +- A folder starting with `.` keeps its literal dot. The `.decofile` URL is handled by `withDeco`'s rewrite instead. + +## What Next doesn't have + +Some features exist only in the TanStack binding, because they depend on Cloudflare Workers: + +- **Fast Deploy.** Content ships with the build. See [Deploying and Fast Deploy](/v7/releases). +- **The edge cache** and its `X-Cache` headers, segments and purge endpoint. Use Next's own caching. +- **`?renderJson` and `?asJson`.** `/deco/invoke` works on both bindings. See [Storefront as an API](/v7/storefront-api). +- **The generated invoke server functions**, and the `NavigationProgress` and `StableOutlet` components. + +## Related + +- [Quickstart: Next.js App Router](/v7/quickstart-nextjs) wires all four surfaces from an empty app. +- [Code generation](/v7/generate) explains the files `createNextSetup` imports. +- [Moving a Next.js site off @decocms/start 5.x](/v7/nextjs-from-start) maps the old package to this one. +- [Deco Studio and the admin protocol](/v7/studio) describes each endpoint. diff --git a/docs/content/v7/observability.mdx b/docs/content/v7/observability.mdx new file mode 100644 index 00000000..4b522b96 --- /dev/null +++ b/docs/content/v7/observability.mdx @@ -0,0 +1,244 @@ +--- +title: Observability +group: Production +order: 38 +description: The traces, metrics and logs a v7 site emits, how to send them to your OpenTelemetry collector, and how to instrument your own code. +--- + +# Observability + +A v7 site on Cloudflare Workers reports what it does as OpenTelemetry-shaped traces, metrics and structured logs: how long each request and page resolution took, how each cache decided, how each upstream commerce call went. It's on by default in `@decocms/tanstack`'s Worker entry and stays quiet until you tell it where to send data. This page lists what's emitted, how to configure the exporters, and how to add your own spans, logs and instrumented fetches. + +## What's emitted + +**Spans** (one per unit of work, nested under the request): + +| Span | What it covers | +|---|---| +| `deco.http.request` | The whole request, in the Worker entry. | +| `deco.cms.resolvePage` | Finding and resolving the page for a URL. | +| `deco.section.loaders.batch` | All section loaders of a page. | +| `deco.section.loader` | One section loader. | +| `deco.section.deferred.load` | Loading one [deferred section](/v7/glossary#deferred-section). | +| `deco.cache.lookup`, `deco.cache.store` | Edge cache reads and writes. | +| `deco.admin.meta`, `deco.admin.decofile.read`, `deco.admin.decofile.reload`, `deco.admin.render`, `deco.admin.invoke` | [Admin protocol](/v7/glossary#admin-protocol) endpoints. | +| `<provider>.<operation>` | An outbound call through an instrumented fetch, such as a VTEX search. | + +When a request makes a cache decision, its span also gets `deco.cache.decision` (`HIT`, `STALE-HIT`, `STALE-ERROR`, `MISS`, `BYPASS`, the same values as the `X-Cache` header) and `deco.cache.profile`. + +**Metrics:** + +| Metric | Type | Labels | +|---|---|---| +| `http.server.request.duration` | histogram | method, status, route pattern, cache decision and layer | +| `http.client.request.duration` | histogram | `provider`, `operation`, `status_class`, `cached` | +| `deco.cache.requests` | counter | status, profile, layer (`edge`, `cachedLoader`, `swr`), provider | +| `deco.cache.size` | histogram | `op` (`get`, `set`), profile | +| `deco.cms.resolve.duration` | histogram | route | +| `deco.loader.duration` | histogram | `deco.loader.name`, `deco.cache.result` | +| `deco.loader.errors` | counter | `deco.loader.name` | + +The names are exported as `MetricNames` from `@decocms/blocks/sdk/observability`. The route label is the route pattern (for example `/$`), not the raw path, so it stays low-cardinality. + +**Identity.** Every framework span and log line carries: + +- `service.name`: the `serviceName` option, else the `DECO_SITE_NAME` variable, else `deco-site`; +- `service.version`: the Worker version id, when the `version_metadata` binding exists; +- `deco.runtime.version`: the `@decocms/blocks` version; +- `deployment.environment`: the `DECO_ENV_NAME` variable, else `production`. + +Logs written inside a traced scope also carry `trace_id` and `span_id`, so you can go from a log line to its trace. + +## Turn it on + +`createDecoWorkerEntry` wraps your handler in `instrumentWorker` for you. It starts the exporters from environment variables on each request and flushes them after the response with `ctx.waitUntil`, so the visitor never waits for telemetry. + +Whether it runs is controlled by the `DECO_OTEL` variable: + +| `DECO_OTEL` | Effect | +|---|---| +| unset | On in deployed Workers, off in local development (`vite dev`). | +| `on` | Always on, including locally. | +| `off` | Off. | + +Pass options with the Worker entry's `observability` option, or `observability: false` to turn the wrapper off (for example because you wrap the handler yourself): + +```ts title="src/worker-entry.ts" +export default createDecoWorkerEntry(serverEntry, { + observability: { serviceName: "my-store" }, +}); +``` + +If you build your own handler, wrap it with `instrumentWorker` from `@decocms/blocks/sdk/otel`. It also accepts a function of `env` when an option comes from the environment: + +```ts title="src/worker-entry.ts" +import { instrumentWorker } from "@decocms/blocks/sdk/otel"; + +export default instrumentWorker(handler, (env) => ({ serviceName: String(env.DECO_SITE_NAME) })); +``` + +### Where the data goes + +The framework sends data over three channels. Each is wired only when its variable is set: + +| Variable | Channel | When unset | +|---|---|---| +| `DECO_OTEL_TRACES_ENDPOINT` | Spans, posted as OTLP/HTTP to your collector | Spans reach you only through Cloudflare's own tracing, if enabled in `wrangler.jsonc` | +| `DECO_OTEL_METRICS_ENDPOINT` | Metrics, posted as OTLP/HTTP | No OTLP metrics (Analytics Engine still works, below) | +| `DECO_OTEL_LOGS_ENDPOINT` | Log records at or above `DECO_OTEL_LOGS_MIN_LEVEL` (default `info`), posted as OTLP/HTTP | Logs go to the console only, and from there to Cloudflare Workers Logs | +| `DECO_METRICS` (binding) | Metrics written to a Cloudflare Analytics Engine dataset | No Analytics Engine metrics | + +Point the endpoints at your OpenTelemetry collector. Related variables: + +| Variable | What it does | +|---|---| +| `DECO_OTEL_HEADERS` | Extra headers for the OTLP requests, as `key=value,key2=value2`. | +| `DECO_OTEL_AUTH_TOKEN` | The full `Authorization` header value for the collector, such as `Bearer <token>`. Store it as a Worker secret. An `authorization` key in `DECO_OTEL_HEADERS` overrides it. | +| `DECO_OTEL_TRACES_SAMPLING_RATE` | Fraction of traces to export. Default `0.01`. | +| `DECO_OTEL_LOGS_MIN_LEVEL` | Lowest level posted to the logs endpoint. Default `info`. | +| `DECO_OTEL_ERROR_PROMOTION` | `true` exports a share of unsampled traces that ended in an error. | +| `DECO_OTEL_ERROR_PROMOTION_RATE` | That share. Default `0.1`. | + +Sampling is consistent per trace: either every span of a trace is exported or none is. A request that arrives with a sampled `traceparent` header is always exported. Add `?__d` to a URL to force sampling for that request while debugging. + +### Options + +`OtelOptions` (the `observability` option, or `instrumentWorker`'s second argument): + +| Option | Type | Default | What it does | +|---|---|---|---| +| `disabled` | `boolean` | `false` | Skip every exporter. `DECO_OTEL` overrides it. | +| `envVar` | `string` | `"DECO_OTEL"` | The variable read for the on/off switch. | +| `serviceName` | `string` | `DECO_SITE_NAME`, then `"deco-site"` | `service.name`. | +| `analyticsEngineBindingName` | `string` | `"DECO_METRICS"` | The Analytics Engine binding. | +| `analyticsEngineEnabled` | `boolean` | on when bound | `false` ignores the binding. | +| `otlpTracesEnabled`, `otlpMetricsEnabled`, `otlpLogsEnabled` | `boolean` | on when the endpoint is set | `false` disables that channel without unsetting its variable. | +| `otlpTracesSamplingRate` | `number` | `0.01` | Trace sampling. The variable wins over the option. | +| `otlpLogsMinLevel` | `"debug" \| "info" \| "warn" \| "error"` | `"info"` | Lowest level posted. The variable wins. | +| `otlpHeaders` | `Record<string, string>` | | Extra OTLP headers. | +| `otlpAuthToken` | `string` | | Collector token, if you don't use the variable. | +| `otlpTracesErrorPromotion`, `otlpTracesErrorPromotionRate` | `boolean`, `number` | `false`, `0.1` | Error promotion, as above. | +| `decoAppsVersion` | `string` | | Stamped as `deco.apps.version`. | + +Each `*EnvVar` variant (`otlpTracesEndpointEnvVar`, `otlpMetricsEndpointEnvVar`, `otlpLogsEndpointEnvVar`, `otlpHeadersEnvVar`, `otlpAuthTokenEnvVar`, `otlpTracesSamplingRateEnvVar`, `otlpLogsMinLevelEnvVar`, …) renames the variable read for that setting. + +## Configure Wrangler + +`wrangler.jsonc` controls Cloudflare's own logs and traces and the bindings the framework uses: + +```jsonc title="wrangler.jsonc" +{ + "main": "./src/worker-entry.ts", + "compatibility_flags": ["nodejs_compat", "no_handle_cross_request_promise_resolution"], + "observability": { + "enabled": true, + "logs": { "enabled": true, "head_sampling_rate": 1, "persist": true }, + "traces": { "enabled": true, "head_sampling_rate": 0.01, "persist": true } + }, + "version_metadata": { "binding": "CF_VERSION_METADATA" }, + "analytics_engine_datasets": [{ "binding": "DECO_METRICS", "dataset": "my_store_metrics" }] +} +``` + +- Keep `traces.head_sampling_rate` at `0.01`. Higher rates multiply the volume of trace data at storefront traffic levels; raise it only temporarily, for an investigation. +- Keep `logs.head_sampling_rate` at `1`: info and warning logs are cheap and useful. +- `version_metadata` is what puts `service.version` on spans and logs, so you can tie a regression to a deployment. + +Two CLIs help keep this block right: + +```bash +npx -p @decocms/blocks-cli deco-audit-observability +``` + +```bash +npx -p @decocms/blocks-cli deco-cf-observability --write --traces-rate 0.01 +``` + +`deco-audit-observability` reads `wrangler.jsonc` and reports problems such as observability disabled, a traces rate above `0.01` or a missing `version_metadata` binding. It exits 0 in its default `--mode warn`; use `--mode block` in CI to fail on errors. Some of its rules check for bindings used by Deco's hosted platform; treat those as informational on your own setup. `deco-cf-observability` writes the observability block for you; pass `--traces-rate 0.01`, since its own default is `0.1`. Both are described in the [CLI reference](/v7/cli). + +## Logging + +Use `logger` from `@decocms/blocks/sdk/logger` (also exported from `@decocms/blocks` and `@decocms/blocks/sdk/observability`). Each call writes one JSON line with your message and attributes, plus the identity above: + +```ts title="src/loaders/wishlist.ts" +import { logger, serializeError } from "@decocms/blocks/sdk/logger"; + +export default async function wishlist(props: { userId: string }) { + try { + return await fetchWishlist(props.userId); + } catch (err) { + logger.error("wishlist fetch failed", { userId: props.userId, error: serializeError(err) }); + return []; + } +} +``` + +- `logger.debug`, `info`, `warn` and `error` take a message and an optional attributes object. +- `setLogLevel("warn")` sets the minimum level. +- `serializeError(err)` turns anything thrown into a JSON-safe object. +- `configureLogger(adapter)` replaces the output, for an adapter with `log(level, msg, attrs)`. + +When `DECO_OTEL_LOGS_ENDPOINT` is set, `console.*` calls also go through the logger, so third-party code that logs to the console is captured. + +Without an OTLP logs endpoint, `logger` and `console` output goes to Cloudflare Workers Logs when `observability` is enabled in `wrangler.jsonc` (see [Configure Wrangler](#configure-wrangler)). During an incident you can stream a deployed Worker's logs live with `npx wrangler tail` (see [Real-time logs](https://developers.cloudflare.com/workers/observability/logs/real-time-logs/)). + +## Tracing your own code + +`withTracing(name, fn, attributes?)` runs an async function inside a span: + +```ts title="src/loaders/recommendations.ts" +import { withTracing, injectTraceContext } from "@decocms/blocks/sdk/observability"; + +export default function recommendations(props: { sku: string }) { + return withTracing("site.recommendations", async () => { + const headers = new Headers(); + injectTraceContext(headers); + const res = await fetch(`https://api.example.com/recs/${props.sku}`, { headers }); + return res.json(); + }, { sku: props.sku }); +} +``` + +`injectTraceContext(headers)` adds a W3C `traceparent` header for the active span, so a service that also uses OpenTelemetry joins the same trace. It does nothing when no span is active. `setSpanAttribute(key, value)` adds an attribute to the active span. + +## Instrumenting upstream calls + +Commerce apps report their upstream calls through one shared fetch wrapper, which records `http.client.request.duration` and a span per call. For VTEX, Shopify, Wake and Magento, wire it once at boot, at module scope in your setup module: + +```ts title="src/setup.ts" +import { createVtexFetch, setVtexFetch } from "@decocms/apps-vtex"; + +setVtexFetch(createVtexFetch()); +``` + +Until you do, those apps use a plain `fetch` with a timeout and report nothing. The Salesforce app is instrumented by default; Algolia uses its SDK's own transport and isn't instrumented. Each app page shows its call ([Apps](/v7/apps)). + +If you write your own integration, build the same thing with `createInstrumentedFetch` from `@decocms/blocks/sdk/instrumentedFetch` and `recordCommerceMetric`: + +```ts title="src/utils/acmeFetch.ts" +import { createInstrumentedFetch } from "@decocms/blocks/sdk/instrumentedFetch"; +import { recordCommerceMetric } from "@decocms/blocks/sdk/observability"; + +export const acmeFetch = createInstrumentedFetch({ + name: "acme", + resolveOperation: (url) => (new URL(url).pathname.startsWith("/search") ? "search" : undefined), + onComplete: (m) => + recordCommerceMetric(m.durationMs, { provider: "acme", operation: m.operation, cached: m.cached }), +}); + +// An explicit operation name wins over resolveOperation. +await acmeFetch("https://api.example.com/search?q=shoes", { operation: "search.products" }); +``` + +- The span is named `acme.<operation>`. The operation comes from the call's `operation`, then `defaultOperation`, then `resolveOperation(url, method)`, then `fetch`. +- Each call gets a `traceparent` header (`injectTraceparent: false` to stop it) and a 10-second timeout (`timeoutMs` to change it, `0` to disable). +- Query values are redacted in spans and logs unless listed in `keepQueryKeys`. +- `OTEL_LOG_OUTGOING_FETCH=true` logs every outgoing request. + +For caching upstream `GET`s, use `createFetchCache` from `@decocms/blocks/sdk/fetchCache` rather than your own cache: it emits `deco.cache.requests` with layer `swr` and your provider name, including hits that never reach the network. + +## Related + +- [Caching](/v7/caching): the cache decisions behind `deco.cache.*`. +- [Configuration reference](/v7/configuration): every `DECO_OTEL_*` variable. +- [CLI reference](/v7/cli): the audit and codemod flags. diff --git a/docs/content/v7/packages.mdx b/docs/content/v7/packages.mdx new file mode 100644 index 00000000..3b828ae9 --- /dev/null +++ b/docs/content/v7/packages.mdx @@ -0,0 +1,204 @@ +--- +title: Packages and exports +nav: Packages & exports +group: Reference +order: 44 +description: Every v7 package, every import path it exposes, and which ones are safe in browser code. +--- + +# Packages and exports + +Each v7 package publishes its TypeScript source, and its `package.json` `exports` map lists the only import paths you can use. A path that isn't in the map can't be imported, even if the file exists. This page lists every package's map, what each path holds, and where it may run: **server** means server code only (it imports Node or Workers APIs), **both** means it is safe in browser bundles too. + +The dependency graph is one-way. `@decocms/blocks` depends on no other Deco package. `@decocms/blocks-admin` and `@decocms/blocks-cli` depend on it. `@decocms/tanstack` depends on all three, and `@decocms/nextjs` on `blocks` and `blocks-admin`. The two bindings never depend on each other. + +## Requirements + +| Package | Peer dependencies | Engines | +|---|---|---| +| `@decocms/blocks` | `react` ^19, `react-dom` ^19 | Node.js 24 or later | +| `@decocms/blocks-admin` | `react` ^19, `react-dom` ^19 | | +| `@decocms/blocks-cli` | none (runs with `tsx`) | | +| `@decocms/tanstack` | `@tanstack/react-start` ≥1, `@tanstack/react-query` ≥5, `@tanstack/store` ≥0.7, `react` ^19, `react-dom` ^19, `vite` 6, 7 or 8 | | +| `@decocms/nextjs` | `next` ≥15, `react` ^19, `react-dom` ^19 | Node.js 24 or later | +| `@decocms/apps-*` | `react` ^19, `react-dom` ^19, plus the extras noted per app below | | + +## `@decocms/blocks` + +The framework-agnostic core. + +| Import path | What it is | Runs on | +|---|---|---| +| `@decocms/blocks` | Re-exports `./cms`, `./hooks`, `./middleware`, `./types` and the logger. It does **not** re-export `./sdk`. | server | +| `@decocms/blocks/cms` | The CMS: content (`setBlocks`, `loadBlocks`, `onChange`), page lookup and resolution (`resolveDecoPage`, `resolveValue`), registries (sections, section loaders, commerce loaders, matchers, layout and SEO sections), section conventions, deferral helpers, schema composition, draft preview and Fast Deploy key helpers. | server | +| `@decocms/blocks/cms/client` | The browser-safe subset: section registry lookups (`getResolvedComponent`, `getSection`, `registerSection`, …), schema registration, section mixins (`compose`, `withDevice`, …) and the deferred-section trigger. | both | +| `@decocms/blocks/cms/loadDecofileDirectory` | `loadDecofileDirectory(dir)`: reads a directory of block files into one map. | server (Node file system) | +| `@decocms/blocks/setup` | `createSiteSetup`. | both | +| `@decocms/blocks/hooks` | React components: `Image`, `Picture`, `Source`, `LazySection`, `RenderSection`, `SectionErrorBoundary`, `LiveControls`, `Stats`, `useLoadMore`, JSON-LD components, image CDN settings. | both | +| `@decocms/blocks/preview` | `DraftPreviewBadge` and its helpers. | both | +| `@decocms/blocks/types` | Section and app types (`SectionProps`, `Resolved`, `AppContext`, …). | types | +| `@decocms/blocks/types/widgets` | Studio widget aliases (`ImageWidget`, `RichText`, `Color`, `Secret`, …). | types | +| `@decocms/blocks/matchers/builtins` | `registerBuiltinMatchers` (called by `createSiteSetup`). | both | +| `@decocms/blocks/matchers/posthog` | PostHog feature-flag matcher bridge. | both | +| `@decocms/blocks/matchers/override` | Reading `x-deco-matchers-override`. | both | +| `@decocms/blocks/flags/audience`, `/flags/everyone`, `/flags/flag`, `/flags/types`, `/flags/multivariate`, `/flags/multivariate/image`, `/flags/multivariate/message`, `/flags/multivariate/page`, `/flags/multivariate/section` | Function-style flag primitives carried over from the Fresh-era website app. | both | +| `@decocms/blocks/middleware` | Request helpers: liveness and health checks, CORS, server timing, hydration context, deferred-section input validation. | server | +| `@decocms/blocks/middleware/healthMetrics`, `/middleware/hydrationContext`, `/middleware/observability`, `/middleware/validateSection` | The same, one module each. | server | +| `@decocms/blocks/sdk` | The SDK barrel: common helpers (device detection, invoke, scripts, redirects, URLs, cookies, CSP, analytics, cache headers, `deepOmit`, …). Several modules below are **only** available by subpath. | mixed | + +The SDK subpaths. Modules marked † are not in the `@decocms/blocks/sdk` barrel; import them by subpath. + +| Import path | What it is | Runs on | +|---|---|---| +| `@decocms/blocks/sdk/abTesting` † | Traffic split between a migrated Worker and a legacy origin, for migrations. | server | +| `@decocms/blocks/sdk/analytics` | `useSendEvent`, `ANALYTICS_SCRIPT`, `gtmScript`. | both | +| `@decocms/blocks/sdk/cacheHeaders` | Cache profiles: `setCacheProfile`, `detectCacheProfile`, `registerCachePattern`, `registerPrivatePaths` †, `cacheHeaders`. | both | +| `@decocms/blocks/sdk/cachedLoader` † | `createCachedLoader` and the loader cache. | server | +| `@decocms/blocks/sdk/cacheStorage` † | Shared cache storage adapters (KV, Web Cache, memory). | server | +| `@decocms/blocks/sdk/responseCache` † | HTTP response caching over a cache storage. | server | +| `@decocms/blocks/sdk/fetchCache` † | `createFetchCache`, the shared stale-while-revalidate cache for upstream `GET`s. | server | +| `@decocms/blocks/sdk/fetchTimeout` | `withFetchTimeout` and the 10 s default. | both | +| `@decocms/blocks/sdk/instrumentedFetch` | `createInstrumentedFetch`. | server | +| `@decocms/blocks/sdk/mergeCacheControl` | Merges `Cache-Control` headers, keeping the most restrictive. | both | +| `@decocms/blocks/sdk/clx`, `@decocms/blocks/sdk/cn` † | Class name helpers (`cn` adds Tailwind merge). | both | +| `@decocms/blocks/sdk/composite` † | `createCompositeLogger`, `createCompositeMeter`. | both | +| `@decocms/blocks/sdk/cookie` | Cookie helpers for the browser and the server (`getCookies`, `setResponseCookie` † are subpath-only). | both | +| `@decocms/blocks/sdk/crypto` † | `resolveSecret`, `decryptSecret` for CMS-encrypted secrets. | server | +| `@decocms/blocks/sdk/csp` | `frame-ancestors` headers for Studio previews. | server | +| `@decocms/blocks/sdk/useDevice`, `@decocms/blocks/sdk/detectDevice` † | Device detection (`detectDevice` is also re-exported by `useDevice`) and the `useDevice` hook. | both | +| `@decocms/blocks/sdk/djb2`, `@decocms/blocks/sdk/encoding` † | Hashing (`djb2`, `djb2Hex`, also in the barrel) and base64 helpers. | both | +| `@decocms/blocks/sdk/env` | `isDevMode()`. | both | +| `@decocms/blocks/sdk/experiments` † | Sticky N-way experiments read from a published configuration. Experimental. | server | +| `@decocms/blocks/sdk/flags` † | The `deco_segment` sticky-flag cookie. | both | +| `@decocms/blocks/sdk/http` † | `STATUS_CODE`, `HttpError`, `UserAgent`. | both | +| `@decocms/blocks/sdk/invoke` | `invoke`, `createAppInvoke`, `batchInvoke`, `invokeQueryOptions`. | both | +| `@decocms/blocks/sdk/logger` † | The structured logger. | both | +| `@decocms/blocks/sdk/nonce` † | The request's CSP nonce. | server | +| `@decocms/blocks/sdk/normalizeUrls` | Rewrites production URLs in content to relative ones. | both | +| `@decocms/blocks/sdk/observability` † | Tracing, metrics and logging in one barrel. | server | +| `@decocms/blocks/sdk/otel` † | `instrumentWorker` and `OtelOptions`. | server | +| `@decocms/blocks/sdk/otelAdapters` †, `/sdk/otelHttpLog` †, `/sdk/otelHttpMeter` †, `/sdk/otelHttpTracer` † | OTLP and Analytics Engine exporters, for custom wiring. | server | +| `@decocms/blocks/sdk/redirects` | CMS redirects. | both | +| `@decocms/blocks/sdk/requestContext` † | `RequestContext`. | both (stub in the browser) | +| `@decocms/blocks/sdk/requestContextStorage` † | The storage behind it, chosen by export condition: `workerd`, `node` and `default` get `AsyncLocalStorage`; `browser` gets a no-op stub. | both | +| `@decocms/blocks/sdk/retry` † | Retry helpers. | both | +| `@decocms/blocks/sdk/serverTimings` | `createServerTimings` for `Server-Timing` headers. | server | +| `@decocms/blocks/sdk/signal` | `signal()`, backed by a TanStack store. | both | +| `@decocms/blocks/sdk/sitemap` † | Sitemap generation from page blocks. | server | +| `@decocms/blocks/sdk/urlUtils` | Tracking-parameter stripping and canonical URLs. | both | +| `@decocms/blocks/sdk/useId` | `useId`. | both | +| `@decocms/blocks/sdk/useScript` | `inlineScript`; `useScript` is deprecated. | both | +| `@decocms/blocks/sdk/useSuggestions` † | Autocomplete hook factory. | browser | +| `@decocms/blocks/sdk/wrapCaughtErrors` | Error wrapping helpers. | both | + +<Callout type="warning"> + +**Don't import `@decocms/blocks/cms` (or the `@decocms/blocks` root) from browser code.** It reaches `node:async_hooks`. Client Components use `@decocms/blocks/cms/client`. + +</Callout> + +## `@decocms/blocks-admin` + +| Import path | What it is | Runs on | +|---|---|---| +| `@decocms/blocks-admin` | The admin protocol handlers (`handleMeta`, `handleDecofileRead`, `handleDecofileReload`, `handleRender`, `handleInvoke`), CORS helpers, invoke registration (`setInvokeLoaders`, `setInvokeActions`, `registerInvokeHandlers`), schema and render-shell setters (`setMetaData`, `setRenderShell`, `setPreviewWrapper`), `registerAdminOrigin`. | server | +| `@decocms/blocks-admin/setup` | `createAdminSetup`. | server | +| `@decocms/blocks-admin/admin/setup` | The state setters alone, without the handlers. | both | +| `@decocms/blocks-admin/apps` | `autoconfigApps`, `setupApps`, app types (`AppRegistry`, `AppDefinition`, …). | server | +| `@decocms/blocks-admin/apps/autoconfig` | `autoconfigApps` and its types. | server | +| `@decocms/blocks-admin/sdk/setupApps` | `setupApps`, `registerAppMiddleware`, `getAppMiddleware`. | server | +| `@decocms/blocks-admin/sdk/htmlShell` | `buildHtmlShell`, the preview HTML document. | server | + +## `@decocms/blocks-cli` + +| Import path | What it is | +|---|---| +| `@decocms/blocks-cli/generate` | The `generate` orchestrator. Run it as `tsx node_modules/@decocms/blocks-cli/scripts/generate.ts`. | +| `@decocms/blocks-cli/generate-blocks` | `generateBlocks` and `readBlockDelta`, used by the TanStack Vite plugin. | + +Commands (`bin`): `deco-migrate`, `deco-post-cleanup`, `deco-htmx-analyze`, `deco-reconcile`, `deco-upgrade-6-to-7`, `deco-sync-blocks-to-kv`, `deco-migrate-blocks-to-kv`, `deco-sync-blocks-bot`, `deco-cf-observability`, `deco-audit-observability`. Run them with `npx -p @decocms/blocks-cli <command>`. The individual generator scripts and `audit-secrets.ts`, `cdn-rules.ts` and `tailwind-lint.ts` are reachable only by file path under `node_modules/@decocms/blocks-cli/scripts/`. See [CLI reference](/v7/cli). + +## `@decocms/tanstack` + +| Import path | What it is | Runs on | +|---|---|---| +| `@decocms/tanstack` | Route configs (`cmsRouteConfig`, `cmsHomeRouteConfig`, `decoMetaRouteConfig`, `decoRenderRouteConfig`, `decoInvokeRouteConfig`), server functions (`loadCmsPage`, `loadCmsHomePage`, `loadDeferredSection`), components (`DecoRootLayout`, `DecoPageRenderer`, `SectionRenderer`, `SectionList`, `NavigationProgress`, `StableOutlet`, `DraftPreviewIndicator`, `PreviewProviders`, `CmsPage`, `NotFoundPage`), `createDecoWorkerEntry`, `setupTanstackFastDeploy`, `createDecoRouter`, speculation rules. | both | +| `@decocms/tanstack/vite` | `decoVitePlugin`. Plain JavaScript without type declarations: add `// @ts-expect-error` above the import in a TypeScript config. | build | +| `@decocms/tanstack/sdk/deferredSectionLoader` | `deferredSectionLoader`, for `DecoPageRenderer`'s `loadDeferredSectionFn`. | both | +| `@decocms/tanstack/sdk/serverFnFetch` | `decoServerFnFetch`, for your own `src/start.ts`. | browser | +| `@decocms/tanstack/sdk/startEntry` | The default TanStack Start entry used when your site has no `src/start.ts`. | both | +| `@decocms/tanstack/sdk/cookiePassthrough` | `getRequestCookieHeader`, `forwardResponseCookies`. | server | +| `@decocms/tanstack/sdk/cdnSegment` | Constants of the CDN segment marker. Framework plumbing. | both | +| `@decocms/tanstack/sdk/createInvoke` | A type marker read by `generate` when it writes `invoke.gen.ts`. It throws if called. | build | +| `@decocms/tanstack/daemon` | The local development tunnel to Studio, started by the Vite plugin. Not for direct use. | dev | + +The option types of `createDecoWorkerEntry` aren't exported; their shapes are written out in [TanStack Start on Cloudflare Workers](/v7/tanstack). + +## `@decocms/nextjs` + +| Import path | What it is | Runs on | +|---|---|---| +| `@decocms/nextjs` | `createDecoPage`, `createDecoPreviewPage`, `DecoRootLayout`, `DecoPageRenderer`, `SectionRenderer`, draft preview helpers (`ensureDraft`, `DraftPreviewIndicator`, …). For `page.tsx` and `layout.tsx`, **never** `route.ts`. | server components | +| `@decocms/nextjs/routeHandlers` | `createDecoRouteHandlers`. The import for `route.ts` files. | server | +| `@decocms/nextjs/setup` | `createNextSetup`. | server | +| `@decocms/nextjs/config` | `withDeco`, `DECO_REWRITES`. CommonJS, so it works from `next.config.js` and `next.config.ts`. | build | +| `@decocms/nextjs/middleware` | `draftMiddleware`, `prepareDraft`, `applyDraft`, `draftRequestHeaders`, `rewriteToDraftRoute`. | edge middleware | + +## `@decocms/eitri` + +| Import path | What it is | +|---|---| +| `@decocms/eitri` | `generateEitri`, `eitriGenerateArgs`, `runEitriInit`. | +| `@decocms/eitri/tsconfig` | A base `tsconfig.json` to extend. | +| `@decocms/eitri/types` | Type shims for the Eitri runtime modules. | + +Command: `deco-eitri` (`init`, `generate`). See [Eitri apps](/v7/eitri). + +## Apps + +Wildcard paths (`*`) map to the files of that directory, without the extension. + +### `@decocms/apps-commerce` + +`./types` (also the package's main entry), `./types/cart`, `./app-types`, `./resolve`, `./manifest-utils`, `./registry`, `./sdk/*` (`formatPrice`, `url`, `useOffer`, `useVariantPossibilities`, `analytics`), `./utils/*` (`filters`, `canonical`, `productToAnalyticsItem`, `constants`, `stateByZip`). There's no root import; use `@decocms/apps-commerce/types`. See [Commerce types and utilities](/v7/apps-commerce). + +### `@decocms/apps-website` + +`.`, `./mod`, `./client`, `./types`, `./components/*` (`Seo`, `Analytics`, `OneDollarStats`, `Stats`, `Theme`, `Video`), `./loaders/*`, `./loaders/fonts/*`, `./utils/*`, `./sections/*`, `./sections/Seo/*`. No `./registry`. See [Website app](/v7/apps-website). + +### `@decocms/apps-vtex` + +`.`, `./client`, `./mod`, `./registry`, `./commerceLoaders`, `./schemas`, `./types`, `./middleware`, `./actions`, `./actions/*`, `./actions/analytics/*`, `./loaders`, `./loaders/*`, `./loaders/intelligentSearch/*`, `./loaders/legacy/*`, `./loaders/workflow/*`, `./inline-loaders/productDetailsPage`, `./inline-loaders/productListingPage`, `./inline-loaders/productList`, `./inline-loaders/productListShelf`, `./inline-loaders/relatedProducts`, `./inline-loaders/suggestions`, `./inline-loaders/minicart`, `./inline-loaders/workflowProducts`, `./hooks`, `./hooks/*`, `./utils`, `./utils/*`. Extra optional peer: `@tanstack/react-query` ≥5 for the Query-based hooks. See [VTEX](/v7/vtex). + +### `@decocms/apps-shopify` + +`.`, `./client`, `./mod`, `./registry`, `./loaders/*`, `./actions/*`, `./actions/cart/*`, `./actions/user/*`, `./utils/*`. See [Shopify](/v7/shopify). + +### `@decocms/apps-wake` + +`.`, `./client`, `./mod`, `./registry`, `./commerceLoaders`, `./loaders/*`, `./actions/*`, `./actions/cart/*`, `./actions/newsletter/*`, `./actions/review/*`, `./actions/wishlist/*`, `./handlers/*`, `./utils/*`. The map also declares `./hooks` and `./hooks/*`, but the package ships no hook files, so those paths don't resolve. See [Wake](/v7/wake). + +### `@decocms/apps-magento` + +`.`, `./client`, `./types`, `./middleware`, `./loaders/*`, `./actions/*`, `./utils/*`. No `mod` or `registry`. The map declares `./hooks/*`, but the package ships no hook files, so that path doesn't resolve. See [Magento](/v7/magento). + +### `@decocms/apps-salesforce` + +`.`, `./types`, `./loaders/products/*` (`list`, `listRecomended`, `listCart`), `./utils/*`. Extra peer: `@tanstack/react-start` ≥1. See [Salesforce Personalization](/v7/salesforce). + +### `@decocms/apps-algolia` + +`.`, `./client`, `./types`, `./loaders/*`. Extra peer: `algoliasearch` ^5, required by `.` and `./client`. See [Algolia](/v7/algolia). + +### `@decocms/apps-blog` + +`.`, `./mod`, `./client`, `./registry`, `./types`, `./manifest.gen`, `./loaderMap`, `./loaders/*`, `./loaders/extensions/*`, `./actions/*`, `./core/*`, `./utils/*`, `./sections/*`, `./sections/Seo/*`, `./sections/blocks/*`, `./static/*`. See [Blog](/v7/blog). + +### `@decocms/apps-resend` + +`.`, `./mod`, `./client`, `./registry`, `./types`, `./actions/send`. See [Resend](/v7/resend). + +## Related + +- [Configuration reference](/v7/configuration) +- [How v7 is built](/v7/internals): why the packages are split this way. diff --git a/docs/content/v7/preview.mdx b/docs/content/v7/preview.mdx new file mode 100644 index 00000000..82f8f0b7 --- /dev/null +++ b/docs/content/v7/preview.mdx @@ -0,0 +1,154 @@ +--- +title: Previews and draft preview +nav: Previews & drafts +group: Core concepts +order: 13 +description: How Studio previews sections and pages, how to make previews render correctly, and how draft preview shows unpublished content on the real site. +--- + +# Previews and draft preview + +Editors need to see a change before visitors do. v7 gives them two ways. *Previews* render a section or page inside Studio, with the editor's unsaved props, as they type. *Draft preview* goes further: it renders the real site, on its real URLs, with unpublished content from Studio, through a shareable link. This page covers both, and what each binding needs from you. + +<Terms> + <Term name="Preview">A section or page rendered by your site inside Studio's frame, with props Studio sends. Nothing is saved.</Term> + <Term name="Draft preview">Rendering unpublished Studio content on the real site through a `?__draft=` link, only on allowed hosts. See the [glossary](/v7/glossary#draft-preview).</Term> + <Term name="Preview wrapper">A component wrapped around every preview to provide context (a router, a query client) your sections expect.</Term> +</Terms> + +## Previews in Studio + +When an editor changes a field, Studio asks your site to render the section, or the whole page, with the new props through the `/live/previews/*` endpoint (see [Deco Studio and the admin protocol](/v7/studio)). Nothing is published: the props travel with the request, and any changed blocks apply only to that one render. + +Previews render outside your app's normal route tree, so two things need setting up. + +**The document around the preview.** Previews use your stylesheet and fonts from setup (`createAdminSetup({ css, fonts })` on TanStack, `createNextSetup({ renderShell })` on Next.js). If your styles use DaisyUI theme colors, set the theme with `setRenderShell({ theme: "light" })`; see [What previews look like](/v7/studio#what-previews-look-like). + +**Context your sections use.** A section that calls a router hook or a TanStack Query hook crashes if there's no router or query client above it. The *preview wrapper* provides them. On TanStack Start, use `PreviewProviders` from `@decocms/tanstack`, which supplies an in-memory router and a query client: + +```ts title="src/setup.ts (excerpt)" +import { createAdminSetup } from "@decocms/blocks-admin/setup"; +import { PreviewProviders } from "@decocms/tanstack"; + +createAdminSetup({ + meta: () => import("../.deco/meta.gen.json").then((m) => m.default), + css: appCss, + previewWrapper: PreviewProviders, +}); +``` + +If your sections need more context (a theme provider, a cart context), write your own wrapper that renders `PreviewProviders` around it. + +### Previews on Next.js + +On Next.js, preview requests are redirected to a page you mount at `/deco/preview`, built with `createDecoPreviewPage`: + +```tsx title="src/app/deco/preview/[[...path]]/page.tsx" +import { createDecoPreviewPage } from "@decocms/nextjs"; +import { ensureSetup } from "../../../../deco/setup"; + +export const dynamic = "force-dynamic"; + +export default createDecoPreviewPage({ setup: ensureSetup }); +``` + +It's a page rather than a route handler because a plain HTML renderer can't run components marked `"use client"`; only Next's Server Components renderer can combine them with server sections. So keep `"use client"` wherever a section needs state, effects or browser APIs, and let previews go through this page. + +## Draft preview + +Draft preview lets someone open the real site, for example `https://www.example.com/summer-sale`, and see the content an editor hasn't published yet. Studio produces a link with a `__draft` query parameter; the site fetches that draft from Deco's content service, uses it for the request, and falls back to published content if anything goes wrong. + +Only allow-listed hosts render drafts: on every other host the parameter is ignored. The link's token says where the draft lives and carries a grant signed by Studio, and the site fetches drafts only from Deco's content-service domains (see `DECO_PREVIEW_API_DOMAINS` below). + +### What a drafted visitor gets + +<Flow label="Draft preview"> + <FlowNode title="Open the link">`?__draft=<token>` on an allowed host</FlowNode> + <FlowNode title="Cookie">The site sets a `__deco_draft` cookie for 30 minutes</FlowNode> + <FlowNode title="Browse">Every page renders with the draft content</FlowNode> + <FlowNode title="Exit">`?__draft=off`, or the badge's exit button, clears it</FlowNode> +</Flow> + +- The draft follows the visitor across pages through the `__deco_draft` cookie, which lasts 30 minutes. +- Drafted responses are marked `private` and not to be stored by shared caches, and carry `X-Robots-Tag: noindex, nofollow`, so drafts don't reach other visitors or search engines. On Next.js, Next may replace the `Cache-Control` value on dynamic pages with `no-cache, must-revalidate`, which still makes a shared cache check with your site before reusing a response. +- A floating badge marks the page as a draft, with buttons to share the link or exit. It hides itself inside Studio's frame. On TanStack, `DecoRootLayout` renders it for you, and on Next.js `createDecoPage` does; the component is `DraftPreviewBadge` from `@decocms/blocks/preview`. +- If the draft can't be loaded, the page renders published content. + +### Allow hosts + +Draft preview does nothing until at least one host is allowed. Hosts are compared exactly, port included (`localhost:3000`, not `localhost`). There are two ways to allow them: + +- **In content:** a `previewHosts` list in the [Site block](/v7/content#the-site-block), such as `"previewHosts": ["preview.example.com", "localhost:3000"]`. +- **In the environment:** `DECO_ALLOWED_PREVIEW_HOSTS`, a comma-separated list. When set, it replaces the Site block's list. + +Set `DECO_ALLOWED_PREVIEW_HOSTS=none` to turn draft preview off completely, whatever the content says. + +On TanStack Start, when `DECO_SITE_NAME` is set, your site's Deco-hosted domains are allowed automatically. On Next.js, list every host yourself. + +`DECO_PREVIEW_API_DOMAINS` limits which content-service domains a draft may be loaded from. The defaults cover Deco's hosted Studio and local development; you only need it when running Studio somewhere else. + +### On TanStack Start + +Nothing to wire. `createDecoWorkerEntry` reads the draft parameter and cookie, binds the draft to the request, and bypasses the edge cache for it. Your routes, loaders and `/deco/invoke` calls see the draft content. + +### On Next.js + +`createDecoPage` honors drafts on its own once a host is allowed. Two optional pieces complete the setup. + +**Middleware** sets and clears the cookie and the cache and noindex headers, which a Server Component can't do, and forwards the draft to the rest of the render so shared parts such as a header resolved in the layout see it too. Use `draftMiddleware` from `@decocms/nextjs/middleware` as your whole middleware, or compose its parts into one you already have: + +```ts title="src/middleware.ts" +import { + applyDraft, + draftRequestHeaders, + prepareDraft, + rewriteToDraftRoute, +} from "@decocms/nextjs/middleware"; +import { type NextRequest, NextResponse } from "next/server"; + +export function middleware(request: NextRequest) { + const decision = prepareDraft(request); + // Your own middleware logic can go here. + const response = + rewriteToDraftRoute(request, decision) ?? + NextResponse.next({ request: { headers: draftRequestHeaders(request, decision) } }); + return applyDraft(response, decision); +} +``` + +On Next.js 16, the file is `proxy.ts` and the function is exported as `proxy`. + +**A separate draft route** keeps normal pages static. Reading the draft cookie makes a page dynamic, so if your catch-all page is statically generated or uses ISR, `rewriteToDraftRoute` sends drafted requests to a route under `/_draft` instead. Mount a copy of your page there; the folder name is URL-encoded because Next.js treats folders starting with `_` as private: + +```tsx title="src/app/%5Fdraft/[[...slug]]/page.tsx" +import { createDecoPage } from "@decocms/nextjs"; + +const page = createDecoPage({ siteName: "My Store" }); + +export const generateMetadata = page.generateMetadata; +export default page.default; +``` + +If you render pages with your own component instead of `createDecoPage`, call `await ensureDraft(await searchParams)` from `@decocms/nextjs` in the page before resolving anything. It returns whether a draft was bound. Call it from the page itself; a layout awaiting it doesn't cover the page. + +### Honor drafts in your own endpoints + +A route of your own that reads content (a JSON feed, a sitemap) shows published content unless you bind the draft yourself. `resolveDraftForRequest` from `@decocms/blocks/cms` returns the request's draft decofile, or `null`, and `withDraftBlocks` uses it in place of the published content for the duration of a function: + +```ts title="A draft-aware handler" +import { loadBlocks, resolveDraftForRequest, withDraftBlocks } from "@decocms/blocks/cms"; + +export async function handleFeed(request: Request): Promise<Response> { + const work = async () => Response.json(Object.keys(loadBlocks())); + const draft = await resolveDraftForRequest(request); + return draft ? withDraftBlocks(draft, work) : work(); +} +``` + +Inside `work`, `loadBlocks()` and everything built on it, including `resolveDecoPage`, see the draft. Other requests running at the same time are unaffected. + +## Next steps + +- [Deco Studio and the admin protocol](/v7/studio): the preview endpoints and render shell. +- [Matchers and variants](/v7/variants): preview a segment with `x-deco-matchers-override`. +- [Configuration reference](/v7/configuration): every environment variable. diff --git a/docs/content/v7/project-structure.mdx b/docs/content/v7/project-structure.mdx new file mode 100644 index 00000000..8e2674b2 --- /dev/null +++ b/docs/content/v7/project-structure.mdx @@ -0,0 +1,105 @@ +--- +title: Project structure +group: Getting started +order: 5 +description: The files of a Deco Blocks v7 site, the files the code generator writes, and which of them to commit. +--- + +# Project structure + +A v7 site is an ordinary TanStack Start or Next.js project with a few conventional folders the framework reads, and a `.deco/` folder for content and generated files. This page maps every one of them, so you know what you edit, what the tools write, and what belongs in git. + +## The tree + +A TanStack Start site on Cloudflare Workers looks like this. A Next.js site has the same `src/sections`, `src/loaders`, `src/actions` and `.deco/` folders, with `src/app/` routes instead of `src/routes/`. + +```text +my-store/ +├── .deco/ +│ ├── blocks/ content: one JSON file per block (you and Studio edit these) +│ │ ├── pages-home.json +│ │ ├── pages-summer-sale.json +│ │ ├── Header.json +│ │ └── Site.json +│ ├── blocks.gen.json generated: all blocks in one file +│ ├── blocks.gen.ts generated: small stub the Vite plugin fills in +│ ├── sections.gen.ts generated: section conventions (and sectionImports on Next.js) +│ ├── loaders.gen.ts generated: registry of src/loaders and src/actions +│ ├── meta.gen.json generated: the schema Studio reads +│ ├── generate.digests.json generated: the generator's cache records +│ └── .cache/ generated: local cache, ignored by git +├── public/ static files, and CSV redirect files +├── src/ +│ ├── sections/ sections: React components editors place on pages +│ │ ├── Hero.tsx +│ │ ├── ProductShelf.tsx +│ │ ├── Header/Header.tsx +│ │ └── Footer/Footer.tsx +│ ├── loaders/ site loaders: server functions that fetch data +│ ├── actions/ site actions: server functions that change something +│ ├── components/ ordinary React components used by sections +│ ├── routes/ TanStack routes: __root, index, $, deco/* and your own +│ ├── server/invoke.gen.ts generated (TanStack with @decocms/apps-vtex): client-callable server functions +│ ├── setup.ts registers sections, content and admin settings +│ ├── router.tsx createDecoRouter +│ ├── server.ts TanStack Start server entry +│ └── worker-entry.ts the Worker's entry: createDecoWorkerEntry +├── vite.config.ts +├── wrangler.jsonc +└── package.json +``` + +## Folders you write + +**`src/sections/`.** Every `.tsx` or `.ts` file in this folder, at any depth, is a section, except test, spec, story and `.gen` files. A section's *key* is its path under `src/` with a `site/` prefix: `src/sections/Header/Header.tsx` is `site/sections/Header/Header.tsx`. Content refers to sections by that exact key in `__resolveType`, so renaming or moving a section file changes its key and breaks the content that uses it. Keep other components in `src/components/` and let a section file be a thin entry point if you like: + +```tsx title="src/sections/Hero.tsx" +export { default } from "../components/Hero/Hero"; +export type { HeroProps as Props } from "../components/Hero/Hero"; +``` + +**`src/loaders/` and `src/actions/`.** Server functions with a default export. `generate` registers them under `site/loaders/<path>` and `site/actions/<path>`, so content and the `/deco/invoke` endpoint can call them. See [Loaders and actions](/v7/loaders). + +**`.deco/blocks/`.** The content, one JSON file per block. The file name is the block's name, URL-encoded (`encodeURIComponent(name) + ".json"`), so a block named `pages-summer-sale` lives in `pages-summer-sale.json`. You can edit these by hand, and they're the source the generator bundles into your build. Content edited in Studio reaches this folder when it's synced back to your repository. See [Content and the decofile](/v7/content). + +**`src/setup.ts`** (TanStack) or **`src/deco/setup.ts`** (Next.js). The one place that registers sections and content with the runtime. On TanStack it runs in both the browser and the Worker, so keep server-only modules (commerce loader maps, anything holding credentials) out of it and import those from the Worker entry instead. See the quickstarts for [TanStack](/v7/quickstart) and [Next.js](/v7/quickstart-nextjs). + +## Files the tools write + +The `generate` command from `@decocms/blocks-cli` writes most generated files; run it with `tsx node_modules/@decocms/blocks-cli/scripts/generate.ts`. In development the TanStack Vite plugin also regenerates them as you edit. Which generators run depends on the project: Next.js sites get the manifest instead of `blocks.gen.*`, and the invoke file is only for TanStack sites with `@decocms/apps-vtex` installed. + +| File | Written by | Read by | Commit it? | +|---|---|---|---| +| `.deco/blocks.gen.json` | `generate` (TanStack); the Vite plugin on dev start | The server bundle, as the default content | No. It's one large line that conflicts on every content change; it's rebuilt on every build. | +| `.deco/blocks.gen.ts` | `generate` (TanStack) | `setup.ts`; the Vite plugin swaps its contents for the JSON at build time and empties it in the browser bundle | Yes | +| `.deco/blocksManifest.gen.ts` | `generate` (Next.js) | `src/deco/setup.ts`, as the content source | Yes | +| `.deco/sections.gen.ts` | `generate` | `setup.ts` (`applySectionConventions`, or `createNextSetup`'s `sections` and `conventions`) | Yes | +| `.deco/loaders.gen.ts` | `generate` | Your commerce-loader registration, to make site loaders callable | Yes | +| `.deco/meta.gen.json` | `generate`; the Vite plugin when source changes | `createAdminSetup`'s or `createNextSetup`'s `meta`, served at `/live/_meta` | Yes, so Studio works on a fresh clone | +| `src/server/invoke.gen.ts` | `generate` (TanStack with `@decocms/apps-vtex` installed) | Client code that calls the app's loaders and actions | Yes | +| `.deco/generate.digests.json` | `generate` | `generate`, to skip unchanged generators | Yes. Fresh clones and CI then hit the cache. | +| `.deco/.cache/` | `generate` | `generate` (a local speed-up only) | No. It contains its own `.gitignore`. | +| `src/routeTree.gen.ts` | The TanStack Start plugin | The router | Usually not | + +<Callout type="warning">**Leave `src/server/invoke.gen.ts` in `src/`.** It holds server functions that TanStack Start's compiler turns into client-callable RPC stubs, and that only works for files compiled as part of your application code. Moved into `.deco/`, the calls fail on the server.</Callout> + +If two people regenerate in parallel, `generate.digests.json` may conflict in a merge. Resolve it either way and run `generate` again. + +## Names and keys + +A few naming rules are worth knowing early: + +| Thing | Key format | Example | +|---|---|---| +| Section | `site/sections/<path under src/sections>` | `site/sections/Product/SearchResult.tsx` | +| Site loader | `site/loaders/<path>` (also accepted without `.ts`) | `site/loaders/wishlist.ts` | +| Site action | `site/actions/<path>` | `site/actions/newsletter.ts` | +| App loader | `<app>/loaders/<path>` | `vtex/loaders/intelligentSearch/productList.ts` | +| Page block | Any name starting with `pages-`, or any block with `__resolveType: "website/pages/Page.tsx"` | `pages-summer-sale` | +| Site-wide settings | A block named `Site` (or `site`) | `Site` | + +## Next steps + +- [Blocks and sections](/v7/model): what goes in a section file. +- [Content and the decofile](/v7/content): what goes in `.deco/blocks/`. +- [Code generation](/v7/generate): every generator and flag. diff --git a/docs/content/v7/quickstart-nextjs.mdx b/docs/content/v7/quickstart-nextjs.mdx new file mode 100644 index 00000000..abdaf06b --- /dev/null +++ b/docs/content/v7/quickstart-nextjs.mdx @@ -0,0 +1,248 @@ +--- +title: "Quickstart: Next.js App Router" +nav: Quickstart · Next.js +group: Getting started +order: 4 +description: Add Deco Blocks to a Next.js App Router site, render one CMS page and connect Deco Studio. +--- + +# Quickstart: Next.js App Router + +In this guide you'll add Deco Blocks to a Next.js App Router project: one section, one page block, a page that renders whatever the CMS has for a path, and the routes Deco Studio uses to read, preview and publish content. Sections render as React Server Components, and sections marked `"use client"` keep working, including in Studio previews. + +You need Next.js 15 or later, React 19 and Node.js 24 or later. The examples assume your app lives in `src/app/`. + +## 1. Install the packages + +```bash +bun add @decocms/blocks @decocms/blocks-admin @decocms/nextjs +``` + +`next`, `react` and `react-dom` are peer dependencies your project already has. Add the code generator and `tsx` to run it: + +```bash +bun add -d @decocms/blocks-cli tsx +``` + +## 2. Wrap the Next.js config + +`withDeco` adds the rewrites that map Studio's URLs (`/.decofile`, `/live/_meta`, `/live/previews/*`) onto routes Next.js can express, and adds the Deco packages to `transpilePackages` because they ship TypeScript source. + +```ts title="next.config.ts" +import type { NextConfig } from "next"; +import { withDeco } from "@decocms/nextjs/config"; + +const nextConfig: NextConfig = {}; + +export default withDeco(nextConfig); +``` + +It also adds response headers that keep draft-preview renders out of shared caches and search indexes, and it merges with any `rewrites`, `headers` and `transpilePackages` you already have. A CommonJS `next.config.js` works the same with `require("@decocms/nextjs/config")`. + +## 3. Add a path alias for generated files + +The code generator writes into `.deco/` at the project root. Add a `deco/*` alias so setup code can import those files without long relative paths: + +```json title="tsconfig.json (compilerOptions)" +{ + "compilerOptions": { + "paths": { + "deco/*": [".deco/*"] + } + } +} +``` + +Keep any aliases you already have, such as `@/*`. + +## 4. Write a section + +A *section* is a React component that editors can place on a page: a file under `src/sections/` with a default export and an exported `Props` type. JSDoc comments become labels in Studio. + +```tsx title="src/sections/Hero.tsx" +import type { ImageWidget } from "@decocms/blocks/types/widgets"; + +export interface Props { + /** @title Title */ + title: string; + /** @title Subtitle */ + subtitle?: string; + /** @title Background image */ + image?: ImageWidget; +} + +export default function Hero({ title, subtitle, image }: Props) { + return ( + <section style={{ backgroundImage: image ? `url(${image})` : undefined }}> + <h1>{title}</h1> + {subtitle && <p>{subtitle}</p>} + </section> + ); +} +``` + +Its key in content is `site/sections/Hero.tsx`. This one is a Server Component; a section that needs state or event handlers can start with `"use client"` like any other component. + +## 5. Add a page + +Content lives in `.deco/blocks/`, one JSON file per block. A *page block* has a `path` and a list of `sections`, and either its name starts with `pages-` or its `__resolveType` is `website/pages/Page.tsx`. + +```json title=".deco/blocks/pages-home.json" +{ + "__resolveType": "website/pages/Page.tsx", + "name": "Home", + "path": "/", + "sections": [ + { + "__resolveType": "site/sections/Hero.tsx", + "title": "Summer collection", + "subtitle": "Light layers for long days." + } + ] +} +``` + +## 6. Generate + +```json title="package.json (scripts)" +{ + "scripts": { + "generate": "tsx node_modules/@decocms/blocks-cli/scripts/generate.ts --site my-store", + "predev": "bun run generate", + "prebuild": "bun run generate" + } +} +``` + +```bash +bun run generate +``` + +The generator notices `@decocms/nextjs` in your dependencies and adjusts what it writes: + +- `.deco/blocksManifest.gen.ts`, a module that statically imports every block file. Content becomes part of Next's module graph, so editing a block hot-reloads in `next dev` and production builds bundle the content. +- `.deco/sections.gen.ts`, including `sectionImports`: a map of lazy imports for every section (Next.js has no `import.meta.glob`, so the map is generated instead). +- `.deco/meta.gen.json`, the schema Studio builds its forms from. + +Rerun it when you add or remove a section or a block file, or change a section's `Props` (the schema comes from them). Editing an existing block's content doesn't need it; the `predev` and `prebuild` scripts above also run it for you. Commit `.deco/generate.digests.json` so CI can reuse the cache. See [Code generation](/v7/generate). + +## 7. Create the setup function + +`createNextSetup` is the Next.js bootstrap. It returns `ensureSetup`, an async function that registers your sections and content the first time it's called and does nothing on later calls. + +```ts title="src/deco/setup.ts" +import { createNextSetup } from "@decocms/nextjs/setup"; +import blocks from "deco/blocksManifest.gen"; +import { loadingFallbacks, sectionImports, sectionMeta, syncComponents } from "deco/sections.gen"; + +export const ensureSetup = createNextSetup({ + blocks, + blocksDir: false, + sections: sectionImports, + conventions: { meta: sectionMeta, syncComponents, loadingFallbacks }, + meta: () => import("deco/meta.gen.json").then((m) => m.default), + productionOrigins: ["https://www.example.com"], +}); +``` + +- `blocks` with `blocksDir: false` makes the generated manifest the only content source, so setup reads nothing from disk. +- `conventions` applies the per-section flags the generator found. See [Section conventions](/v7/sections). +- `meta` points at the schema. Keep it a dynamic `import()` so it loads only when Studio asks for it. Without it, `/live/_meta` answers 503 "Schema not initialized". + +Import this module only from server code (routes, layouts, pages), never from a client component. + +<Callout type="warning">**`ensureSetup` is not a side effect of importing the file.** Nothing is registered until something awaits it. The three places below each await it.</Callout> + +## 8. Mount the admin routes + +One catch-all route serves the whole admin protocol: reading and publishing content, the schema, loader and action calls, and section rendering. + +```ts title="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, OPTIONS } = createDecoRouteHandlers({ setup: ensureSetup }); +``` + +Studio previews render on a separate page at the fixed path `/deco/preview`. The catch-all redirects preview requests there. + +```tsx title="src/app/deco/preview/[[...path]]/page.tsx" +import { createDecoPreviewPage } from "@decocms/nextjs"; +import { ensureSetup } from "../../../../deco/setup"; + +export const dynamic = "force-dynamic"; + +export default createDecoPreviewPage({ setup: ensureSetup }); +``` + +Previews need their own page because only Next's Server Components renderer can render sections that contain Client Components. See [Previews and draft preview](/v7/preview). + +<Callout type="warning"> + +**Import from `@decocms/nextjs/routeHandlers` in `route.ts`, never from `@decocms/nextjs`.** The root package includes the rendering components, and route handlers can't load client component code: the route fails at import time with errors like "createContext is not a function". The root package is correct in pages and layouts. + +**Don't remove `"use client"` from a section to make a preview work.** Previews of interactive sections go through the preview page above. + +</Callout> + +## 9. Await setup in the root layout + +Pages don't have a setup hook of their own, so the root layout, which every page shares, awaits `ensureSetup` before rendering. `DecoRootLayout` renders `<html>` and `<body>` and the bridge that lets Studio talk to the page in its preview frame. + +```tsx title="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 <DecoRootLayout siteName="my-store">{children}</DecoRootLayout>; +} +``` + +## 10. Render CMS pages + +`createDecoPage` returns a page component that resolves the CMS page for the current path, plus a `generateMetadata` that fills the title, description, canonical URL and robots from the page's SEO. Paths with no page block get Next's `notFound()`. + +```tsx title="src/app/[[...slug]]/page.tsx" +import { createDecoPage } from "@decocms/nextjs"; + +const page = createDecoPage({ siteName: "My Store" }); + +export const generateMetadata = page.generateMetadata; +export default page.default; +``` + +Assign the result to a variable and re-export it: `export const { default } = …` isn't valid JavaScript, because `default` is a reserved word. + +`createDecoPage` resolves content and renders sections, but it doesn't run section loaders or pass request details (cookies, the full URL) to matchers. When your sections need those, write your own page with `resolveDecoPage` and `runSectionLoaders`; see [Next.js App Router](/v7/nextjs). + +## 11. Run it + +```bash +bun run dev +``` + +Open `http://localhost:3000`. You should see the Hero. Then check the endpoints Studio needs: + +```bash +curl -s http://localhost:3000/live/_meta +``` + +It returns JSON with a `schema` and a `manifest`, and `manifest.blocks.sections` lists `site/sections/Hero.tsx`. `curl -s http://localhost:3000/.decofile` returns your blocks. + +## What you built + +- A section and a page block, with content bundled into the build. +- A memoized setup awaited by the layout, the admin route and the preview page. +- A catch-all page that renders any path the CMS knows. +- The admin protocol: the endpoints Studio reads, previews through and publishes to. + +## Next steps + +- [Project structure](/v7/project-structure): the files you created and the generated ones. +- [Next.js App Router](/v7/nextjs): the four surfaces in depth, custom page wrappers and limits. +- [Loaders and actions](/v7/loaders): bring data into sections. +- [Previews and draft preview](/v7/preview): render unpublished content on the real site. +- [examples/nextjs-smoke](https://github.com/decocms/blocks/tree/main/examples/nextjs-smoke): a complete, runnable Next.js site to compare yours with. diff --git a/docs/content/v7/quickstart.mdx b/docs/content/v7/quickstart.mdx new file mode 100644 index 00000000..8f63d06e --- /dev/null +++ b/docs/content/v7/quickstart.mdx @@ -0,0 +1,398 @@ +--- +title: "Quickstart: TanStack Start on Cloudflare Workers" +nav: Quickstart · TanStack +group: Getting started +order: 3 +description: Build a TanStack Start site on Cloudflare Workers with one CMS-driven page that Deco Studio can edit. +--- + +# Quickstart: TanStack Start on Cloudflare Workers + +In this guide you'll take an empty TanStack Start app to a page whose content comes from the CMS: one section, one page block, the routes that render it, and the admin endpoints Deco Studio needs. It runs locally with `vite dev` and deploys as a Cloudflare Worker. + +You need Bun (or another package manager), React 19, and a basic TanStack Start project. If you're on Next.js, follow [Quickstart: Next.js App Router](/v7/quickstart-nextjs) instead. + +## 1. Install the packages + +Add the runtime, the admin package and the TanStack binding with their peers: + +```bash +bun add @decocms/blocks @decocms/blocks-admin @decocms/tanstack @tanstack/react-start @tanstack/react-router @tanstack/react-query @tanstack/store react react-dom +``` + +Then the build tools: Vite and its plugins, the Cloudflare Vite plugin and Wrangler, and `@decocms/blocks-cli` with `tsx` to run the code generator. + +```bash +bun add -d vite @vitejs/plugin-react @cloudflare/vite-plugin wrangler @decocms/blocks-cli tsx +``` + +## 2. Configure Vite + +`decoVitePlugin()` keeps server-only modules (content, loaders, the schema) out of the browser bundle, stamps each build with a hash, and regenerates `.deco/` files while you develop. Add it after the TanStack Start and React plugins, and dedupe the Deco packages so each one is loaded once: + +```ts title="vite.config.ts" +import { cloudflare } from "@cloudflare/vite-plugin"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +// @ts-expect-error: the plugin is plain JavaScript and ships no type declarations +import { decoVitePlugin } from "@decocms/tanstack/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + cloudflare({ viteEnvironment: { name: "ssr" } }), + tanstackStart({ server: { entry: "server" } }), + react(), + decoVitePlugin(), + ], + define: { + "process.env.DECO_SITE_NAME": JSON.stringify(process.env.DECO_SITE_NAME || "my-store"), + }, + resolve: { + dedupe: [ + "@decocms/blocks", + "@decocms/blocks-admin", + "@decocms/tanstack", + "@tanstack/react-start", + "@tanstack/react-router", + "react", + "react-dom", + ], + }, +}); +``` + +`DECO_SITE_NAME` is your site's name. When the plugin regenerates the schema during development it passes this value as the site name, so keep it the same as the `--site` value of the `generate` script in step 5. + +## 3. Write a section + +A *section* is a React component that editors can place on a page. It's a file under `src/sections/` with a default export and an exported `Props` type. JSDoc comments become labels in Studio, and widget types such as `ImageWidget` tell Studio which input to show. + +```tsx title="src/sections/Hero.tsx" +import type { ImageWidget } from "@decocms/blocks/types/widgets"; + +export interface Props { + /** @title Title */ + title: string; + /** @title Subtitle */ + subtitle?: string; + /** @title Background image */ + image?: ImageWidget; +} + +export default function Hero({ title, subtitle, image }: Props) { + return ( + <section style={{ backgroundImage: image ? `url(${image})` : undefined }}> + <h1>{title}</h1> + {subtitle && <p>{subtitle}</p>} + </section> + ); +} +``` + +The section's *key*, the name content uses to refer to it, is `site/sections/Hero.tsx`: its path under `src/`, with a `site/` prefix. [Blocks and sections](/v7/model) covers sections in depth. + +## 4. Add a page + +Content lives in `.deco/blocks/`, one JSON file per block. A *page block* has a `path` and a list of `sections`, and either its name starts with `pages-` or its `__resolveType` is `website/pages/Page.tsx`. That is the built-in page type; the `website/` prefix is a legacy name, and no app needs installing. Create the home page: + +```json title=".deco/blocks/pages-home.json" +{ + "__resolveType": "website/pages/Page.tsx", + "name": "Home", + "path": "/", + "sections": [ + { + "__resolveType": "site/sections/Hero.tsx", + "title": "Summer collection", + "subtitle": "Light layers for long days." + } + ] +} +``` + +Each item in `sections` names a section in `__resolveType` and carries its props next to it. This is the JSON Studio will edit for you once it's connected. + +## 5. Generate + +The `generate` command turns your content and sections into files the runtime and Studio read: the bundled content (`.deco/blocks.gen.json`), the section metadata (`.deco/sections.gen.ts`), the site loader registry and the schema (`.deco/meta.gen.json`). Add it as a script: + +```json title="package.json (scripts)" +{ + "scripts": { + "generate": "tsx node_modules/@decocms/blocks-cli/scripts/generate.ts --site my-store", + "dev": "vite dev", + "build": "bun run generate && vite build" + } +} +``` + +Run it once now: + +```bash +bun run generate +``` + +It skips generators whose inputs haven't changed, so it's cheap to run often. Commit `.deco/generate.digests.json`, which lets fresh clones and CI reuse that cache. See [Code generation](/v7/generate) for every flag. + +## 6. Set up the site + +`src/setup.ts` registers your sections and content with the runtime and configures the admin side. It's imported by both the router (browser and server) and the Worker entry, so keep server-only code out of it. + +```ts title="src/setup.ts" +import { applySectionConventions } from "@decocms/blocks/cms"; +import { createSiteSetup } from "@decocms/blocks/setup"; +import { createAdminSetup } from "@decocms/blocks-admin/setup"; +import { PreviewProviders } from "@decocms/tanstack"; +import { blocks } from "../.deco/blocks.gen"; +import { loadingFallbacks, renderJsons, sectionMeta, syncComponents } from "../.deco/sections.gen"; +import appCss from "./styles/app.css?url"; + +const sections = import.meta.glob("./sections/**/*.tsx") as Record<string, () => Promise<any>>; + +createSiteSetup({ + sections, + blocks, + productionOrigins: ["https://www.example.com"], +}); + +createAdminSetup({ + meta: () => import("../.deco/meta.gen.json").then((m) => m.default), + css: appCss, + previewWrapper: PreviewProviders, +}); + +applySectionConventions({ + meta: sectionMeta, + syncComponents, + loadingFallbacks, + renderJsons, + sectionGlob: sections, +}); +``` + +- `createSiteSetup` from `@decocms/blocks/setup` registers every file under `src/sections/` under its `site/sections/…` key, loads the content, and registers the built-in matchers. `productionOrigins` lists your live domains so absolute links to them in content are rewritten as relative ones. +- `createAdminSetup` from `@decocms/blocks-admin/setup` tells the admin side where the schema is (keep `meta` a dynamic `import()` so it loads only when Studio asks for it), which stylesheet previews should use, and which component wraps previews. `PreviewProviders` gives previewed sections a router and a query client. +- `applySectionConventions` applies the per-section flags `generate` found (lazy loading, layout caching, skeletons). See [Section conventions](/v7/sections). + +The import of `./styles/app.css?url` assumes you have a stylesheet there; create an empty one if you don't. + +## 7. Create the router + +`createDecoRouter` wraps TanStack's `createRouter` with search-param handling that suits storefront URLs. Import `./setup` here so sections are registered in the browser too, and create the `QueryClient` inside `getRouter()`: + +```tsx title="src/router.tsx" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createDecoRouter } from "@decocms/tanstack"; +import { routeTree } from "./routeTree.gen"; +import "./setup"; + +export function getRouter() { + const queryClient = new QueryClient(); + return createDecoRouter({ + routeTree, + context: { queryClient }, + Wrap: ({ children }) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>, + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType<typeof getRouter>; + } +} +``` + +`routeTree.gen.ts` is generated by the TanStack Start plugin from `src/routes/`. + +## 8. Add the routes + +The root route renders the whole document with `DecoRootLayout`. It already renders the matched route, so don't pass an `<Outlet />`: + +```tsx title="src/routes/__root.tsx" +import { createRootRoute } from "@tanstack/react-router"; +import { DecoRootLayout } from "@decocms/tanstack"; + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: "utf-8" }, + { name: "viewport", content: "width=device-width, initial-scale=1" }, + ], + }), + component: () => <DecoRootLayout siteName="my-store" />, +}); +``` + +The home route and a catch-all route turn the URL into a CMS page. `cmsHomeRouteConfig` and `cmsRouteConfig` return route options (a loader that resolves the page on the server, cache headers, and a `head` with the page's SEO) that you spread into the route. You supply the component, which renders the sections with `DecoPageRenderer`: + +```tsx title="src/routes/index.tsx" +import { createFileRoute } from "@tanstack/react-router"; +import { cmsHomeRouteConfig, DecoPageRenderer } from "@decocms/tanstack"; +import { deferredSectionLoader } from "@decocms/tanstack/sdk/deferredSectionLoader"; + +export const Route = createFileRoute("/")({ + ...cmsHomeRouteConfig({ siteName: "My Store", defaultTitle: "My Store" }), + component: HomePage, +}); + +function HomePage() { + const data = Route.useLoaderData() as Record<string, any> | null; + if (!data) return <p>No CMS page matches /.</p>; + return ( + <DecoPageRenderer + sections={data.resolvedSections ?? []} + deferredSections={data.deferredSections ?? []} + pagePath={data.pagePath} + pageUrl={data.pageUrl} + loadDeferredSectionFn={deferredSectionLoader} + /> + ); +} +``` + +```tsx title="src/routes/$.tsx" +import { createFileRoute } from "@tanstack/react-router"; +import { cmsRouteConfig, DecoPageRenderer } from "@decocms/tanstack"; +import { deferredSectionLoader } from "@decocms/tanstack/sdk/deferredSectionLoader"; + +export const Route = createFileRoute("/$")({ + ...cmsRouteConfig({ siteName: "My Store", defaultTitle: "My Store" }), + component: CmsPage, + notFoundComponent: NotFound, +}); + +function CmsPage() { + const data = Route.useLoaderData() as Record<string, any> | null; + if (!data) return <NotFound />; + return ( + <DecoPageRenderer + sections={data.resolvedSections ?? []} + deferredSections={data.deferredSections ?? []} + pagePath={data.pagePath} + pageUrl={data.pageUrl} + loadDeferredSectionFn={deferredSectionLoader} + /> + ); +} + +function NotFound() { + return <h1>Page not found</h1>; +} +``` + +The two `siteName` options mean different things. In `cmsHomeRouteConfig` and `cmsRouteConfig` it's a display name that page titles use (`Page name | My Store`); in `DecoRootLayout` it's the site's ID, the same value as `DECO_SITE_NAME` and the `--site` flag. + +`loadDeferredSectionFn={deferredSectionLoader}` lets sections that editors mark as async load after the page, including after client-side navigation. See [Deferred sections](/v7/rendering). + +Three more routes serve the parts of the admin protocol that run through TanStack: the schema, loader and action calls, and section rendering. Each is a factory you call, once per route file: + +```ts title="src/routes/deco/meta.ts" +import { createFileRoute } from "@tanstack/react-router"; +import { decoMetaRouteConfig } from "@decocms/tanstack"; + +export const Route = createFileRoute("/deco/meta")(decoMetaRouteConfig()); +``` + +```ts title="src/routes/deco/invoke.$.ts" +import { createFileRoute } from "@tanstack/react-router"; +import { decoInvokeRouteConfig } from "@decocms/tanstack"; + +export const Route = createFileRoute("/deco/invoke/$")(decoInvokeRouteConfig()); +``` + +```ts title="src/routes/deco/render.ts" +import { createFileRoute } from "@tanstack/react-router"; +import { decoRenderRouteConfig } from "@decocms/tanstack"; + +export const Route = createFileRoute("/deco/render")(decoRenderRouteConfig()); +``` + +## 9. Add the server and Worker entries + +`src/server.ts` is TanStack Start's server entry (the `entry: "server"` in `vite.config.ts`): + +```ts title="src/server.ts" +import "./setup"; +import { createStartHandler, defaultStreamHandler } from "@tanstack/react-start/server"; + +export default createStartHandler(defaultStreamHandler); +``` + +`src/worker-entry.ts` is the Worker's real entry point. `createDecoWorkerEntry` wraps TanStack Start with the admin endpoints Studio calls directly (`/live/_meta`, `/.decofile`, `/live/previews/*`), CMS redirects and the edge cache: + +```ts title="src/worker-entry.ts" +import "./setup"; +import handler, { createServerEntry } from "@tanstack/react-start/server-entry"; +import { createDecoWorkerEntry } from "@decocms/tanstack"; +import { + corsHeaders, + handleDecofileRead, + handleDecofileReload, + handleMeta, + handleRender, +} from "@decocms/blocks-admin"; + +const serverEntry = createServerEntry({ fetch: handler.fetch }); + +export default createDecoWorkerEntry(serverEntry, { + admin: { handleMeta, handleDecofileRead, handleDecofileReload, handleRender, corsHeaders }, +}); +``` + +<Callout type="warning"> + +**Import `./setup` first** in both `server.ts` and `worker-entry.ts`. If another import runs before it, parts of the server can execute before the content is loaded: the page renders on a full reload but client-side navigation finds no page. + +**Keep admin and cache logic in `createDecoWorkerEntry`,** not in TanStack's `createServerEntry`. Production builds don't keep custom request handling in the server entry, so `/live/_meta` would return HTML instead of JSON. + +</Callout> + +## 10. Configure the Worker + +Point Wrangler at the Worker entry and turn on Node compatibility, which the runtime needs for request-scoped state: + +```jsonc title="wrangler.jsonc" +{ + "name": "my-store", + "main": "src/worker-entry.ts", + "compatibility_date": "2025-05-01", + // The framework's dedup caches hold in-flight promises across requests; without this flag the Worker can hang. + "compatibility_flags": ["nodejs_compat", "no_handle_cross_request_promise_resolution"], + "vars": { + "DECO_SITE_NAME": "my-store" + } +} +``` + +## 11. Run it + +```bash +bun run dev +``` + +Open the dev server's URL (Vite prints it, usually `http://localhost:5173`). You should see the Hero with "Summer collection". Then check the admin protocol: + +```bash +curl -s http://localhost:5173/live/_meta +``` + +This returns JSON with a `schema` and a `manifest`, and `manifest.blocks.sections` lists `site/sections/Hero.tsx`. `curl -s http://localhost:5173/.decofile` returns your blocks. Those two answers are what Studio needs to edit the site. + +Try editing `.deco/blocks/pages-home.json` while the dev server runs: the plugin applies the change without a restart. + +## What you built + +- A section with a typed `Props` that Studio can render a form for. +- A page block at `/` stored as JSON. +- Routes that resolve any path to a page block and render its sections. +- A Worker that serves the admin protocol: the endpoints Studio reads, previews through and publishes to. + +## Next steps + +- [Project structure](/v7/project-structure): every file you just created, plus the generated ones. +- [Content and the decofile](/v7/content): named blocks, references, and how publishing works. +- [Loaders and actions](/v7/loaders): bring data into sections. +- [TanStack Start on Cloudflare Workers](/v7/tanstack): every option of the Worker entry and the routes. +- [Deploying and Fast Deploy](/v7/releases): publish content without a redeploy. +- [examples/tanstack-smoke](https://github.com/decocms/blocks/tree/main/examples/tanstack-smoke): a complete, runnable TanStack site to compare yours with. diff --git a/docs/content/v7/releases.mdx b/docs/content/v7/releases.mdx new file mode 100644 index 00000000..a73ce3b2 --- /dev/null +++ b/docs/content/v7/releases.mdx @@ -0,0 +1,229 @@ +--- +title: Deploying and Fast Deploy +nav: Deploying & Fast Deploy +group: Production +order: 36 +description: How a v7 site ships code and content, and how Fast Deploy lets a Studio publish go live on Cloudflare Workers without a redeploy. +--- + +# Deploying and Fast Deploy + +A v7 site ships two things: code (your sections, loaders and routes) and content (the decofile). By default they ship together, because the content is bundled into the build. This page explains that default, how to deploy a TanStack site to Cloudflare Workers, and Fast Deploy, an opt-in mode that serves content from Cloudflare KV so a Studio publish reaches every visitor in seconds without a new deploy. + +## How content ships by default + +`generate` turns `.deco/blocks/*.json` into `.deco/blocks.gen.json`, and the binding bundles it (see [Code generation](/v7/generate)). Each build therefore carries the content that was committed when it was built, and every server starts from that snapshot. + +When an editor publishes in [Deco Studio](/v7/glossary#deco-studio), Studio sends the change to your running site with `POST /.decofile` (see [Deco Studio and the admin protocol](/v7/studio)). The site swaps it into memory right away, so the next request renders the new content. But it lives only in that server's memory: + +- On Cloudflare Workers, each **isolate** (one running copy of your Worker) holds its own copy. An isolate that starts later begins again from the bundled snapshot. +- On Next.js, each server instance holds its own copy, and a restart returns to the bundled content. + +Content becomes permanent when it's committed to `.deco/blocks/` and the site is rebuilt. Fast Deploy removes that gap on Workers. + +## Deploying a TanStack site to Workers + +A TanStack site deploys with Wrangler like any Worker. The `main` entry of `wrangler.jsonc` must be your own `src/worker-entry.ts`, the file that calls `createDecoWorkerEntry` (see [TanStack Start on Cloudflare Workers](/v7/tanstack)). + +```bash +npx wrangler deploy --var BUILD_HASH:$(git rev-parse --short HEAD) +``` + +`BUILD_HASH` identifies the build. The Worker adds it to every edge cache key, so each deploy reads and writes its own cache entries, and HTML cached by an older deploy, which points at older JavaScript chunks, is never served after a new one goes live. You can rename the variable with the `cacheVersionEnv` option. When it's missing, the Worker uses a hash the Vite plugin injects at build time (the commit sha on Cloudflare Workers Builds, otherwise the local git sha). + +Cloudflare's docs cover the rest of a Worker's setup: + +- [secrets](https://developers.cloudflare.com/workers/configuration/secrets/) (`wrangler secret put`) +- [staging and production environments](https://developers.cloudflare.com/workers/wrangler/environments/) +- [custom domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) +- [platform limits](https://developers.cloudflare.com/workers/platform/limits/), such as subrequests per request and memory + +## Fast Deploy + +[Fast Deploy](/v7/glossary#fast-deploy) decouples content from code on `@decocms/tanstack`. Content is stored in a Cloudflare KV namespace, and every isolate reads it from there, so a publish reaches all of them within seconds and survives new isolates. Only code changes need `wrangler deploy`. + +It isn't available in `@decocms/nextjs`: edge KV is specific to Cloudflare Workers. + +### How it works + +KV holds the **whole decofile as one value**, keyed by **deployment id**: the identifier of the code version, normally its git commit sha. Keying by deployment means each code version reads only its own content. During a rolling deploy, old and new code never read each other's content, and rolling back to an older version finds its content still there. + +<Flow label="Content with Fast Deploy"> + <FlowNode title="Build">CI writes this commit's content to KV, keyed by its sha</FlowNode> + <FlowNode title="Cold start">An isolate loads its deployment's snapshot from KV once and swaps it into memory</FlowNode> + <FlowNode title="Publish">Studio's `POST /.decofile` updates memory and writes the snapshot back to KV</FlowNode> + <FlowNode title="Poll">Every isolate checks the snapshot's revision at most every 10 seconds and reloads on change</FlowNode> +</Flow> + +The details: + +- **Rendering never waits on KV.** Resolution reads content from memory, as it does without Fast Deploy. KV is touched once per isolate on its first request, and afterwards by a background check, at most once every 10 seconds, of a small revision key (a hash of the snapshot). When the revision changes, the isolate reloads the snapshot. +- **The bundle is the fallback.** If no deployment id resolves, or KV fails, or the key is missing, the isolate serves the content bundled with its own build. It never serves another deployment's content. +- **Exact redirects live in their own keys.** Redirects with an exact `from` path are stored one per KV key and looked up per request, so a site with tens of thousands of them doesn't hold them all in memory. Wildcard redirects stay in the decofile. A redirect-only change doesn't change the snapshot's revision; isolates pick it up within 60 seconds. + +The deployment id comes from the `DECO_DEPLOYMENT_ID` variable, then `BUILD_HASH`, then the hash the Vite plugin injected at build time. + +### Turn it on + +Fast Deploy requires **both** an explicit flag and a KV binding, so binding a namespace on its own never changes how a site behaves. + +<Steps> +<Step> + +**Create a KV namespace and bind it as `DECO_KV`**, then set the flag in `wrangler.jsonc`: + +```jsonc title="wrangler.jsonc" +{ + "kv_namespaces": [{ "binding": "DECO_KV", "id": "<your namespace id>" }], + "vars": { + "DECO_FAST_DEPLOY": "1" + } +} +``` + +`DECO_FAST_DEPLOY` accepts `"1"` or `"true"`. Don't set `DECO_DEPLOYMENT_ID` here; the deploy command passes it. + +</Step> +<Step> + +**Call `setupTanstackFastDeploy()` once in your setup module.** It hands the KV binding to the admin protocol so a Studio publish writes through to KV. + +```ts title="src/setup.ts" +import { createSiteSetup } from "@decocms/blocks/setup"; +import { createAdminSetup } from "@decocms/blocks-admin/setup"; +import { setupTanstackFastDeploy } from "@decocms/tanstack"; +import { blocks } from "../.deco/blocks.gen"; +import appCss from "./styles/app.css?url"; + +createSiteSetup({ + sections: import.meta.glob("./sections/**/*.tsx"), + blocks, +}); + +createAdminSetup({ + meta: () => import("../.deco/meta.gen.json").then((m) => m.default), + css: appCss, +}); + +setupTanstackFastDeploy(); +``` + +</Step> +<Step> + +**Seed content at build time and pass the deployment id at deploy time.** With Cloudflare Workers Builds, set the build command to your build followed by a content sync: + +```bash +npx -p @decocms/blocks-cli deco-sync-blocks-to-kv --write --all --deployment-id "$WORKERS_CI_COMMIT_SHA" +``` + +and the deploy command to deploy, then mark the deployment live: + +```bash +npx wrangler deploy --var DECO_DEPLOYMENT_ID:"$WORKERS_CI_COMMIT_SHA" +``` + +```bash +npx -p @decocms/blocks-cli deco-sync-blocks-to-kv --set-live --deployment-id "$WORKERS_CI_COMMIT_SHA" +``` + +Chain each pair with `&&` in the build settings. Seeding before the deploy means new code never starts without its content. Use whatever variable your CI exposes for the commit sha; `WORKERS_CI_COMMIT_SHA` is Cloudflare Workers Builds'. + +</Step> +<Step> + +**Check it.** After a deploy, publish a change in Studio and load the page from a new browser session. `POST /.decofile` answers with `"kvWritten": true` when the write reached KV. + +</Step> +</Steps> + +<Callout type="warning"> + +**Without `setupTanstackFastDeploy()`, a Studio publish reports success but writes nothing to KV.** The change shows on the isolate that received it and disappears as others reload from KV. The same happens (with `"kvWritten": false` in the response) when no deployment id resolves. + +</Callout> + +### Credentials for the sync CLI + +`deco-sync-blocks-to-kv` writes through Cloudflare's KV REST API, so it runs anywhere, without a Worker binding. It reads: + +| Variable | Fallback | What it is | +|---|---|---| +| `CF_ACCOUNT_ID` | `CLOUDFLARE_ACCOUNT_ID` | Your Cloudflare account id. | +| `CF_API_TOKEN` | `CLOUDFLARE_API_TOKEN` | A token with **Workers KV Storage: Edit**. | +| `CF_KV_NAMESPACE_ID` | The `DECO_KV` namespace id in `wrangler.jsonc` | The namespace to write. | + +Cloudflare Workers Builds already provides the `CLOUDFLARE_*` values, and the namespace id comes from your `wrangler.jsonc`, so no extra configuration is needed there. + +Always write KV through this CLI rather than your own script. The isolates compare the snapshot's revision byte for byte with what they compute in memory; a different serialization makes them reload forever. + +### The sync CLI's options + +| Flag | What it does | +|---|---| +| `--deployment-id <id>` | The deployment to write. Required to write. | +| `--write` | Apply the changes. Without it the CLI prints what it would do. | +| `--all` | Write the full snapshot even if no block changed since `--since`. | +| `--since <ref>` | Without `--all`, skip the write when no `.deco/blocks/*.json` changed since this git ref. Default `HEAD~1`. | +| `--set-live` | Only record this deployment as the live one. Cheap; run it after the deploy is active. | +| `--blocks-dir <dir>` | Where the block files are. Default `.deco/blocks`. | +| `--retain <n>` | How many deployments' snapshots to keep. Default 10. The live one is never removed. | +| `--purge-url <origin>` | After writing, call `POST /_cache/purge` on this origin. | +| `--purge-token <token>` | The purge token. Defaults to the `PURGE_TOKEN` environment variable. | + +To seed one deployment by hand (for example the very first one), `deco-migrate-blocks-to-kv --deployment-id <sha> --write` writes `.deco/blocks/` to KV once. See [CLI reference](/v7/cli). + +### Roll back or turn it off + +- **Roll back content with code.** Redeploy an older commit. It reads its own snapshot, which the sync kept (up to `--retain` deployments). +- **Turn Fast Deploy off.** Unset `DECO_FAST_DEPLOY`, set it to `"0"`, or remove the `DECO_KV` binding. The Worker serves its bundled snapshot immediately. + +### Code that reads content at module scope + +Fast Deploy swaps the content in memory when a snapshot arrives. Code that read `loadBlocks()` once, at module scope, keeps the bundled content it saw at startup and never sees updates: + +```ts title="src/redirects.ts" +import { loadBlocks } from "@decocms/blocks/cms"; +import { loadRedirects } from "@decocms/blocks/sdk/redirects"; + +// Don't: computed once from the bundled snapshot. +// const redirects = loadRedirects(loadBlocks()); + +// Do: read inside the request path. +export function getRedirects() { + return loadRedirects(loadBlocks()); +} +``` + +The framework's own redirect handling already rebuilds whenever the content revision changes. To react to content changes in your own code, subscribe with `onChange` from `@decocms/blocks/cms` (see [Content and the decofile](/v7/content)). + +### Bundle-stub mode (advanced) + +By default an isolate holds the content twice: the bundled snapshot (the fallback) and the snapshot it loaded from KV. On sites with several megabytes of content that matters against Workers' memory limit. `decoVitePlugin({ fastDeploy: true })` removes the bundled snapshot from the server bundle so only the KV copy exists. + +<Callout type="warning"> + +**Bundle-stub mode removes the fallback.** With no bundled content, a cold start that can't read KV fails the request with a server error instead of rendering. Use it only when your pipeline always seeds the deployment's snapshot before activating it. The default, `fastDeploy: "auto"`, stubs only when the build environment sets `DECO_SEEDED_DEPLOY` (set by deployment pipelines that seed KV first); Cloudflare Workers Builds and a manual `wrangler deploy` keep the bundled snapshot. `false` never stubs. + +</Callout> + +## Deploying a Next.js site + +On Next.js, content ships with the build. The recommended setup imports every block file through the generated `.deco/blocksManifest.gen.ts`, so Next bundles the JSON with your code (see [Next.js App Router](/v7/nextjs)). A Studio publish updates a running instance's memory; commit the content and rebuild to make it permanent. Deploy as you deploy any Next.js app. + +## Keeping `.deco/blocks` in sync with production + +Studio commits content to your repository. If a site's content is also published somewhere else (for example, while a migrated site's old storefront is still the one editors publish to), `deco-sync-blocks-bot` pulls it back. It downloads `GET <origin>/.decofile` from the live site, writes one file per block into `.deco/blocks/`, and rewrites only the blocks whose content changed, so you can run it on a schedule and open a pull request with the diff. + +```bash +npx -p @decocms/blocks-cli deco-sync-blocks-bot --origin https://www.example.com --dry-run +``` + +By default it never overwrites the `Site` block or any block holding an encrypted secret, and `--fail-on-plaintext-secret` stops it if an incoming block looks like it carries a credential in the clear. Its flags are in the [CLI reference](/v7/cli). + +## Related + +- [Caching](/v7/caching): cache versioning per deploy, and purging. +- [Deco Studio and the admin protocol](/v7/studio): what `POST /.decofile` accepts and how it's authorized. +- [Configuration reference](/v7/configuration): every Fast Deploy variable in one table. +- [Troubleshooting](/v7/troubleshooting#a-studio-publish-doesn-t-show-up-with-fast-deploy) diff --git a/docs/content/v7/rendering.mdx b/docs/content/v7/rendering.mdx new file mode 100644 index 00000000..c33155c1 --- /dev/null +++ b/docs/content/v7/rendering.mdx @@ -0,0 +1,166 @@ +--- +title: Deferred sections +group: Rendering +order: 16 +description: How sections that editors mark async in Studio render as a skeleton first and load afterwards, which sections get deferred, and how to configure it. +--- + +# Deferred sections + +A slow section, such as a product shelf waiting on a search API, shouldn't hold up the rest of the page. In Studio an editor can mark any section on a page as async (the ⚡ toggle). That section becomes a **deferred section**: the server sends its skeleton with the page, and the browser fetches the real section separately, when it's about to scroll into view. The rest of the page arrives without waiting for it. + +<Terms> + <Term name="Deferred section">A section rendered first as its skeleton and loaded separately afterwards. See the [glossary](/v7/glossary#deferred-section).</Term> + <Term name="Eager section">A section resolved and rendered on the server as part of the page.</Term> + <Term name="Eager request">A request that gets every section eagerly: search-engine crawlers, audit requests and programmatic fetches.</Term> + <Term name="Fold threshold">An optional position on the page after which unmarked sections are deferred too. Off by default.</Term> +</Terms> + +## How a deferred section loads + +<Flow label="Deferred section"> + <FlowNode title="Editor marks ⚡">Studio wraps the section in the content, so the page knows it's async</FlowNode> + <FlowNode title="Server renders the page">Eager sections render; the deferred one renders its `LoadingFallback`. Its content stays on the server</FlowNode> + <FlowNode title="Skeleton nears the viewport">Within 300px of the visible area, the browser asks the server for that section</FlowNode> + <FlowNode title="Server resolves it">Resolves the section's content, runs its section loader, returns the props</FlowNode> + <FlowNode title="Section replaces the skeleton">Rendered in place, with a short fade-in</FlowNode> +</Flow> + +A deferred section's content isn't embedded in the page, which also keeps the HTML and the hydration payload smaller. When the browser asks for the section, the server looks up the content it kept from the page render, or resolves the page again to find it, so the request can land on any server instance. + +Each section's request is independent: one slow or failing section doesn't affect the others. If the request fails, the section's `ErrorFallback` renders, or nothing. + +## Wire it up + +**TanStack Start.** Two things are needed, and the [quickstart](/v7/quickstart) sets up both: + +1. `applySectionConventions` in setup. Among other things it turns async rendering on; without it, every section renders eagerly and the ⚡ toggle does nothing. See [Section conventions](/v7/sections#apply-them-in-setup). +2. `loadDeferredSectionFn={deferredSectionLoader}` on `DecoPageRenderer`, imported from `@decocms/tanstack/sdk/deferredSectionLoader`: + +```tsx title="src/routes/$.tsx (excerpt)" +<DecoPageRenderer + sections={data.resolvedSections ?? []} + deferredSections={data.deferredSections ?? []} + pagePath={data.pagePath} + pageUrl={data.pageUrl} + loadDeferredSectionFn={deferredSectionLoader} +/> +``` + +`deferredSectionLoader` calls a TanStack server function that resolves the section. It works on the first page load and after client-side navigation alike. Without it, deferred sections stay skeletons forever after a client-side navigation. + +**Next.js.** `createDecoPage` handles deferred sections without extra wiring. The server starts resolving each one immediately and streams it into the page under its own `<Suspense>` boundary, so there's no scroll trigger: everything arrives in the same response, just not all at once. `deferredTrigger` has no effect, and the per-section `LoadingFallback` isn't used for the stream. [Next.js App Router](/v7/nextjs) lists what `createDecoPage` does and doesn't run, including section loaders. + +## Which sections are deferred + +The ⚡ toggle in Studio decides, with a few exceptions. For each top-level section on a page, the runtime goes through these checks in order and stops at the first one that applies: + +1. **Eager requests get everything eagerly.** Crawlers must see the whole page, so a request from a search-engine bot is never deferred, whatever the content says. +2. **`export const deferred = true`** in the section file: deferred. +3. **Marked async (⚡) in Studio**, directly or inside a variant that matched: deferred. +4. **Layout sections** (`export const layout = true`): eager. +5. **`export const neverDefer = true`**: eager. +6. **`export const eager = true`**, when the section is before the fold threshold: eager. +7. **At or after the fold threshold**: deferred. +8. Anything else: eager. + +The fold threshold is off by default (it's `Infinity`), which makes steps 4 to 7 inert. With the defaults, a section is deferred when an editor marked it ⚡ or its file says `deferred = true`, and is eager otherwise. No code flag can make an editor's ⚡ section eager. + +Two more cases: + +- A deferred section with a `scheduling` prop whose window has closed, or not opened yet, is left out of the page entirely rather than rendered as a skeleton that turns into nothing. +- Only top-level sections of a page are deferred. Sections nested inside another section's props resolve with their parent. + +### Eager requests + +A request gets every section eagerly when any of these is true: + +- **Its user agent looks like a crawler.** The list covers the common search engines, social previews and auditing tools (Googlebot, Bingbot, Lighthouse and others). Add your own pattern with `registerBotPattern(/mycrawler/i)` from `@decocms/blocks/cms`. +- **The URL has `?__deco_ssr=1`** (or `?__bot=1`). Use it to see the crawler's version of a page from a normal browser, for SEO checks or debugging. +- **It's a programmatic fetch**, such as a `fetch()` from your own script that reads the page's HTML. Those can't run the browser-side loading, so they'd only ever see skeletons. They're detected by the `Sec-Fetch-Dest: empty` header; client-side navigations within the site are excluded. + +On TanStack Start, the edge cache keeps the eager and deferred versions of a page in separate entries. See [Caching](/v7/caching). + +<Callout type="warning"> + +**Don't mark a gate section async.** Some sections decide what the rest of the page shows: a combined product-and-category route whose loader picks which branch renders, for example. A deferred section is resolved later, in a separate request, and can't change what the page already rendered around it. Leave those sections unmarked in Studio. There's deliberately no code override that wins over ⚡, so this is an editorial rule: tell your editors which sections must stay eager. + +</Callout> + +## Configure it + +`setAsyncRenderingConfig` from `@decocms/blocks/cms` adjusts the behaviour site-wide. Call it once, at module scope, in setup: + +```ts title="src/setup.ts (excerpt)" +import { setAsyncRenderingConfig } from "@decocms/blocks/cms"; + +setAsyncRenderingConfig({ deferredTrigger: "load" }); +``` + +Each call merges with the previous settings, so the order relative to `applySectionConventions` doesn't matter. + +| Option | Type | Default | What it does | +|---|---|---|---| +| `deferredTrigger` | `"intersection" \| "load"` | `"intersection"` | When the browser fetches a deferred section. `"intersection"` waits until the skeleton is within 300px of the viewport. `"load"` fetches every deferred section as soon as the page hydrates, without waiting for scroll. TanStack Start only. | +| `respectCmsLazy` | `boolean` | `true` | Whether the ⚡ toggle defers sections. Set `false` to ignore it. | +| `foldThreshold` | `number` | `Infinity` | Defers unmarked sections from this position on (0-based, counting top-level sections). Off by default. | +| `alwaysEager` | `string[]` | `[]` | Section keys kept eager before the fold threshold, like `export const eager = true`. Merged with earlier calls. | +| `botAwareSeo` | `boolean` | `false` | Skips commerce data in the page's SEO block for human visitors. See [SEO](/v7/seo#structured-data-and-crawlers). | + +<Callout type="warning"> + +**Set `deferredTrigger` in a module the browser also loads.** The browser reads it, not the server. On TanStack Start that means `src/setup.ts`, which the router imports. Called from the Worker entry or other server-only code, the server still defers sections, but the browser falls back to `"intersection"` without any warning. + +</Callout> + +**Choosing a trigger.** `"intersection"` sends the fewest requests: a section nobody scrolls to is never fetched. Its cost is that content below the fold doesn't exist in the document until the visitor scrolls, so it can't be found with the browser's find-in-page, and impressions for it aren't tracked until then. `"load"` fills in the whole page right after hydration, which is how Deco sites on Fresh behave, at the cost of a burst of requests on every page load and every client-side navigation. Sites migrated from Fresh usually want `"load"`; pages with many heavy deferred sections usually don't. + +**The fold threshold.** A finite `foldThreshold` defers unmarked sections by position, for example everything from the fifth section on, while keeping the first sections server-rendered for a fast first paint. Use `eager`, `neverDefer` and `layout` to keep particular sections out of it, such as an interactive filter bar that needs its props during hydration. Most sites leave it off and let editors decide. + +## Skeletons and layout shift + +When a deferred section arrives, it replaces its skeleton. If the two have different heights, everything below moves, which hurts the page's Cumulative Layout Shift. Give every section that can be deferred a `LoadingFallback` with the section's final size. [Section conventions](/v7/sections#skeletons) covers what the skeleton receives on each binding. + +## Defer a single prop + +Deferring is all or nothing for a section. Sometimes only one prop is expensive and is needed only in some cases, like the "not found" sections a product page renders when the product doesn't exist. By default the resolver resolves every prop, including commerce loaders inside branches the section never shows. + +`asResolved(value, true)` tells the resolver to leave a prop alone and hand the section a function instead. The section calls `resolveDeferred` on it only in the branch that needs it. Both come from `@decocms/blocks/cms`, and `asResolved` is applied in the section's `onBeforeResolveProps` export, which receives the raw props from the content before resolution: + +```tsx title="src/sections/Product/ProductDetails.tsx" +import { asResolved, resolveDeferred } from "@decocms/blocks/cms"; +import type { ProductDetailsPage } from "@decocms/apps-commerce/types"; +import type { Section } from "@decocms/blocks/types"; + +export interface Props { + page: ProductDetailsPage | null; + notFoundSections?: Section[]; +} + +export const onBeforeResolveProps = (props: Props) => ({ + ...props, + notFoundSections: asResolved(props.notFoundSections, true), +}); + +export const loader = async (props: Props) => { + if (props.page) return { ...props, notFoundSections: [] }; + return { ...props, notFoundSections: await resolveDeferred(props.notFoundSections) }; +}; +``` + +The section's loader must be registered for this to run; see [Loaders and actions](/v7/loaders). + +<Callout type="warning"> + +**A deferred prop that's never resolved reaches the component as `undefined`.** Functions can't be sent to the browser, so the prop is dropped. Resolve it on every branch that renders it. `asResolved(value)` without `true` passes the value through untouched, without resolving anything inside it. + +</Callout> + +`resolveDeferred` also accepts a plain value, so the section works the same when the prop wasn't deferred, such as in Studio previews. + +## Next steps + +- [Section conventions](/v7/sections): `LoadingFallback`, `deferred`, `eager` and the other exports. +- [Images, scripts and UI helpers](/v7/components): `LazySection`, for deferring part of a section in the browser. +- [Caching](/v7/caching): how the edge cache treats eager and deferred page versions. +- [How resolution works](/v7/walkthrough): where deferral fits in resolving a page. diff --git a/docs/content/v7/request-context.mdx b/docs/content/v7/request-context.mdx new file mode 100644 index 00000000..54f19459 --- /dev/null +++ b/docs/content/v7/request-context.mdx @@ -0,0 +1,109 @@ +--- +title: Request context +group: Core concepts +order: 14 +description: Read the current request, its abort signal, device and request id from anywhere in server code with RequestContext, and set response headers from loaders and actions. +--- + +# Request context + +Loaders, matchers and helpers often need something about the current request (its URL, the visitor's device, whether the visitor has left) without it being passed down through every function. `RequestContext` from `@decocms/blocks/sdk/requestContext` gives server code that access. The binding opens a scope around each request, and anything that runs inside it can read the request's state. + +<Terms> + <Term name="Request scope">The span of one request, opened by the binding with `RequestContext.run(request, fn)`. Code running inside `fn`, including awaited calls, sees that request.</Term> + <Term name="Bag">A per-request key–value store for passing data from middleware to loaders.</Term> +</Terms> + +## Read the request + +```ts title="src/actions/notifyMe.ts" +import { RequestContext } from "@decocms/blocks/sdk/requestContext"; + +export interface Props { + email: string; + sku: string; +} + +export default async function notifyMe(props: Props) { + const res = await RequestContext.fetch("https://api.example.com/back-in-stock", { + method: "POST", + body: JSON.stringify(props), + headers: { "Content-Type": "application/json" }, + }); + + RequestContext.responseHeaders.append("Set-Cookie", "notified=1; Path=/; Max-Age=86400"); + return { ok: res.ok }; +} +``` + +Two things in that action come from the request context: + +- **`RequestContext.fetch`** is `fetch` with the request's abort signal attached. If the visitor disconnects, the upstream call is cancelled instead of running to completion for nobody. Pass your own `signal` to override it. +- **`RequestContext.responseHeaders`** collects headers for the response. When the action is called through `/deco/invoke`, they're copied onto the HTTP response, `Set-Cookie` values included. + +This example assumes TanStack Start, where every request runs in a scope. On Next.js, see [Who opens the scope](#who-opens-the-scope). + +## The API + +| Member | Returns | Outside a request scope | +|---|---|---| +| `RequestContext.run(request, fn)` | Runs `fn` inside a new scope for `request` | (opens one) | +| `RequestContext.current` | The scope's data, or `null` | `null` | +| `RequestContext.request` | The `Request` | Throws | +| `RequestContext.signal` | An `AbortSignal` that fires when the request is aborted | Throws | +| `RequestContext.responseHeaders` | `Headers` to add to the response | Throws | +| `RequestContext.requestId` | The request id: the incoming `x-request-id` header, else the platform's request id, else a random UUID | `null` | +| `RequestContext.device` | `"mobile"` or `"desktop"`, from the user agent | `"desktop"` | +| `RequestContext.isBot` | Whether the user agent looks like a crawler | `false` | +| `RequestContext.elapsed` | Milliseconds since the request started | `0` | +| `RequestContext.fetch(input, init?)` | `fetch` with the request's signal | Plain `fetch` | +| `RequestContext.getBag<T>(key)` / `setBag(key, value)` | A per-request value | `undefined` / no-op | +| `RequestContext.getAppState<T>(name)` | An installed app's configuration for this request | `undefined` | + +`getAppState` reads what an app put in the bag. For example, `RequestContext.getAppState<VtexState>("vtex")` returns the VTEX app's configuration, so a site loader can read the account name. See [Apps](/v7/apps). + +## Who opens the scope + +You don't call `run` yourself in a normal site. On TanStack Start, `createDecoWorkerEntry` opens a scope for every request before anything else runs, so route loaders, server functions, CMS resolution, section loaders and `/deco/invoke` handlers all see it. + +The Next.js binding doesn't open a scope, neither for page renders nor for its route handlers: React Server Components render a component's children after the call that started them returns, so a scope wrapped around the render wouldn't reach them. In Next.js server code, read request data with Next's own `headers()` and `cookies()`, and treat `RequestContext` accessors as being outside a scope: use `RequestContext.current` (which can be `null`) rather than the throwing getters. That includes loaders and actions called through `/deco/invoke`, where `RequestContext.responseHeaders` throws; return a `Response` from the handler instead when you need to set a cookie, since a single `/deco/invoke/<key>` call passes a returned `Response` through unchanged. + +If you write your own server or a test, wrap the work yourself: + +```ts title="A test or custom server" +import { RequestContext } from "@decocms/blocks/sdk/requestContext"; + +const response = await RequestContext.run(request, () => handle(request)); +``` + +<Callout type="warning"> + +**Don't read `request`, `signal` or `responseHeaders` at module load.** Top-level code runs outside any request, so those getters throw. Read them inside the function that handles the request. + +**Don't keep request data in module-level variables.** One server instance handles many requests at once; anything specific to a visitor belongs in the request scope (the bag) or in function arguments. + +</Callout> + +## In the browser + +`RequestContext` is safe to import from code that also ends up in the browser bundle. Its storage is published with conditional exports: server runtimes (Cloudflare Workers, Node.js) get the real implementation built on `AsyncLocalStorage`, and browser bundles get a stub with the same shape that never holds a scope. In the browser every accessor behaves as outside a request scope: `current` is `null`, `device` is `"desktop"`, and the throwing getters throw. + +Don't import `node:async_hooks` (where `AsyncLocalStorage` lives) directly in code that can reach a `"use client"` file or a browser bundle; the browser build fails. Go through `RequestContext` instead. + +## On Cloudflare Workers + +`AsyncLocalStorage` is a Node.js API. On Workers it's available with the `nodejs_compat` compatibility flag, so your `wrangler.jsonc` needs it: + +```jsonc title="wrangler.jsonc (excerpt)" +{ + "compatibility_flags": ["nodejs_compat", "no_handle_cross_request_promise_resolution"] +} +``` + +Without the flag, request-scoped features (cookie forwarding, abort signals, device detection) stop working. + +## Next steps + +- [Loaders and actions](/v7/loaders): where request context is most used. +- [Matchers and variants](/v7/variants): the matcher context, a separate object built from the same request. +- [How v7 is built](/v7/internals): why the storage uses conditional exports. diff --git a/docs/content/v7/request-pipeline.mdx b/docs/content/v7/request-pipeline.mdx new file mode 100644 index 00000000..ec346f30 --- /dev/null +++ b/docs/content/v7/request-pipeline.mdx @@ -0,0 +1,86 @@ +--- +title: The Worker request pipeline +nav: Worker pipeline +group: Under the hood +kind: internals +order: 3 +description: Everything createDecoWorkerEntry does with a request on TanStack Start, in order, from opening the request scope to the edge cache and the response headers. +--- + +# The Worker request pipeline + +On TanStack Start, every request reaches your site through `createDecoWorkerEntry` before TanStack sees it (see [TanStack Start on Cloudflare Workers](/v7/tanstack#the-worker-entry)). This page lists what it does, in order. It's useful when a request behaves unexpectedly: a page served from cache when you expected a fresh one, an admin route answered by your app, a redirect that doesn't fire. + +The pipeline has three parts: preparing the request, routing it (most branches answer and stop), and finishing the response. TanStack Start renders a page only when a request reaches the last branches of the routing part. + +## 1. Preparing the request + +These steps run for every request: + +1. **Location cookies.** With `autoInjectGeoCookies` (the default), Cloudflare's geolocation for the visitor is copied into request cookies that matchers read. They exist only inside the Worker and are never sent to the browser. +2. **Request scope.** Everything after this runs inside a [request context](/v7/request-context) for this request, so loaders and helpers anywhere can read it. +3. **Content.** With [Fast Deploy](/v7/releases#fast-deploy) on, the first request in a Worker instance loads the current decofile from KV, and the instance checks for a newer revision at most every 10 seconds. Without it, the content bundled with the build is used. +4. **Apps.** Installed [apps](/v7/apps) are configured from their blocks the first time, and again after the content changes. +5. **Tracing.** The request gets an id (the incoming `x-request-id`, or a new one) and joins the caller's trace when a `traceparent` header is present. `?__d` forces this request's trace to be recorded. +6. **Draft preview.** If the request carries a valid draft link or cookie for an allowed host, the draft's content is used for this request only. See [Draft preview](/v7/preview#draft-preview). +7. **Security nonce.** With `cspMode: "enforce"`, a per-request nonce is generated for inline scripts. +8. **App middleware.** Apps that ship middleware (VTEX, for example) wrap the routing step, so they can read and set cookies around everything that follows. + +## 2. Routing + +The Worker tries each branch in this order. The first one that answers ends the routing. + +1. **Admin protocol.** `/live/_meta`, `/.decofile`, `/live/previews/*` and the liveness check go to the `admin` handlers you passed in. They're never cached. See [Deco Studio and the admin protocol](/v7/studio). +2. **Purging.** `POST /_cache/purge` and `POST /_cache/purge-loaders`, with the purge token. See [Caching](/v7/caching#purging). +3. **CMS redirects.** Redirect blocks from the content, and with Fast Deploy also the redirects stored in KV. Exact paths are checked before patterns, across both sources. A match answers with its status (301 or 302) and `Location`. See [Pages and routing](/v7/routing). +4. **Page JSON.** `?renderJson` and `?asJson` on a page URL return the resolved page as JSON instead of HTML, unless turned off. See [Storefront as an API](/v7/storefront-api). +5. **Your proxy.** `proxyHandler`, if set, gets a chance to answer, typically by forwarding checkout and account paths to the commerce platform. +6. **Static assets.** Fingerprinted build assets are served with a one-year immutable cache. An asset path that comes back as HTML (a missing file falling through to the app) becomes a 404. +7. **Server functions.** `POST` requests to TanStack's server-function endpoints carry page data and deferred sections. They're cached at the edge with the `listing` profile, keyed on a hash of the request body plus the dimensions listed below, and only when the response marks itself cacheable and sets no cookie. Draft, logged-in and matcher-override requests bypass the cache. +8. **Requests that aren't cacheable.** Anything other than `GET`, the bypass paths (`/deco/`, `/live/`, `/.decofile`, `/_build` and your own), draft and preview requests, and requests that force matcher results go straight to TanStack. If the URL's [cache profile](/v7/caching#cache-profiles) is `private`, `none` or `cart`, the response is marked `no-store`. +9. **Cacheable pages.** Everything else is a cacheable `GET`, handled by the edge cache in the next section. + +## 3. The edge cache + +For a cacheable request, the Worker builds a cache key, then looks it up in Cloudflare's Cache API: + +- **Logged-in visitors** (`loggedIn: true` from `buildSegment`) skip the cache entirely and always get a fresh render. +- **Fresh hit** (`X-Cache: HIT`): the stored response is returned. +- **Stale hit** (`X-Cache: STALE-HIT`): within the profile's stale-while-revalidate window, the stored response is returned at once and a fresh one is rendered in the background to replace it. +- **Miss** (`X-Cache: MISS`): TanStack renders the page. The result is stored only if it's a `200`, isn't degraded, sets no cookies other than the safe ones (which are removed from the stored copy), its profile is public, and the request carried no tracking parameters. +- **Origin failure.** If rendering throws, returns a 5xx or a 429, or produces a degraded page, and a stored copy is still within the profile's stale-if-error window, that copy is served (`X-Cache: STALE-ERROR`). Otherwise the response passes through uncached. + +The cache is skipped in local development. [Caching](/v7/caching#reading-the-cache-headers) explains every `X-Cache` and `X-Cache-Reason` value. + +### What the cache key includes + +Two requests share a cached response only if all of these are the same: + +| Dimension | Notes | +|---|---| +| The URL | Path and query string, with tracking parameters (`utm_*`, `gclid` and the like) removed. | +| The deploy | Each build gets its own cache namespace, from `BUILD_HASH` or the hash the Vite plugin injects. A deploy never serves the previous build's pages. | +| The segment | What `buildSegment` returns (device, sales channel, region, flags and custom keys). Without `buildSegment`, the device class, unless `deviceSpecificKeys` is `false`. | +| Location | Added when `geoCacheKey` asks for it, or in `"auto"` mode when the content uses the location matcher. | +| Crawler or human | Crawlers get every section server-rendered, so they have their own entries. | +| Programmatic fetch | Requests with `Sec-Fetch-Dest: empty` (a script fetching the page) also get everything server-rendered, and their own entries. | +| A/B cohort | The visitor's assigned variants from random-split tests. | + +Cookies, other headers and the logged-in visitor's identity are never part of the key, which is why logged-in visitors bypass the cache and why responses that set private cookies aren't stored. + +## 4. Finishing the response + +Whatever branch answered, the response then gets: + +1. **Cookie cleanup.** Duplicate `Set-Cookie` headers for the same cookie are reduced to the last one. +2. **CDN instructions.** A `CDN-Cache-Control` header for Cloudflare's CDN in front of the Worker. Bypassed responses, and any without one, get `no-store`. See [Caching](/v7/caching#the-cdn-in-front-of-the-worker). +3. **Security headers** on HTML responses, from `securityHeaders` and `csp`, including the `frame-ancestors` policy that lets Studio frame the site. +4. **Identification:** `x-request-id`, `x-trace-id` when the request was traced, and `x-powered-by`. +5. **Draft cookies.** Setting or clearing the draft-preview cookie, and the headers that keep draft responses out of caches and search indexes. +6. **Telemetry.** A request metric with the method, status, duration and cache decision. With `DECO_OTEL_*` endpoints configured, traces, metrics and logs are exported; see [Observability](/v7/observability). + +## Next steps + +- [TanStack Start on Cloudflare Workers](/v7/tanstack): every option of `createDecoWorkerEntry`. +- [Caching](/v7/caching): profiles, segments and purging in depth. +- [How resolution works](/v7/walkthrough): what happens when TanStack renders a page. diff --git a/docs/content/v7/resend.mdx b/docs/content/v7/resend.mdx new file mode 100644 index 00000000..e0542718 --- /dev/null +++ b/docs/content/v7/resend.mdx @@ -0,0 +1,100 @@ +--- +title: Resend +group: Apps +order: 35 +description: Send transactional email, such as contact-form messages, through Resend. +--- + +# Resend + +`@decocms/apps-resend` sends email through [Resend](https://resend.com)'s HTTP API. The typical use is a contact form: editors set the API key, sender, recipients and subject in Studio, and your site sends a message with one call. It has a single action, `sendEmail`. + +```bash +bun add @decocms/apps-resend +``` + +## Configuring + +Editors configure the app in a `deco-resend` block: + +| Field | What it does | +|---|---| +| `apiKey` | Your Resend API key: plain text or an encrypted secret. Falls back to the `RESEND_API_KEY` environment variable. | +| `emailFrom.name` | The sender's display name. Defaults to `Contact`. | +| `emailFrom.domain` | The sender's **full address**, such as `hello@store.example.com`, despite the field's name. It's placed inside the angle brackets of `Name <address>`. Defaults to `onboarding@resend.dev`, Resend's test sender. The address must be on a domain you've [verified in Resend](https://resend.com/docs/dashboard/domains/introduction). | +| `emailTo` | Default recipients, as a list of addresses. | +| `subject` | Default subject. | + +`configure` returns `null` when no API key resolves, and the app isn't installed: `sendEmail` then throws `Resend not configured`. Install it with the registry entry (see [Apps](/v7/apps#installing-apps-with-autoconfig)): + +```ts title="src/setup/apps.ts" +import { autoconfigApps, type AppRegistry } from "@decocms/blocks-admin/apps"; +import { loadBlocks } from "@decocms/blocks/cms"; +import { RESEND_REGISTRY_ENTRY } from "@decocms/apps-resend/registry"; +import * as resendMod from "@decocms/apps-resend/mod"; + +const APP_REGISTRY: AppRegistry = [{ ...RESEND_REGISTRY_ENTRY, module: async () => resendMod }]; + +await autoconfigApps(loadBlocks(), APP_REGISTRY); +``` + +To configure it from code instead, call `configureResend` once at module scope. You then resolve the key yourself; this path doesn't decrypt secrets: + +```ts title="src/setup/resend.ts" +import { configureResend } from "@decocms/apps-resend/client"; + +configureResend({ + apiKey: process.env.RESEND_API_KEY ?? "", + emailFrom: "My Store <hello@store.example.com>", + emailTo: ["team@store.example.com"], + subject: "Contact form submission", +}); +``` + +`ResendConfig.emailFrom` here is the complete `Name <address>` string. + +## Sending email + +`sendEmail(payload)` merges what you pass over the configured defaults and posts the message to Resend: + +| Field | Default | +|---|---| +| `from` | The configured sender, else `Contact <onboarding@resend.dev>` | +| `to` | The configured `emailTo`, else an empty list (which Resend rejects) | +| `subject` | The configured subject, else `No subject` | +| `html`, `text`, `cc`, `bcc`, `reply_to`, `headers` | Sent only when you set them | + +It never throws for an error Resend reports. It always resolves to `{ data, error }`: + +```ts title="src/actions/contact.ts" +import { sendEmail } from "@decocms/apps-resend"; + +export interface Props { + name: string; + email: string; + message: string; +} + +export default async function contact(props: Props) { + const { data, error } = await sendEmail({ + subject: `Message from ${props.name}`, + reply_to: props.email, + text: props.message, + }); + if (error) return { ok: false, reason: error.name }; + return { ok: true, id: data?.id }; +} +``` + +On success `data` is `{ id }` and `error` is `null`. On failure `data` is `null` and `error` is Resend's `{ message, name }`, where `name` is one of Resend's error codes, such as `validation_error`, `invalid_from_address`, `rate_limit_exceeded` or `missing_api_key` (the full list is `RESEND_ERROR_CODES_BY_KEY` in `@decocms/apps-resend/types`). Resend limits how fast you can send; see its [rate limits](https://resend.com/docs/api-reference/rate-limit). Network failures and timeouts, after 10 seconds, still throw. Calling `sendEmail` before the app is configured throws `Resend not configured`. + +To build HTML emails as React components, render them on the server with [React Email](https://react.email) and pass the result as `html`. + +## Calling it from a form + +Call `sendEmail` from server code you own, not from the browser. For a form, write a small action like `contact` above: it fixes the recipients and subject on the server, accepts only the fields the form needs, and is the place to add validation and spam protection. Put it in `src/actions/` and the [generate](/v7/generate) command makes it callable as `site/actions/contact` (see [Loaders and actions](/v7/loaders)). + +## Related + +- [Apps](/v7/apps): installing apps and secrets. +- [Loaders and actions](/v7/loaders): site actions and invoke. diff --git a/docs/content/v7/routing.mdx b/docs/content/v7/routing.mdx new file mode 100644 index 00000000..626aa192 --- /dev/null +++ b/docs/content/v7/routing.mdx @@ -0,0 +1,174 @@ +--- +title: Pages and routing +nav: Pages & routing +group: Core concepts +order: 8 +description: How a URL finds a page block in v7, the path pattern syntax, redirects, sitemaps, and the route helpers in each binding. +--- + +# Pages and routing + +In a v7 site, editors create pages, not developers. A page is a block in the decofile with a URL pattern and a list of sections, and one catch-all route in your app renders whichever page matches the request. This page explains what makes a block a page, how patterns are matched and ranked, how URL parameters reach sections, and how redirects and sitemaps fit in. + +<Terms> + <Term name="Page block">A block with a `path` and `sections`, whose name starts with `pages-` or whose `__resolveType` is `website/pages/Page.tsx`. See the [glossary](/v7/glossary#page-block).</Term> + <Term name="Path pattern">The page's `path`, matched against the request path with the `URLPattern` syntax, for example `/:slug/p`.</Term> + <Term name="Route params">The values a pattern captures, such as `slug`, available to content through `requestToParam`.</Term> +</Terms> + +## What makes a block a page + +A block is a page when both are true: + +- its name starts with `pages-`, **or** its `__resolveType` is `website/pages/Page.tsx`; +- it has a `path` and a `sections` array. + +```json title=".deco/blocks/pages-summer-sale.json" +{ + "__resolveType": "website/pages/Page.tsx", + "name": "Summer sale", + "path": "/summer-sale", + "sections": [{ "__resolveType": "site/sections/Hero.tsx", "title": "Summer sale" }], + "seo": { "__resolveType": "website/sections/Seo/SeoV2.tsx", "title": "Summer sale" } +} +``` + +`name` is used as a fallback title. The optional `seo` holds the page's SEO section; it's always resolved up front so crawlers see it. See [SEO](/v7/seo). + +The block's name (`pages-summer-sale`) is only an identifier. The URL comes from `path`, so editors can change a page's URL without renaming anything. + +## Path patterns + +`path` is matched with the web-standard `URLPattern` API, the same syntax browsers and Cloudflare Workers support. The common forms: + +| Pattern | Matches | Captures | +|---|---|---| +| `/summer-sale` | Exactly `/summer-sale` | Nothing | +| `/:slug/p` | `/blue-shirt/p` | `slug = "blue-shirt"` | +| `/:category/:slug/p` | `/men/blue-shirt/p` | `category`, `slug` | +| `/:slug([\w-]+)` | `/blue-shirt`, not `/blue.shirt` | `slug`, restricted by the regular expression | +| `/blog{/:page}?` | `/blog` and `/blog/2` | `page` when present | +| `/*` | Any path | The rest of the path as `0` | + +Pattern matching needs `URLPattern`, which Cloudflare Workers, browsers, Deno and Node.js 24+ provide. On an older Node.js, matching throws an error that says so, rather than quietly returning no page. + +## Which page wins + +Several patterns can match the same path: `/men/blue-shirt/p` matches both `/:category/:slug/p` and `/*`. The runtime sorts page blocks by how specific their pattern is and uses the first match: + +1. Patterns without wildcards (no `*`, `{…}?` or other optional parts) come before patterns with them. +2. Then, more literal segments first (`/summer-sale` before `/:slug`). +3. Then, more parameter segments first. + +With these four pages: + +| Page | `path` | +|---|---| +| `pages-home` | `/` | +| `pages-summer-sale` | `/summer-sale` | +| `pages-product` | `/:slug/p` | +| `pages-catalog` | `/*` | + +`/summer-sale` renders `pages-summer-sale`, `/blue-shirt/p` renders `pages-product` with `slug = "blue-shirt"`, and `/men/shirts` falls through to `pages-catalog`. A path no pattern matches has no page, and the binding renders its not-found response. + +## Use route params in content + +A product page needs the slug from its URL. A value of type `website/functions/requestToParam.ts` resolves to one of the matched page's params: + +```json title="Inside pages-product" +{ + "__resolveType": "site/sections/Product/ProductDetails.tsx", + "page": { + "__resolveType": "vtex/loaders/intelligentSearch/productDetailsPage.ts", + "slug": { "__resolveType": "website/functions/requestToParam.ts", "param": "slug" } + } +} +``` + +During resolution, `slug` becomes `"blue-shirt"` before the loader runs. Commerce loaders also receive the page's path and full URL automatically (as `__pagePath` and `__pageUrl`), and many of them read the slug or search terms from there; see [Loaders and actions](/v7/loaders). + +## Resolve a page yourself + +The bindings do this for you, but the underlying calls are public in `@decocms/blocks/cms`: + +| Function | Returns | +|---|---| +| `findPageByPath(path)` | `{ page, params, blockKey }` for the best match, or `null`. No resolution. | +| `getAllPages()` | Every page block, sorted most specific first. | +| `resolveDecoPage(path, matcherCtx?)` | The fully resolved page, or `null` when no page matches. | + +`resolveDecoPage` returns: + +| Field | What it is | +|---|---| +| `name`, `path`, `params`, `blockKey` | The page's name, the requested path, the captured params and the block's name. | +| `resolvedSections` | The sections to render now, each `{ component, props, key, index }`. | +| `deferredSections` | Sections marked async in Studio, to be loaded after the page. See [Deferred sections](/v7/rendering). | +| `seoSection` | The resolved `seo` section, if the page has one. | + +`matcherCtx` carries the request (URL, cookies, headers, user agent) so [matchers](/v7/variants) can pick variants. Without it, matchers that depend on the request can't see one. + +## In TanStack Start + +`cmsRouteConfig` and `cmsHomeRouteConfig` from `@decocms/tanstack` return the options for your `/$` (catch-all) and `/` routes. You spread them into `createFileRoute` and add the component. The [quickstart](/v7/quickstart#8-add-the-routes) shows both files. + +The route's loader calls a server function that resolves the page, runs section loaders and resolves the Site block's global sections. It returns `resolvedSections`, `deferredSections`, `pagePath`, `pageUrl`, the SEO and the cache profile, or `null` when no page matches. The route also sets cache headers and a `<head>` from the page's SEO. + +| Option (`cmsRouteConfig`) | Type | Default | What it does | +|---|---|---|---| +| `siteName` | `string` | required | Used in page titles (`<name> \| <siteName>`). | +| `defaultTitle` | `string` | required | Title when nothing else provides one. | +| `defaultDescription` | `string` | none | Description when the page has none. | +| `ignoreSearchParams` | `string[]` | `["skuId"]` | Search params that don't trigger a new page load when they change. | +| `pendingComponent` | component | none | Shown during slow navigations. Without it the previous page stays until the new one is ready. | +| `pendingMs` / `pendingMinMs` | `number` | `200` / `300` | When the pending component appears, and its minimum time on screen. | +| `errorComponent` | component | built-in | Shown when loading the page throws. The built-in page is in Portuguese; pass your own. | +| `ssr` | `boolean \| "data-only"` | full SSR | TanStack's SSR mode for this route. | +| `resolveGlobals` | `boolean` | `true` | Merge the Site block's global sections into every page. | + +`cmsHomeRouteConfig` takes `defaultTitle`, `defaultDescription`, `siteName` (defaults to `defaultTitle`), the pending options, `errorComponent` and `resolveGlobals`. + +### Your own routes + +The CMS catch-all doesn't stop you from adding ordinary TanStack routes. A file such as `src/routes/store-locator.tsx` or `src/routes/api/feed.ts` takes precedence over `/$`, because TanStack Router [matches the most specific route first](https://tanstack.com/router/latest/docs/routing/route-matching); every other URL still reaches the CMS. Paths under `/api/` get the `none` [cache profile](/v7/caching#how-a-url-gets-its-profile), so they're never cached at the edge. On a VTEX site, the checkout proxy claims `/checkout`, `/account`, `/api/` and other paths before routing, so pick paths it doesn't claim or exclude yours from it (see [VTEX](/v7/vtex#wiring-the-worker)). Avoid the paths of the admin protocol (`/live/_meta`, `/.decofile` and `/deco/*`), which the Worker entry and the [admin routes](/v7/tanstack#admin-routes) serve. + +## In Next.js + +`createDecoPage({ siteName })` from `@decocms/nextjs` returns a page component and `generateMetadata` for `app/[[...slug]]/page.tsx`. It builds the path from the slug segments, resolves the page, renders it, and calls Next's `notFound()` when nothing matches. See the [Next.js quickstart](/v7/quickstart-nextjs#10-render-cms-pages). + +`createDecoPage` doesn't run section loaders and resolves without request details, so request-dependent matchers (cookies, device) don't apply. Sites that need either write their own page with `resolveDecoPage`, `runSectionLoaders` and `extractSeoFromSections`; [Next.js App Router](/v7/nextjs) shows how. + +## Redirects + +Redirect blocks (`website/loaders/redirect.ts` and `website/loaders/redirects.ts`, plus CSV files turned into blocks by `generate`) are described in [Content and the decofile](/v7/content#redirects-in-content). On TanStack Start, the Worker entry applies them before rendering: + +- Exact rules are checked before prefix rules (a `from` ending in `*`). +- `"type": "permanent"` answers 301; anything else answers 302. +- The redirect map is rebuilt whenever content changes. + +The Next.js binding doesn't apply CMS redirects. Use Next's `redirects` config, or match them yourself with `loadRedirects(loadBlocks())` and `matchRedirect(path, map)` from `@decocms/blocks/sdk/redirects`. + +## Sitemaps + +`@decocms/blocks/sdk/sitemap` builds a sitemap from your page blocks. `getCMSSitemapEntries(origin)` returns one entry per page whose path has no parameters or wildcards (a product pattern like `/:slug/p` can't be listed without data), and `generateSitemapXml(entries)` renders the XML. Here's a Next.js route: + +```ts title="src/app/sitemap.xml/route.ts" +import { generateSitemapXml, getCMSSitemapEntries } from "@decocms/blocks/sdk/sitemap"; +import { ensureSetup } from "../../deco/setup"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + await ensureSetup(); + const xml = generateSitemapXml(getCMSSitemapEntries("https://www.example.com")); + return new Response(xml, { headers: { "Content-Type": "application/xml" } }); +} +``` + +The home page gets `daily` and priority 1.0, other pages `weekly` and 0.7; pass options to change them. Commerce apps can add product URLs; for VTEX, see [VTEX](/v7/vtex). + +## Next steps + +- [Loaders and actions](/v7/loaders): data for the sections on a page. +- [Matchers and variants](/v7/variants): different content for different visitors on the same path. +- [SEO](/v7/seo): titles, descriptions and structured data. diff --git a/docs/content/v7/salesforce.mdx b/docs/content/v7/salesforce.mdx new file mode 100644 index 00000000..ca59c80e --- /dev/null +++ b/docs/content/v7/salesforce.mdx @@ -0,0 +1,109 @@ +--- +title: Salesforce Personalization +nav: Salesforce +group: Apps +order: 32 +description: Product recommendations from Salesforce Marketing Cloud Personalization, as three stateless loaders. +--- + +# Salesforce Personalization + +`@decocms/apps-salesforce` brings product recommendations from **Salesforce Marketing Cloud Personalization** (the product formerly called Evergage) into your sections. It has three loaders: one for a campaign's product list, one for recommendations related to the product on the current page, and one for cross-sells based on the visitor's cart. Each returns products in the shared [commerce shape](/v7/apps-commerce), so your existing shelf sections can render them. + +<Callout>**Not a Commerce Cloud integration.** The package's own description mentions Salesforce Commerce Cloud, but it contains no catalog, cart or checkout for Commerce Cloud. It covers Marketing Cloud Personalization recommendations only, and you pair it with the commerce app that runs your store.</Callout> + +```bash +bun add @decocms/apps-salesforce @decocms/apps-commerce +``` + +The package also lists `@tanstack/react-start` as a peer dependency, because it reads the visitor's cookie through it (see [Who the visitor is](#who-the-visitor-is)). + +## No configuration step + +The app has no block, no `configure` and no registry entry. Each loader takes everything it needs as props, so editors set them on the block in Studio, and one site can use several datasets or campaigns at once: + +| Prop | What it does | +|---|---| +| `baseUrl` | Your Personalization endpoint, such as `https://<account>.us-5.evergage.com`. | +| `dataset` | The Personalization dataset. | +| `campaignId` | The campaign whose products you want. If the response has several campaigns, the matching one is used, or else the first. | +| `cookieName` | The name of the cookie the Personalization script sets in the browser, such as `_evga_<account>`. | +| `currencyCode` | Optional ISO 4217 currency for prices. Defaults to each product's own currency. | +| `propertyMapper` | Optional function that turns a raw product into extra `additionalProperty` values (see below). Set from code, not from Studio. | + +Register the loaders under keys your content refers to (see [Loaders and actions](/v7/loaders)): + +```ts title="src/setup/commerce-loaders.ts" +import { registerCommerceLoaders } from "@decocms/blocks/cms"; +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"; + +registerCommerceLoaders({ + "salesforce/loaders/products/list.ts": list, + "salesforce/loaders/products/listRecomended.ts": listRecomended, + "salesforce/loaders/products/listCart.ts": listCart, +}); +``` + +## The three loaders + +| Import | Extra props | Sends to Personalization | +|---|---|---| +| `@decocms/apps-salesforce/loaders/products/list` | — | A campaign request for the visitor. | +| `@decocms/apps-salesforce/loaders/products/listRecomended` | `productId`: the page's resolved `ProductDetailsPage` (despite the name) | The product's SKU as the viewed product. | +| `@decocms/apps-salesforce/loaders/products/listCart` | `items`: a list of `{ sku, qty, price }` from your cart; `title`: a fallback heading | A "replace cart" interaction with those items. Returns `null` when `items` is empty. | + +Note the spelling `listRecomended`: that's the real module name. + +Each returns: + +```json title="Result shape" +{ + "@type": "ProductList", + "list": ["…Product objects…"], + "additionalData": { + "title": "Picked for you", + "campaignId": "…", + "experienceId": "…", + "userGroup": "…" + } +} +``` + +`title` comes from the campaign's header text (or, for `listCart`, your fallback `title`). The loaders never throw: on any error they log it and return `null`, so a recommendations shelf just disappears instead of breaking the page. + +## Who the visitor is + +The loaders identify the visitor from the cookie named by `cookieName`. A signed-in visitor's persistent id wins over the anonymous one, so recommendations follow them across devices. When there's no cookie, or it can't be read, the request is sent as `anonymous`, and Personalization still answers with its default campaign. + +<Callout type="warning">The cookie is read through TanStack Start's request APIs. On other frameworks, such as Next.js, every visitor is treated as anonymous.</Callout> + +## Shaping products + +Products are mapped to the shared `Product` type: `productID` (a cross-system `idMagento` field wins over the Personalization id when present), `sku`, name, URL, images and an offer with the regular and sale price. To expose dataset-specific columns, such as brand or product line, as `additionalProperty` values, pass a `propertyMapper`. The simplest way is a wrapper loader: + +```ts title="src/loaders/recommendations.ts" +import list, { type SalesforceListLoaderProps } from "@decocms/apps-salesforce/loaders/products/list"; + +export default function recommendations(props: SalesforceListLoaderProps) { + return list({ + ...props, + propertyMapper: (product) => [ + { "@type": "PropertyValue", name: "brand", value: String(product.brand ?? "") }, + ], + }); +} +``` + +`createProductTransformer({ propertyMapper })`, from the package root, builds the same mapping for your own code. + +## Observability + +This is the one commerce app that's instrumented without any setup: its HTTP client uses the instrumented fetch by default, so upstream timings appear under `provider: "salesforce"`. If you need a custom fetch, wrap it so it stays measured: `createHttpClient({ base, fetcher: createSalesforceFetch({ baseFetch: myFetch }) })`. See [Observability](/v7/observability). + +## Related + +- [Apps](/v7/apps): all apps and their status. +- [Commerce types and utilities](/v7/apps-commerce): the `Product` type. +- [Loaders and actions](/v7/loaders): registering commerce loaders. diff --git a/docs/content/v7/schema.mdx b/docs/content/v7/schema.mdx new file mode 100644 index 00000000..98f0bdfc --- /dev/null +++ b/docs/content/v7/schema.mdx @@ -0,0 +1,139 @@ +--- +title: Schema generation +group: Core concepts +order: 11 +description: How Studio's forms are generated from your TypeScript Props types and JSDoc tags, and how the schema is served to Studio. +--- + +# Schema generation + +Studio never asks you to describe a form. It reads your sections' and loaders' TypeScript types and builds one input per field. The bridge is a JSON Schema file, `.deco/meta.gen.json`, that the code generator writes from your source and your site serves to Studio. This page explains what goes into it, which JSDoc tags shape the form, and how the file reaches Studio. + +<Terms> + <Term name="Schema (meta)">The JSON Schema of your sections, loaders and pages, written to `.deco/meta.gen.json` and served at `/live/_meta`. See the [glossary](/v7/glossary#schema-meta).</Term> + <Term name="Props type">The type the generator reads for a section or loader: its exported `Props`, or the input type of its loader.</Term> + <Term name="Widget">A type alias such as `ImageWidget` that tells Studio which input to show for a string.</Term> +</Terms> + +## From types to forms + +<Flow label="Schema path"> + <FlowNode title="Your types">`Props` in `src/sections`, `src/loaders` and the apps you've installed</FlowNode> + <FlowNode title={<><code>generate</code></>}>Reads them with the TypeScript compiler and writes `.deco/meta.gen.json`</FlowNode> + <FlowNode title="Your site">Serves the schema at `/live/_meta` with a content ETag</FlowNode> + <FlowNode title="Studio">Builds forms, pickers and validation from it</FlowNode> +</Flow> + +The schema step is part of `generate`, so the command you already run produces it: + +```bash +bun run generate +``` + +`generate` runs the schema step whenever a TypeScript file under `src/`, your `tsconfig.json` or an installed app changes; it needs both a `tsconfig.json` and a sections folder. In TanStack development, the Vite plugin regenerates the schema half a second after you save a source file. See [Code generation](/v7/generate) for flags such as `--only schema`. + +## What the generator reads + +For each file under `src/sections/` and `src/loaders/`, and for the loaders of installed apps, the generator finds a props type and turns it into a JSON Schema definition. It also adds the framework's own types (the page block, matchers, the section picker), so the file Studio receives is complete on its own. + +For a section, the props type is the first of these that exists: + +1. The input type of a `loader` exported from the same file. If the section has a loader, editors fill in the loader's input, not the component's props. +2. An exported `Props` interface or type alias. +3. A `Props` re-exported from another file (followed up to three hops). +4. The type of the default export's first parameter. + +Each definition is keyed by namespace and path: `site/sections/Hero.tsx`, `site/loaders/storeHours.ts`. The namespace is `site` unless you pass `--namespace`. Test, spec, story and `.gen` files are skipped. + +## Shape the form with JSDoc + +JSDoc tags on a field become JSON Schema keywords. Write each tag on its own line: + +```tsx title="src/sections/ProductShelf.tsx (excerpt)" +export interface Props { + /** + * @title Shelf title + * @description Shown above the products. + * @default Best sellers + */ + title: string; + /** + * @title Products per row + * @minimum 2 + * @maximum 6 + * @default 4 + */ + perRow?: number; + /** + * @title Background color + * @format color + */ + background?: string; + /** @hide true */ + trackingId?: string; +} +``` + +| Tag | Effect | +|---|---| +| `@title`, `@description` | The field's label and help text. | +| `@default` | The default value. Parsed as JSON, a number or a boolean when it looks like one, otherwise kept as text. | +| `@examples` | Example values, one per line or a JSON array. | +| `@minimum`, `@maximum`, `@exclusiveMinimum`, `@exclusiveMaximum`, `@multipleOf` | Number limits. | +| `@minLength`, `@maxLength` | String length limits. | +| `@minItems`, `@maxItems` | Array length limits. | +| `@minProperties`, `@maxProperties` | Object size limits. | +| `@readOnly`, `@writeOnly`, `@deprecated`, `@uniqueItems` | Boolean keywords; write `true` after the tag. | +| `@format` | Picks a specialized input: `color`, `image-uri`, `rich-text`, `textarea`, `date-time`, `code` and others. | +| `@hide` | Keeps the field in the schema but hides it in Studio. | +| `@ignore` | Leaves the field out. | +| Anything else (`@titleBy`, `@icon`, `@label`, `@options`, `@pattern`, `@placeholder`, …) | Copied into the schema as written, for Studio to use. | + +`@titleBy` names the field to use as the label of each item in a list, so a list of banners shows each banner's `alt` instead of "Item 1, Item 2". + +## Widget types + +A widget type is a `string` alias that sets `format` for you. Import them from `@decocms/blocks/types/widgets`: + +| Type | Format | Studio input | +|---|---|---| +| `ImageWidget` | `image-uri` | Image upload and picker | +| `VideoWidget` | `video-uri` | Video upload | +| `HTMLWidget` | `html` | HTML editor | +| `RichText` | `rich-text` | Rich text editor | +| `TextArea` | `textarea` | Multi-line text | +| `Color` | `color` | Color picker | +| `Secret` | `password` | Password field | + +For the other formats, such as `code` and `date-time`, use `@format` on a plain `string`. + +Unions of string or number literals become dropdowns, optional fields become optional inputs, arrays become repeatable lists, and nested interfaces become grouped fields. A field whose type is written `Section` and resolves to `any` becomes a section picker (see [Blocks and sections](/v7/model#nest-sections-inside-sections)). + +## Serve the schema + +The site serves the schema at `GET /live/_meta`. You point the admin side at the generated file in setup: + +```ts title="src/setup.ts (TanStack, excerpt)" +import { createAdminSetup } from "@decocms/blocks-admin/setup"; + +createAdminSetup({ + meta: () => import("../.deco/meta.gen.json").then((m) => m.default), + css: appCss, +}); +``` + +On Next.js, pass the same function as `createNextSetup({ meta })`. + +Keep `meta` a dynamic `import()`. The schema can be large, and only Studio needs it, so it's loaded on the first `/live/_meta` request rather than when the server starts. A static import would load it into every server instance at boot. + +When Studio asks for it, the site adds the framework's definitions with `composeMeta` (a no-op for a schema `generate` already composed), computes an ETag from its content, and answers `304 Not Modified` when Studio already has that version. Publishing new content resets the ETag and rebuilds the schema on the next request. Without a `meta` function, `/live/_meta` answers 503 with "Schema not initialized". + +## Schemas from apps + +Installed apps contribute their loaders and actions to the schema in two ways. During `generate`, the generator reads the loaders of apps your site imports through bridge files in `src/apps/` (pass `--skip-apps` to skip this). At runtime, apps call `registerAppSchemas` from `@decocms/blocks/cms` with schemas they ship pre-generated, which replace the placeholder entries `registerCommerceLoaders` adds for loaders without one. You normally don't call either yourself; see [Apps](/v7/apps). + +## Next steps + +- [Deco Studio and the admin protocol](/v7/studio): the other endpoints Studio uses. +- [Code generation](/v7/generate): running and caching `generate`. +- [Blocks and sections](/v7/model): writing the types the schema comes from. diff --git a/docs/content/v7/sections.mdx b/docs/content/v7/sections.mdx new file mode 100644 index 00000000..6a5c287f --- /dev/null +++ b/docs/content/v7/sections.mdx @@ -0,0 +1,162 @@ +--- +title: Section conventions +group: Rendering +order: 15 +description: The exports a section file can declare (eager, layout, cache, sync, LoadingFallback and the rest), what each one changes, and how they reach the runtime. +--- + +# Section conventions + +A section file can export a few extra values next to its component. Each one changes how the runtime loads, renders or caches that section: `export const layout = true` caches a header across pages, `export function LoadingFallback` gives a deferred section its skeleton, and so on. You declare them in the file, `generate` records them, and one call in setup applies them. + +<Terms> + <Term name="Convention export">A named export in a section file, such as `export const sync = true`, that `generate` reads and the runtime turns into a registration.</Term> + <Term name="Layout section">A section such as a header or footer whose resolved output is cached for a few minutes and shared across pages. See the [glossary](/v7/glossary#layout-section).</Term> + <Term name="Deferred section">A section rendered as a skeleton first and loaded afterwards. See [Deferred sections](/v7/rendering).</Term> +</Terms> + +## An example + +```tsx title="src/sections/ProductShelf.tsx" +import type { Product } from "@decocms/apps-commerce/types"; + +export interface Props { + title: string; + products: Product[] | null; +} + +export const cache = "listing"; + +export function LoadingFallback() { + return <section style={{ minHeight: 420 }} aria-busy="true" />; +} + +export default function ProductShelf({ title, products }: Props) { + return ( + <section style={{ minHeight: 420 }}> + <h2>{title}</h2> + <ul> + {products?.map((p) => ( + <li key={p.productID}> + <img src={p.image?.[0]?.url} alt={p.name} width={200} height={200} /> + <a href={p.url}>{p.name}</a> + <span>{p.offers?.lowPrice}</span> + </li> + ))} + </ul> + </section> + ); +} +``` + +`cache = "listing"` caches the section loader's results with the `listing` cache profile, and `LoadingFallback` is what visitors see while the section loads, if an editor marks it async in Studio. After you add or change one of these exports, run `generate` again. + +## The exports + +| Export | Default | What it does | Same as calling | +|---|---|---|---| +| `export function LoadingFallback` | none | Skeleton rendered in place of the section while it loads: when it's deferred, and while its code chunk downloads. When the section is deferred it gets no props, so don't depend on them. TanStack Start only; see [Skeletons](#skeletons). | `registerSection(key, loader, { loadingFallback })` | +| `export function ErrorFallback` | none | Rendered instead of the section if it throws while rendering. It gets `{ error }`. TanStack Start reads it from the module when the section loads; `generate` doesn't record it. | `registerSection(key, loader, { errorFallback })` | +| `export const layout = true` | off | Caches the section's resolved props and its section loader's output for 5 minutes, shared by every page, one copy per device class. For headers, footers and theme sections. | `registerLayoutSections([key])` | +| `export const cache = "<profile>"` | off | Caches the section loader's results, keyed by the section and its props, using the loader freshness of a [cache profile](/v7/caching#cache-profiles) such as `"listing"` or `"product"`. Stale results are served while a refresh runs in the background. Has an effect only if the section has a [registered section loader](/v7/loaders). | `registerCacheableSections({ [key]: "listing" })` | +| `export const sync = true` | off | Bundles the section into the main bundle instead of a lazy chunk, so it renders without a loading state on both server and client. For sections above the fold that must never flash. | `registerSectionsSync({ [key]: module })` | +| `export const clientOnly = true` | off | Skips server rendering. The section renders only in the browser, after hydration. On TanStack Start its `LoadingFallback` shows until then. For widgets that need `window`. | `registerSection(key, loader, { clientOnly: true })` | +| `export const seo = true` | off | Marks the section as an SEO section: after its loader runs, its `title`, `description`, `canonical`, `image`, `noIndexing` and `jsonLDs` props feed the page's `<head>`. See [SEO](/v7/seo). | `registerSeoSections([key])` | +| `export const deferred = true` | off | Always defers this section for human visitors, whether or not an editor marked it async. | none in the public API; use the export | +| `export const eager = true` | off | Keeps the section server-rendered when position-based deferral is turned on and the section falls within the fold. No effect by default. | `registerEagerSections([key])` | +| `export const neverDefer = true` | off | Keeps the section server-rendered when position-based deferral is turned on, wherever it sits on the page. No effect by default. | `registerNeverDeferSections([key])` | +| `export const renderJson` | included | `false` drops the section from `?renderJson` output; a function `(props) => props` trims what it sends. See [Storefront as an API](/v7/storefront-api). | `setSectionRenderJson(key, value)` | + +Every `register*` function in the last column is exported from `@decocms/blocks/cms`, for sections whose file you don't control (an app's section, for example) or for keys you compute. + +`eager`, `neverDefer` and `deferred` are about *where* a section renders. None of them overrides an editor: a section marked async (⚡) in Studio is deferred even if its file says `neverDefer`. [Deferred sections](/v7/rendering#which-sections-are-deferred) has the full order. + +`generate` reads these exports with a pattern match on the source, not by running the file. Write each one as a literal on a single line (`export const cache = "listing";`), not computed or re-exported under another name. `LoadingFallback` and `renderJson` may also be re-exported (`export { LoadingFallback } from "./Skeleton"`). + +## Apply them in setup + +`generate` writes what it finds to `.deco/sections.gen.ts`: a `sectionMeta` map of the flags per section key, plus `syncComponents`, `loadingFallbacks` and `renderJsons`, which import the modules and functions those flags need. Setup hands them to the runtime. + +On TanStack Start, call `applySectionConventions` from `@decocms/blocks/cms` after `createSiteSetup`, passing the same section glob: + +```ts title="src/setup.ts (excerpt)" +import { applySectionConventions } from "@decocms/blocks/cms"; +import { loadingFallbacks, renderJsons, sectionMeta, syncComponents } from "../.deco/sections.gen"; + +const sections = import.meta.glob("./sections/**/*.tsx") as Record<string, () => Promise<any>>; + +// createSiteSetup({ sections, blocks, ... }) comes first + +applySectionConventions({ + meta: sectionMeta, + syncComponents, + loadingFallbacks, + renderJsons, + sectionGlob: sections, +}); +``` + +`sectionGlob` is what lets `clientOnly` and `LoadingFallback` register against the right lazy import; without it those two are skipped. `applySectionConventions` also turns on async rendering, which is what makes the editor's ⚡ toggle work at all, so call it even if no section uses a convention yet. + +On Next.js, pass the same values as `conventions` to `createNextSetup`, which calls `applySectionConventions` for you with your `sections` map as the glob: + +```ts title="src/setup.ts (excerpt, Next.js)" +import { loadingFallbacks, renderJsons, sectionImports, sectionMeta, syncComponents } from "deco/sections.gen"; + +export const ensureSetup = createNextSetup({ + // blocks, blocksDir, meta, ... + sections: sectionImports, + conventions: { meta: sectionMeta, syncComponents, loadingFallbacks, renderJsons }, +}); +``` + +## Layout sections + +A header or footer appears on every page with the same content. Resolving it again on each request repeats the same work, including any commerce loader inside it, such as a menu built from categories. Marking it a layout section caches two things for 5 minutes: the section's resolved props, and its section loader's output. Concurrent requests for the same section share one in-flight resolution. + +Each cache entry is keyed by the section and the visitor's device class (`mobile`, `tablet` or `desktop`), and by nothing else. + +<Callout type="warning"> + +**A layout section's output is shared by every visitor on the same device class.** It isn't keyed on cookies, location, search parameters or login state. A header that shows the visitor's name, a regional price, or a cart count from a cookie must not be a layout section: the first visitor's version would be served to everyone for up to 5 minutes. + +Move the personal part into a client component or a separate, non-layout section. To opt out a section that an app or a generated list marks as layout, call `unregisterLayoutSections([key])` from `@decocms/blocks/cms` in setup after `applySectionConventions`. + +</Callout> + +In development the runtime warns when a section loader registered for a layout section reads request-specific data (built with `withSearchParam`, for example), and when a section whose key contains "header" or "footer" isn't registered as a layout section. + +Layout sections are never deferred by position. A layout section that an editor marks async in Studio is still deferred, though, so leave headers and footers unmarked. + +## Cached sections + +`export const cache = "<profile>"` is for sections whose loader output depends only on their props: a shelf with a fixed collection, a list of blog posts. The cache key is the section key plus the props the loader receives, so two shelves configured differently get separate entries. A failed refresh keeps serving the cached result. + +Valid values are the [cache profile](/v7/caching#cache-profiles) names. To set a freshness in milliseconds instead, register it in setup: + +```ts title="src/setup.ts (excerpt)" +import { registerCacheableSections } from "@decocms/blocks/cms"; + +registerCacheableSections({ + "site/sections/ProductShelf.tsx": { maxAge: 60_000, staleWhileRevalidate: 300_000 }, +}); +``` + +`staleWhileRevalidate` defaults to 5 minutes. Don't cache a section whose loader reads cookies or the logged-in visitor: like layout sections, the cached result is shared. + +## Skeletons + +A `LoadingFallback` should take the same space as the section it replaces, so the page doesn't jump when the real section arrives. Give it the section's final height (fixed, or derived from its props, like the number of rows), and keep it a plain layout component: no data fetching, no browser APIs. + +A deferred section's content isn't sent to the browser with the page, so its `LoadingFallback` renders with no props. When the section is only waiting for its code chunk, it gets the section's props. + +If a deferred section has no `LoadingFallback`, TanStack Start renders the `loadingFallback` passed to `DecoPageRenderer`, or else a generic placeholder; in development the placeholder is outlined in red with a reminder to add one. + +On Next.js, deferred sections stream in behind the `loadingFallback` passed to `DecoPageRenderer`, not the section's own `LoadingFallback`. `createDecoPage` passes none, so a deferred section takes no space until it arrives. + +## Next steps + +- [Deferred sections](/v7/rendering): when sections are deferred and how they load. +- [Loaders and actions](/v7/loaders): the section loaders that `cache` and `layout` cache. +- [Caching](/v7/caching): cache profiles and the edge cache. +- [Code generation](/v7/generate): the generator that reads these exports. diff --git a/docs/content/v7/seo.mdx b/docs/content/v7/seo.mdx new file mode 100644 index 00000000..cb050a9c --- /dev/null +++ b/docs/content/v7/seo.mdx @@ -0,0 +1,112 @@ +--- +title: SEO +group: Rendering +order: 18 +description: Where a page's title, description, canonical URL and structured data come from, how each binding writes them into the head, and how crawlers are treated. +--- + +# SEO + +A page's search metadata (its `<title>`, description, canonical URL, robots directive, social-sharing tags and structured data) is content, like its sections. Editors set it in Studio, site-wide defaults fill the gaps, and the binding writes the result into the page's `<head>`. This page explains where each value comes from and what each binding emits. + +<Terms> + <Term name="SEO block">The page's own SEO settings: the `seo` field of a [page block](/v7/glossary#page-block), usually a `SeoV2` block from the website app.</Term> + <Term name="SEO section">A section in the page whose props also contribute metadata, marked with `export const seo = true`.</Term> + <Term name="Site SEO defaults">The `seo` object of the Site block: fallback title, description, image and title templates for every page.</Term> + <Term name="JSON-LD">Structured data in schema.org vocabulary, embedded as `<script type="application/ld+json">`, that search engines read for rich results.</Term> +</Terms> + +## Where the values come from + +Three sources feed a page's metadata: + +1. **The page's SEO block.** In Studio, every page has an SEO field. It usually holds a `SeoV2` block (`website/sections/Seo/SeoV2.tsx`) with a title, description, image, canonical URL and a "don't index" switch. For product and category pages it often holds a commerce SEO section whose data comes from a loader. It's always resolved on the server with the page, never deferred, so crawlers get it in the first response. +2. **SEO sections in the page.** A section exported with `export const seo = true` (or registered with `registerSeoSections` from `@decocms/blocks/cms`) contributes the `title`, `description`, `canonical`, `image`, `noIndexing`, `type` and `jsonLDs` props it ends up with after its section loader runs. Use this when a body section knows the page's real title, like a search results section that knows the query. Later sections override earlier ones; `jsonLDs` from several sections are concatenated. +3. **Site defaults.** The Site block's `seo` object holds `title`, `description`, `image`, and `titleTemplate` and `descriptionTemplate`, used when a page doesn't set its own. See [Content and the decofile](/v7/content). + +On TanStack Start, they're combined like this: + +- The SEO block's values win over SEO sections' values, field by field. Empty fields don't erase anything. +- Missing title, description and image fall back to the site defaults. +- The title and description are inserted into their templates. A template replaces `%s` with the value, so `"%s | My Store"` turns `Summer sale` into `Summer sale | My Store`. The SEO block's own templates come first, then the site's. A template that is empty or just `%s` is ignored. + +The `SeoV2` and `Seo` modules themselves are part of the website app; see [Website app](/v7/apps-website#seo-sections). + +### Product and category pages + +When the SEO block holds commerce data, a product listing page or a product details page from a commerce loader, the runtime derives what the block doesn't set explicitly: + +- **Title and description** from the platform's SEO fields for that category or product. +- **Canonical URL** from the platform, or else from the last item of the page's breadcrumb. +- **Image**, on product pages, from the product's first image. +- **`noindex`** when the listing has no products or the product doesn't exist, so empty pages stay out of search results. +- **JSON-LD**: the product or listing as structured data. + +Values the editor set on the block always win over derived ones. + +## What TanStack Start writes + +`cmsRouteConfig` and `cmsHomeRouteConfig` include a `head` function that turns the combined metadata into tags: + +| Tag | Value | +|---|---| +| `<title>` | The SEO title. Without one, the page block's `name` followed by ` \| ` and your `siteName`. Without a name, `defaultTitle`. | +| `<meta name="description">` | The SEO description, or `defaultDescription`. | +| `<meta name="robots">` | `noindex, nofollow` when `noIndexing` is set, otherwise `index, follow` with large image and snippet previews allowed. Always present. | +| `<link rel="canonical">` and `og:url` | The canonical URL, when there is one. | +| `og:title`, `og:description`, `og:image`, `og:type` | Title, description, image; type defaults to `website`. | +| `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image` | The card is `summary_large_image` when there's an image, else `summary`. | +| `<script type="application/ld+json">` | One per item in `jsonLDs`. | + +`siteName`, `defaultTitle` and `defaultDescription` are options of the route config; see [TanStack Start on Cloudflare Workers](/v7/tanstack#the-cms-routes). The `head` also adds `modulepreload` links for the page's server-rendered sections, so their code starts downloading early. + +Add other head tags (fonts, favicons, verification meta tags) in the root route's `head`, which TanStack merges with the page's. + +## What Next.js writes + +`createDecoPage` exports a `generateMetadata` that returns: + +- `title` and `description`, +- `alternates.canonical` when there's a canonical URL, +- `robots: { index: false, follow: false }` when `noIndexing` is set. + +It's deliberately narrower than the TanStack version. It doesn't apply site defaults or title templates, doesn't run the SEO block's section loader (so metadata that a commerce SEO section computes in its loader isn't available), and emits no Open Graph tags or JSON-LD. Use Next's own `metadata` in your layout for site-wide defaults, render JSON-LD from your sections, or write your own wrapper as shown in [Next.js App Router](/v7/nextjs). + +## Structured data and crawlers + +On a category page, the JSON-LD for the product list comes from the same commerce loader as the page's products and can be large. Human visitors don't need it. Two switches skip it for them while crawlers keep getting it: + +- **Per section, in Studio.** Commerce SEO sections have an `ignoreStructuredData` option ("ignore structured data"). With it on, human visitors get the page without that section's JSON-LD and without waiting for the commerce data behind it. +- **Site-wide, in code.** `setAsyncRenderingConfig({ botAwareSeo: true })` from `@decocms/blocks/cms` does the same for every commerce-backed SEO block. + +Crawlers, and any request with `?__deco_ssr=1`, still get the full structured data either way. See [Deferred sections](/v7/rendering#eager-requests) for how crawlers are detected. + +<Callout type="warning"> + +**Turn `botAwareSeo` on only if pages keep a title without the commerce data.** Skipping the data also skips the title and description derived from it, so a category page could fall back to a generic title for visitors. Give those pages an explicit title in their SEO block, or a site default, first. The per-section option has the same effect on that section, which is why it's off by default. + +</Callout> + +### robots.txt + +v7 doesn't manage `robots.txt` from content. Serve it as a static file from `public/` (a migrated Fresh site's `static/robots.txt` moves there for you): + +```text title="public/robots.txt" +User-agent: * +Disallow: /checkout +Disallow: /account +Sitemap: https://www.example.com/sitemap.xml +``` + +Each AI crawler has its own `User-agent`, so you can decide which ones to allow. For example, `User-agent: GPTBot` followed by `Disallow: /` keeps OpenAI's crawler out. For the sitemap itself, see [Sitemaps](/v7/routing#sitemaps). + +## Canonical URLs + +A canonical URL tells search engines which address is the real one when the same page is reachable through several: with sorting or tracking parameters, or with and without a trailing slash. Set it in the SEO block, or let commerce SEO derive it as described above. Without either, the page has no canonical tag. Write it as an absolute URL on your production domain. + +## Next steps + +- [Website app](/v7/apps-website): the `SeoV2` section and the site's SEO defaults. +- [Section conventions](/v7/sections): `export const seo = true` with the other section exports. +- [Images, scripts and UI helpers](/v7/components): JSON-LD components for your own sections. +- [Pages and routing](/v7/routing): the page block and its `seo` field. diff --git a/docs/content/v7/shopify.mdx b/docs/content/v7/shopify.mdx new file mode 100644 index 00000000..863ecc76 --- /dev/null +++ b/docs/content/v7/shopify.mdx @@ -0,0 +1,158 @@ +--- +title: Shopify +group: Apps +order: 29 +description: Connect a site to the Shopify Storefront API for product pages, listings, shelves, carts and customer sign-in. +--- + +# Shopify + +`@decocms/apps-shopify` connects a site to Shopify through the [Storefront API](https://shopify.dev/docs/api/storefront), Shopify's GraphQL API for custom storefronts. It provides loaders for product pages, listing and search pages, shelves, related products and shop details, all returning the shared [commerce types](/v7/apps-commerce), plus cart and customer actions that keep their state in cookies. + +It's a smaller app than [VTEX](/v7/vtex): it runs on the server only and ships no React hooks, no request middleware, no checkout proxy and no response cache. Your site calls its actions from its own server code. + +```bash +bun add @decocms/apps-shopify @decocms/apps-commerce @decocms/apps-website +``` + +## Configuring + +Editors configure the app in a `deco-shopify` block: + +| Field | What it does | +|---|---| +| `storeName` | Required. Your store's subdomain: `acme` for `acme.myshopify.com`. | +| `storefrontAccessToken` | Required. A Storefront API access token, as plain text or an encrypted secret. Falls back to the `SHOPIFY_STOREFRONT_TOKEN` environment variable. | +| `publicUrl` | Optional. Your storefront's public URL. | + +`configure` returns `null`, and the app isn't installed, when `storeName` or the token is missing. Install it with the registry entry, passing the module statically (see [Apps](/v7/apps#installing-apps-with-autoconfig)): + +```ts title="src/setup/apps.ts" +import { autoconfigApps, type AppRegistry } from "@decocms/blocks-admin/apps"; +import { loadBlocks } from "@decocms/blocks/cms"; +import { SHOPIFY_REGISTRY_ENTRY } from "@decocms/apps-shopify/registry"; +import * as shopifyMod from "@decocms/apps-shopify/mod"; + +const APP_REGISTRY: AppRegistry = [{ ...SHOPIFY_REGISTRY_ENTRY, module: async () => shopifyMod }]; + +await autoconfigApps(loadBlocks(), APP_REGISTRY); +``` + +You can also configure by hand with `configureShopify({ storeName, storefrontAccessToken, publicUrl })` from the package root, or `initShopifyFromBlocks(blocks)` in `createSiteSetup`'s `initPlatform`. `initShopifyFromBlocks` only accepts a token stored as plain text and configures once per process; use autoconfig when the token is encrypted or lives in the environment. + +The app talks to `https://<storeName>.myshopify.com/api/2025-04/graphql.json`. + +## Instrumented fetch + +Call `setShopifyFetch(createShopifyFetch())` once, at module scope in your setup: + +```ts title="src/setup.ts" +import { setShopifyFetch, createShopifyFetch } from "@decocms/apps-shopify"; + +setShopifyFetch(createShopifyFetch()); +``` + +Every GraphQL call is then measured and traced, with the span named after the GraphQL operation in the query (such as `shopify.GetProduct`). Without it, calls use a plain fetch with a timeout and aren't measured. The order doesn't matter: setting the fetch after the app is configured rebuilds its client. See [Observability](/v7/observability). + +## Loaders + +When the app is installed, its loaders are registered under these keys, which content refers to with `__resolveType`: + +| Key | Returns | Props | +|---|---|---| +| `shopify/loaders/ProductDetailsPage` | `ProductDetailsPage` | `slug`, `metafields?` | +| `shopify/loaders/ProductListingPage` | `ProductListingPage` | `query?`, `collectionName?`, `count` (12), `page?`, `pageOffset?` (1), `startCursor?`, `endCursor?`, `pageHref?`, `metafields?` | +| `shopify/loaders/ProductList` | `Product[]` | `props`: either `{ query, count, sort? }` or `{ collection, count, sort? }`; plus `filters?` (tags, product types, vendors, price range, variant options) and `metafields?` | +| `shopify/loaders/RelatedProducts` | `Product[]` | `slug`, `count?` (10) | +| `shopify/loaders/shop` | `Shop` | `metafields?` | + +Older content may refer to them with a `.ts` suffix (`shopify/loaders/ProductDetailsPage.ts`). That still resolves: when an exact key isn't registered, the resolver tries the key without its extension. + +A few rules worth knowing: + +- **Product slugs.** The product page loader treats the last `-` segment of `slug` as a variant id when it's a number; the rest is the product handle. `running-shoe-4412` loads handle `running-shoe`, variant `4412`. +- **Listing pages.** With a `query` (or a `?q=` in the page URL) the listing loader searches; otherwise it loads the collection named by `collectionName`. Shopify paginates with cursors, so the loader reads `?page`, `?startCursor` and `?endCursor` from the page URL, along with `?sort` and filter parameters, and returns the next and previous links in `pageInfo`. +- **Page URL.** The listing and product loaders take the page URL as a second argument, and build product links from it. When content resolves them, they don't receive one: links are then built on `https://localhost`, so render them through `relative()` from `@decocms/apps-commerce/sdk/url`. The resolver does copy the page's query parameters (except `page`) into the loader's props, so `?startCursor` and `?endCursor` still arrive; but `?q`, `?sort`, `?page` and the filter parameters are read only from the URL argument (or from `pageHref`). + +To give a listing page the real URL, register a small wrapper of your own and point content at its key. The resolver adds the current URL to every commerce loader's props as `__pageUrl`: + +```ts title="src/setup/commerce-loaders.ts" +import { registerCommerceLoaders } from "@decocms/blocks/cms"; +import { productListingPageLoader } from "@decocms/apps-shopify"; + +registerCommerceLoaders({ + "site/loaders/shopifyListingPage.ts": (props) => + productListingPageLoader(props, props.__pageUrl ? new URL(props.__pageUrl) : undefined), +}); +``` + +The app has no response cache of its own. Wrap frequently called read loaders, such as shelves and listing pages, with `createCachedLoader(name, loader, profile)` from `@decocms/blocks/sdk/cachedLoader`, and register the wrapper under a key of your own the same way. See [Cache a loader](/v7/loaders#cache-a-loader). + +The package root also exports the loaders as functions: `productDetailsPageLoader`, `productListingPageLoader`, `productListLoader`, `relatedProductsLoader`, `shopLoader` and `userLoader`, and each is reachable at `@decocms/apps-shopify/loaders/<Name>`. + +## Cart and customers + +Cart and customer state live in two cookies, both `HttpOnly`, `Secure`, `SameSite=Lax` and valid for a week: + +| Cookie | Holds | +|---|---| +| `cart` | The Shopify cart id (without its `gid://shopify/Cart/` prefix) | +| `secure_customer_sig` | The customer access token after sign-in | + +The cart and customer functions don't read the request on their own. Each takes the incoming request headers to read these cookies, and optionally a `Headers` object to write `Set-Cookie` into: + +| Function | Import | What it does | +|---|---|---| +| `getCart(requestHeaders, responseHeaders?)` | `@decocms/apps-shopify` | Returns the visitor's cart, creating one (and setting the cookie) if there's none. | +| `createCart()` | `@decocms/apps-shopify` | Creates an empty cart and returns its id. | +| `addItems({ lines, requestHeaders, responseHeaders? })` | `@decocms/apps-shopify/actions/cart/addItems` | Adds lines (`merchandiseId`, `quantity?`, `attributes?`, `sellingPlanId?`). | +| `updateItems({ lines, requestHeaders, responseHeaders? })` | `@decocms/apps-shopify/actions/cart/updateItems` | Changes quantities by line `id`. | +| `updateCoupons({ discountCodes, requestHeaders, responseHeaders? })` | `@decocms/apps-shopify/actions/cart/updateCoupons` | Replaces the discount codes. | +| `signIn({ email, password, requestHeaders, responseHeaders? })` | `@decocms/apps-shopify/actions/user/signIn` | Signs in and sets `secure_customer_sig`. Returns `null` if the visitor is already signed in or the request fails; check `customerAccessTokenCreate.customerUserErrors` for wrong credentials. | +| `signUp({ email, password, firstName?, lastName?, acceptsMarketing? })` | `@decocms/apps-shopify/actions/user/signUp` | Creates a customer account. | +| `userLoader(requestHeaders)` | `@decocms/apps-shopify` | The signed-in customer (`email`, `givenName`, `familyName`), or `null`. | + +Because they take `Headers` objects, call them from your own server functions rather than over `/deco/invoke`. On TanStack, read the request from [request context](/v7/request-context) and pass the cookies on with `forwardResponseCookies`: + +```ts title="src/server/cart.ts" +import { createServerFn } from "@tanstack/react-start"; +import { RequestContext } from "@decocms/blocks/sdk/requestContext"; +import { forwardResponseCookies } from "@decocms/tanstack/sdk/cookiePassthrough"; +import { getCart } from "@decocms/apps-shopify"; +import addItems from "@decocms/apps-shopify/actions/cart/addItems"; + +export const loadCart = createServerFn({ method: "POST" }).handler(async () => { + const responseHeaders = new Headers(); + const cart = await getCart(RequestContext.request.headers, responseHeaders); + forwardResponseCookies(responseHeaders.getSetCookie()); + return cart; +}); + +export const addToCart = createServerFn({ method: "POST" }) + .inputValidator((data: { merchandiseId: string; quantity: number }) => data) + .handler(async ({ data }) => { + const responseHeaders = new Headers(); + const cart = await addItems({ + lines: { merchandiseId: data.merchandiseId, quantity: data.quantity }, + requestHeaders: RequestContext.request.headers, + responseHeaders, + }); + forwardResponseCookies(responseHeaders.getSetCookie()); + return cart; + }); +``` + +Call `loadCart` first, for example when the cart drawer mounts: `getCart` creates the cart and sets its cookie, and the cart actions throw `Missing cart cookie` when the visitor has no cart yet. + +## Environment variables + +| Variable | What it does | +|---|---| +| `SHOPIFY_STOREFRONT_TOKEN` | Fallback Storefront API token when the block doesn't hold one. | +| `DECO_CRYPTO_KEY` | Decrypts a token stored encrypted in the block. See [Apps](/v7/apps#secrets). | + +## Related + +- [Apps](/v7/apps): installing apps and secrets. +- [Commerce types and utilities](/v7/apps-commerce): the shapes these loaders return. +- [Loaders and actions](/v7/loaders): commerce loaders and how content calls them. diff --git a/docs/content/v7/speculation-rules.mdx b/docs/content/v7/speculation-rules.mdx new file mode 100644 index 00000000..782f4c34 --- /dev/null +++ b/docs/content/v7/speculation-rules.mdx @@ -0,0 +1,139 @@ +--- +title: Speculation rules +group: Production +order: 39 +description: Let the browser prefetch or prerender the next page before a click, on TanStack Start sites, without double-counting analytics. +--- + +# Speculation rules + +The browser's Speculation Rules API lets a page declare which links the visitor is likely to follow. The browser then fetches the next document, or renders it completely in a hidden tab, before the click, so the navigation is instant. `@decocms/tanstack` can emit these rules for you from version 7.48.0 on. They're off by default; this page explains when they help, how to turn them on, and what to check in your analytics first. + +## What it helps, and what it doesn't + +Speculation rules only help **document navigations**: a plain `<a href>` that makes the browser load a new HTML page. On a storefront that's typically the mega-menu, the footer and breadcrumbs. + +Links rendered with TanStack Router's `<Link>` gain nothing. The router intercepts the click and navigates on the client, so the browser never uses the document it prepared, and the work is wasted. That's why you should scope the rules to the containers whose links really leave the app, with `linkSelector`. + +The rules themselves are an inert JSON `<script type="speculationrules">` in the page's `<head>`. No framework JavaScript runs on the critical path. + +## Turn it on + +Set the `speculationRules` option of the Worker entry. It applies to the whole site: + +```ts title="src/worker-entry.ts" +export default createDecoWorkerEntry(serverEntry, { + speculationRules: { + action: "prerender", + eagerness: "moderate", + linkSelector: "[data-prerender] a[href]", + }, +}); +``` + +A root layout can override it with the `speculationRules` prop of `DecoRootLayout`: + +```tsx title="src/routes/__root.tsx" +import { createRootRoute } from "@tanstack/react-router"; +import { DecoRootLayout } from "@decocms/tanstack"; + +export const Route = createRootRoute({ + component: () => ( + <DecoRootLayout siteName="my-store" speculationRules={{ action: "prefetch", eagerness: "conservative" }} /> + ), +}); +``` + +Without either, `DecoRootLayout` emits nothing. + +## Mark the right links + +`linkSelector` is an ordinary CSS selector that the browser matches against the page's anchors. `data-prerender` is just a naming convention: what matters is that the selector matches **only** anchors that navigate the whole document. + +With `linkSelector: "[data-prerender] a[href]"`, mark the containers: + +```tsx title="src/components/Footer/FooterLinks.tsx" +import { Link } from "@tanstack/react-router"; + +const institutional = [ + { href: "/about", label: "About us" }, + { href: "/returns-policy", label: "Returns policy" }, +]; + +export function FooterLinks() { + return ( + <footer> + {/* Plain anchors leave the app: candidates. */} + <nav data-prerender> + {institutional.map((link) => ( + <a key={link.href} href={link.href}> + {link.label} + </a> + ))} + </nav> + + {/* Router links navigate on the client: not marked. */} + <nav> + <Link to="/summer-sale">Summer sale</Link> + </nav> + </footer> + ); +} +``` + +The selector matches descendants, so a `data-prerender` on a wrapper that also contains router `<Link>`s makes those candidates too. Mark the specific `<nav>`, not the whole `<header>`. Selectors with `>` work. + +## Options + +| Option | Type | Default | What it does | +|---|---|---|---| +| `action` | `"prerender" \| "prefetch"` | `"prerender"` | `prerender` renders the whole page in a hidden document and runs its JavaScript: instant, more expensive, and it needs prerender-safe analytics. `prefetch` only downloads the HTML and runs nothing. | +| `eagerness` | `"immediate" \| "eager" \| "moderate" \| "conservative"` | `"moderate"` | When to start. `immediate`: as soon as the rules are read. `eager`: on any sign of interest. `moderate`: on a hover of about 200 ms, or pointer down. `conservative`: on pointer down only. Move toward `conservative` if speculative load on your server grows. | +| `linkSelector` | `string` | none | Which anchors are candidates. Without it, every internal link (`/*`) is. | +| `excludeHrefMatches` | `string[]` | `[]` | Extra path patterns to exclude, added to the defaults. For example `["/*/p"]` skips product pages. | +| `overrideDefaultExclusions` | `boolean` | `false` | Replace the default exclusions instead of adding to them. Only if none of the default paths exist on your site. | + +The types are exported from `@decocms/tanstack` as `SpeculationRulesConfig`, `SpeculationAction` and `SpeculationEagerness`. + +### Default exclusions + +These are always excluded unless you set `overrideDefaultExclusions: true`: + +```text +/checkout* /account* /_secure/* /login* /logout* /cart* /api/* +``` + +They're pages with session side effects (prerendering `/cart` could change state) and proxied or API routes, which are never cached, so speculating on them is pure cost. The list mirrors the `private` [cache profile](/v7/caching) and is exported as `DEFAULT_EXCLUDED_HREF_MATCHES`. + +## Before you use `prerender`: analytics + +`prerender` runs the page's JavaScript in the hidden document, pixels included. An analytics loader that doesn't check for this fires once during the prerender and again when the visitor actually opens the page, so you count the pageview twice. If the prerendered page is never opened, you count a visit that didn't happen. + +The framework's own analytics are already safe. `DecoRootLayout` includes `ANALYTICS_SCRIPT`, the observer behind `data-event` attributes, which waits until the page is shown. For Google Tag Manager, use `gtmScript` from `@decocms/blocks/sdk/analytics`, which defers loading the container the same way: + +```tsx title="src/components/Analytics.tsx" +import { gtmScript } from "@decocms/blocks/sdk/analytics"; + +export function Analytics() { + return <script dangerouslySetInnerHTML={{ __html: gtmScript("GTM-XXXXXXX") }} />; +} +``` + +For any other pixel, use the same guard: run its initialization only when `document.prerendering` is false, or after the `prerenderingchange` event. + +```js +if (document.prerendering) { + document.addEventListener("prerenderingchange", initPixel, { once: true }); +} else { + initPixel(); +} +``` + +In development, with speculation rules on, the framework adds a script that logs a `console.error` naming any tracker that fired during a prerender. Turn the rules on in development first and clear those errors before shipping. + +`prefetch` runs no JavaScript, so it's the safe choice while your pixels haven't been checked. + +## Related + +- [TanStack Start on Cloudflare Workers](/v7/tanstack): the other Worker entry options. +- [Images, scripts and UI helpers](/v7/components): `useSendEvent` and the `data-event` analytics helpers. diff --git a/docs/content/v7/storefront-api.mdx b/docs/content/v7/storefront-api.mdx new file mode 100644 index 00000000..29570053 --- /dev/null +++ b/docs/content/v7/storefront-api.mdx @@ -0,0 +1,159 @@ +--- +title: Storefront as an API +group: Framework guides +order: 21 +description: Serve loader results and whole CMS pages as JSON for native apps and other non-HTML clients. +--- + +# Storefront as an API + +A Deco site can serve its data as JSON as well as HTML, so a native mobile app or any other client can use the same content editors manage in Studio. This page covers the three ways to get JSON out of a site, how to shape what each section sends, and how to keep that payload stable for clients you can't redeploy. + +| Way | Returns | Weight | Use when | Binding | +|---|---|---|---|---| +| `POST /deco/invoke/<key>` | One loader or action result | Lightest | You need one piece of data: a product, the cart, a search | TanStack and Next.js | +| `?renderJson` | The page, one entry per section, each projected by the section | Light | The client renders a whole CMS page and you control the payload | TanStack | +| `?asJson` | The whole resolved page object, unfiltered | Heavy | Legacy clients only | TanStack | + +## Invoke one loader or action + +**Invoke** calls a loader or action by its key over HTTP. A **loader** fetches data and an **action** changes something; both are registered under keys such as `site/loaders/product/details.ts` (your own, from [`generate`](/v7/generate)) or `vtex/loaders/intelligentSearch/productDetailsPage.ts` (from an [app](/v7/apps)). [Loaders and actions](/v7/loaders) explains how keys are registered. + +Send the props as the JSON body: + +```bash +curl -X POST "https://www.example.com/deco/invoke/site/loaders/product/details.ts" -H "Content-Type: application/json" -d '{"slug":"linen-shirt"}' +``` + +The response is the loader's result as JSON. Add `?select=` with a comma-separated list of top-level fields to return only those fields; on an array result, it applies to each item. Your own loaders and actions, as registered by `generate`, answer to their key with or without the `.ts` suffix. An unknown key returns 404 with a hint to regenerate loaders. + +From browser code in your own site, the `invoke` proxy from `@decocms/blocks/sdk/invoke` makes the same request: + +```ts title="src/components/ProductPanel.tsx (excerpt)" +import { invoke } from "@decocms/blocks/sdk/invoke"; + +const details = await invoke.site.loaders.product.details({ slug: "linen-shirt" }); +``` + +The proxy turns the property path into a key and posts to `/deco/invoke/<key>`. If that key returns 404, it retries once with `.ts` appended. + +Use `POST`. On Next.js the invoke endpoint accepts nothing else. + +## Pages as JSON with ?renderJson + +Append `?renderJson` to any page URL and the site resolves the page and returns it as a lean JSON document instead of HTML: + +```bash +curl "https://www.example.com/summer-sale?renderJson" +``` + +```json title="Response" +{ + "name": "Summer sale", + "path": "/summer-sale", + "sections": [ + { "component": "site/sections/Hero.tsx", "props": { "title": "Summer sale", "image": "https://…" } }, + { "component": "site/sections/ProductShelf.tsx", "lazyUrl": "/summer-sale?renderJson&__section=1" } + ] +} +``` + +Each section appears as `{ component, props }` with its props after [resolution](/v7/glossary) and its section loader. Framework fields whose names start with `__` are removed, and so are values shaped like [secrets](/v7/content). + +### Lazy sections + +A section the editor marked as deferred (⚡ in Studio) isn't resolved in the page response. It appears in its position as `{ component, lazyUrl }`. Fetch the `lazyUrl` with a plain `GET` when the client needs that section, for example as the user scrolls, and you get `{ component, props }` back. Treat `lazyUrl` as opaque: only the two shapes are the contract. + +### Shape each section's JSON + +A section controls its own JSON with an `export const renderJson` in its file. [`generate`](/v7/generate) picks the export up and setup applies it through the section conventions. + +```tsx title="src/sections/Analytics.tsx" +// A web-only section: leave it out of the JSON entirely. +// Its section loader doesn't run either. +export const renderJson = false; +``` + +```tsx title="src/sections/Product/SearchResult.tsx" +import { deepOmit } from "@decocms/blocks/sdk"; +import type { SectionProps } from "@decocms/blocks/types"; + +// `loader` is this section's own loader export, defined in the same file. +export const renderJson = (props: SectionProps<typeof loader>) => + deepOmit(props, "storeConfig", "page.seo", "page.products.*.isVariantOf"); +``` + +Without the export, the section is sent with all its resolved props. `deepOmit(value, ...paths)` returns a copy without the given dotted paths; `*` matches every element of an array or every value of an object. + +Sections that come from apps can't carry your export. Drop them by suffix of their key in the Site block's `renderJson.sectionsToIgnore`: + +```json title=".deco/blocks/Site.json (excerpt)" +{ + "renderJson": { "sectionsToIgnore": ["SeoV2.tsx", "Analytics.tsx"] } +} +``` + +Prefer `export const renderJson = false` for your own sections, so the decision lives with the section. + +### Response details + +- `Content-Type: application/json`, with `Cache-Control: public, max-age=0, must-revalidate`. +- An `ETag` computed over the body. Send it back in `If-None-Match` and an unchanged page answers `304 Not Modified`. +- A path with no page answers HTTP 404 with the body `{ "status": 404, "notFound": true }`. So does a `lazyUrl` whose section no longer exists. +- `OPTIONS` requests with `?renderJson` answer 204 with the CORS headers below. + +```ts title="A client fetching a page with revalidation" +async function fetchPage(path: string, etag?: string) { + const res = await fetch(`https://www.example.com${path}?renderJson`, { + headers: etag ? { "If-None-Match": etag } : {}, + }); + if (res.status === 304) return null; + return { page: await res.json(), etag: res.headers.get("ETag") }; +} +``` + +## ?asJson (legacy) + +`?asJson` returns the whole resolved page object: every eager section with all its props, plus metadata meant for Studio. On listing and product pages it can be several megabytes. It exists for older clients; use `?renderJson` in new code. When a URL has both parameters, `?renderJson` wins. + +## Turning the endpoints on + +`?renderJson` and `?asJson` are served by the TanStack Worker entry. Both default to on in `createDecoWorkerEntry`, but sites created by the migration script set them to `false`. Enable the one you need: + +```ts title="src/worker-entry.ts (excerpt)" +export default createDecoWorkerEntry(serverEntry, { + // ...admin, buildSegment + renderJson: true, + asJson: false, + pageJsonCors: ["https://app.example.com"], +}); +``` + +The Next.js binding doesn't serve page JSON. Invoke works on both bindings. + +### CORS + +`pageJsonCors` sets the cross-origin headers on `?renderJson` responses. + +| Value | Result | +|---|---| +| unset or `"*"` (default) | `Access-Control-Allow-Origin: *`, no credentials. Any origin can read the JSON without cookies. | +| `string[]` | When the request's `Origin` is in the list, it's echoed back with `Access-Control-Allow-Credentials: true` and `Vary: Origin`. Other origins get no CORS headers. | +| `false` | No CORS headers. Native apps don't need them; browsers can then only read it same-origin. | + +Allowed methods are `GET` and `OPTIONS`; allowed request headers are `Content-Type`, `If-None-Match` and `Authorization`; `ETag` is exposed to scripts. `?asJson` always answers with `Access-Control-Allow-Origin: *`. + +## Keep the contract stable + +A `renderJson` export is invisible to TypeScript and to the Studio schema. Renaming a prop or dropping a projection during a refactor raises no error anywhere; the JSON just changes. A released mobile app can't follow that change. So: + +- Keep a snapshot test of the projected JSON of each section the app renders. +- Type each projection against the section's own props, as above, so a renamed field fails to compile. +- Version the contract, and change the version before shipping a breaking change. + +## Related + +- [Loaders and actions](/v7/loaders) explains the keys that invoke calls. +- [Section conventions](/v7/sections) lists `renderJson` with the other section exports. +- [Deferred sections](/v7/rendering) explains what makes a section lazy. +- [TanStack Start on Cloudflare Workers](/v7/tanstack) lists all Worker entry options. diff --git a/docs/content/v7/studio.mdx b/docs/content/v7/studio.mdx new file mode 100644 index 00000000..915b833d --- /dev/null +++ b/docs/content/v7/studio.mdx @@ -0,0 +1,144 @@ +--- +title: Deco Studio and the admin protocol +nav: Studio & admin protocol +group: Core concepts +order: 12 +description: The HTTP endpoints Deco Studio calls on your site, how each binding mounts them, and how to configure previews, CORS and publishing. +--- + +# Deco Studio and the admin protocol + +[Deco Studio](https://studio.decocms.com) is Deco's visual editor. It doesn't host your site or hold a copy of your code: it talks to your running site over a small set of HTTP endpoints, called the *admin protocol*, to read the schema and content, render previews and publish changes. This page lists those endpoints, shows how each binding mounts them, and covers the settings that affect what editors see. + +<Terms> + <Term name="Deco Studio">Deco's visual editor (also called the admin). It reads your schema and content from your site and publishes changes back. See the [glossary](/v7/glossary#deco-studio).</Term> + <Term name="Admin protocol">The HTTP endpoints Studio calls on your site. See the [glossary](/v7/glossary#admin-protocol).</Term> + <Term name="Render shell">The HTML document previews render inside: your stylesheet, fonts and theme.</Term> +</Terms> + +## How Studio talks to your site + +<Flow label="Studio and your site"> + <FlowNode title="Schema">`GET /live/_meta`: which sections and loaders exist and what their forms look like</FlowNode> + <FlowNode title="Content">`GET /.decofile`: the current blocks</FlowNode> + <FlowNode title="Preview">`/live/previews/*`: a section or page rendered with the editor's unsaved props</FlowNode> + <FlowNode title="Publish">`POST /.decofile`: the new content, applied in memory</FlowNode> +</Flow> + +Studio loads your site in an iframe for previews and calls the other endpoints from the browser, so they answer with CORS headers. All the handlers come from `@decocms/blocks-admin`; the bindings mount them for you. + +## The endpoints + +| Method and path | What it does | +|---|---| +| `GET /live/_meta` | Returns the schema (`.deco/meta.gen.json`, composed with the framework's types) with an ETag. Answers `304` when Studio's `If-None-Match` matches, `503` when no schema is configured. Also served at `/deco/meta`. | +| `GET /.decofile` | Returns the current decofile, with the revision as ETag and `Cache-Control: no-cache`. | +| `POST /.decofile` | Replaces content. See [Publishing](#publishing). | +| `GET /live/previews` | The empty HTML shell Studio's preview frame starts from. | +| `GET` or `POST /live/previews/<block or section>` | Renders one section, a saved block, or a whole page (`website/pages/Page.tsx`) with the props Studio sends, inside the render shell. | +| `POST /deco/render` | The same renderer, with the component given in the body or the `resolveChain` query parameter. | +| `POST /deco/invoke/<key>`, `POST /deco/invoke` | Calls a loader or action by key, or a batch of them. See [Loaders and actions](/v7/loaders#call-loaders-and-actions-over-http). | +| `GET /deco/_liveness` | Answers `OK`, for health checks. | + +Page previews evaluate matchers against the path and device Studio is previewing (`?path=`, `?deviceHint=mobile`), and run section loaders, so the preview shows real data. Each previewed section is wrapped in a `<section data-manifest-key="…">` so Studio can map clicks back to blocks. Errors render inline in the preview rather than failing the request. + +## Publishing + +When an editor publishes, Studio sends the content to `POST /.decofile`. The body is one of two shapes: + +- **A delta:** an object whose only key is `blocks`, holding the changed blocks. A `null` value deletes that block. + + ```json title="A delta publish" + { "blocks": { "Header": { "__resolveType": "site/sections/Header/Header.tsx", "logo": "https://www.example.com/logo-v2.svg" }, "pages-old-sale": null } } + ``` + +- **A full decofile:** any other object replaces all content. + +The site applies it with `setBlocks`, clears its loader cache, invalidates the schema ETag, and, with [Fast Deploy](/v7/releases), writes the snapshot to KV. It answers: + +| Field | Meaning | +|---|---| +| `ok` | `true` | +| `mode` | `"delta"` or `"full"` | +| `previousBlockCount`, `newBlockCount` | Block counts before and after | +| `revision` | The new content revision | +| `kvWritten` | Whether the snapshot was written to KV (always `false` without Fast Deploy) | +| `timestamp` | When it was applied | + +Outside development, publishing requires a token. Set `DECO_RELEASE_RELOAD_TOKEN` in the site's environment, and the request's `Authorization` header must equal it exactly. Without the variable, every publish is refused with `401`. In development (`NODE_ENV=development`) no token is needed, which is how the TanStack dev server applies your local block edits. + +## In TanStack Start + +The endpoints are split between the Worker entry and three file routes. + +`createDecoWorkerEntry` handles `/live/_meta`, `/.decofile`, `/live/previews` and `/live/previews/*`, and `/deco/_liveness`, when you pass the handlers as its `admin` option. These responses are never cached. + +```ts title="src/worker-entry.ts (excerpt)" +import { + corsHeaders, + handleDecofileRead, + handleDecofileReload, + handleMeta, + handleRender, +} from "@decocms/blocks-admin"; + +export default createDecoWorkerEntry(serverEntry, { + admin: { handleMeta, handleDecofileRead, handleDecofileReload, handleRender, corsHeaders }, +}); +``` + +The file routes `src/routes/deco/meta.ts`, `src/routes/deco/invoke.$.ts` and `src/routes/deco/render.ts` serve the rest, using `decoMetaRouteConfig()`, `decoInvokeRouteConfig()` and `decoRenderRouteConfig()`. Call each factory in its own route file. The [quickstart](/v7/quickstart#8-add-the-routes) has all three. + +<Callout type="warning">**Mount the admin handlers in `createDecoWorkerEntry`, not in TanStack's `createServerEntry`.** Production builds drop custom request handling from the server entry, so the endpoints work in development and then return your HTML page in production. If `/live/_meta` returns HTML, check this first, and check that `wrangler.jsonc`'s `main` points at `src/worker-entry.ts`.</Callout> + +## In Next.js + +One catch-all route, `app/deco/[[...deco]]/route.ts` with `createDecoRouteHandlers({ setup })`, serves every endpoint. `withDeco` in `next.config` rewrites the public paths onto it, because Next.js route folders can't start with a dot: + +| Public path | Rewritten to | +|---|---| +| `/.decofile` | `/deco/decofile` | +| `/live/_meta` | `/deco/meta` | +| `/live/previews/:path*` | `/deco/previews/:path*` | + +Preview `GET`s redirect to the fixed page `/deco/preview`, rendered by `createDecoPreviewPage`, so Client Components work in previews; see [Previews and draft preview](/v7/preview). On Next.js, `/deco/meta` accepts only `GET` and `/deco/invoke` only `POST`. The [Next.js quickstart](/v7/quickstart-nextjs#8-mount-the-admin-routes) shows both files. + +## What previews look like + +Previews render your sections outside your app's normal layout, so you tell the admin side what the surrounding document needs. On TanStack, `createAdminSetup` from `@decocms/blocks-admin/setup` takes: + +| Option | Type | What it does | +|---|---|---| +| `meta` | `() => Promise<schema>` | Loads the schema. Required. Keep it a dynamic `import()`. | +| `css` | `string` | URL of your stylesheet (a Vite `?url` import). Required. | +| `fonts` | `string[]` | Font stylesheet URLs to load in previews. | +| `previewWrapper` | component | Wraps every preview, to provide context your sections need. Use `PreviewProviders` from `@decocms/tanstack`. | +| `getCommerceLoaders` | `() => Record<string, loader>` | The loaders `/deco/invoke` can call, if you don't register them with `setInvokeLoaders`. | + +On Next.js, the same settings are `createNextSetup`'s `meta`, `renderShell: { css, fonts }` and `previewWrapper`. + +For anything else about the shell, call `setRenderShell` from `@decocms/blocks-admin` in setup. It takes `css`, `fonts`, `theme` (the `data-theme` attribute), `bodyClass` and `lang`. If your styles use DaisyUI color variables, set the theme, or previews render without colors: + +```ts title="src/setup.ts (excerpt)" +import { setRenderShell } from "@decocms/blocks-admin"; + +setRenderShell({ theme: "light" }); +``` + +The Next.js preview page already defaults the theme to `light`. + +## Live controls + +`DecoRootLayout` (in both bindings) renders `LiveControls`, a small script that lets Studio and the page talk while the page is in Studio's frame: it reports which page is open and responds to editor messages. Outside Studio it also adds a shortcut: press <Kbd>.</Kbd> on your site to open it in Studio (<Kbd>Ctrl</Kbd>/<Kbd>⌘</Kbd> + <Kbd>.</Kbd> for a new tab). + +## CORS and framing + +Studio calls the admin endpoints from the browser, so they answer with CORS headers that allow `GET`, `POST` and `OPTIONS` and the `Content-Type`, `Authorization` and `If-None-Match` headers. On Next.js, `OPTIONS` preflights are answered without running setup. + +Studio also shows your pages in a frame. On TanStack, HTML responses carry a `frame-ancestors` policy that lets Deco's Studio frame your pages; see [TanStack Start on Cloudflare Workers](/v7/tanstack) to change the security headers. + +## Next steps + +- [Schema generation](/v7/schema): what `/live/_meta` contains. +- [Previews and draft preview](/v7/preview): previews in depth, and drafts on the real site. +- [Deploying and Fast Deploy](/v7/releases): making publishes durable in production. diff --git a/docs/content/v7/tanstack.mdx b/docs/content/v7/tanstack.mdx new file mode 100644 index 00000000..4e2ef62b --- /dev/null +++ b/docs/content/v7/tanstack.mdx @@ -0,0 +1,480 @@ +--- +title: TanStack Start on Cloudflare Workers +nav: TanStack Start +group: Framework guides +order: 19 +description: Every file, option and component the TanStack Start binding gives a site that runs on Cloudflare Workers. +--- + +# TanStack Start on Cloudflare Workers + +`@decocms/tanstack` is the binding that runs a Deco Blocks site on TanStack Start and deploys it to Cloudflare Workers. This page is the reference for it: what each file in your project does, every option of the Worker wrapper, the route factories, the layout components, the router, and the Vite plugin. If you haven't built a page yet, start with the [TanStack quickstart](/v7/quickstart) and come back here when you need to change a default. + +A **binding** is the package that connects the framework-neutral runtime (`@decocms/blocks`) to one app framework. The TanStack binding is the more complete of the two: it also owns the edge cache, [Fast Deploy](/v7/releases) and the page-as-JSON endpoints, none of which exist on [Next.js](/v7/nextjs). + +## The files a site owns + +A TanStack site wires the binding through a handful of files. The [migration scaffold](/v7/migrate-from-fresh) and the [quickstart](/v7/quickstart) both produce this layout. + +| File | Role | +|---|---| +| `vite.config.ts` | Adds `decoVitePlugin()` next to TanStack Start's and Cloudflare's plugins. | +| `src/setup.ts` | Registers sections, content and the Studio schema (`createSiteSetup`, `createAdminSetup`). Module-level settings live here too. | +| `src/server.ts` | TanStack Start's request handler. | +| `src/worker-entry.ts` | The Worker's `main`. Wraps the server entry with `createDecoWorkerEntry`, which owns caching, the admin protocol and everything else in front of TanStack. | +| `src/router.tsx` | Builds the router with `createDecoRouter`. | +| `src/routes/__root.tsx` | Renders the HTML document with `DecoRootLayout`. | +| `src/routes/index.tsx`, `src/routes/$.tsx` | The home route and the catch-all CMS route. | +| `src/routes/deco/meta.ts`, `invoke.$.ts`, `render.ts` | Admin protocol routes built from factories. | +| `wrangler.jsonc` | Worker configuration: `main`, compatibility flags, bindings and vars. | +| `src/start.ts` (optional) | Your own TanStack Start instance. Without it, the Vite plugin supplies a default. | + +### Load setup first + +`src/setup.ts` fills module-level registries: the decofile, the section registry, matchers and the schema loader. TanStack Start splits server functions into separate modules, and any of them can run before your route files are imported. So the setup module must be the **first import** of both server entry points. + +```ts title="src/server.ts" +import "./setup"; +import { createStartHandler, defaultStreamHandler } from "@tanstack/react-start/server"; + +export default createStartHandler(defaultStreamHandler); +``` + +<Callout type="warning"> + +If `import "./setup"` is not first in `src/server.ts` and `src/worker-entry.ts`, the first page load still works (server-side render), but client-side navigation shows "No CMS page block matches this URL". Moving the import to the top fixes it. + +</Callout> + +## The Worker entry + +`createDecoWorkerEntry(serverEntry, options)` takes TanStack Start's server entry and returns a Worker handler `{ fetch(request, env, ctx) }`. Every request goes through it before TanStack sees it. It answers the admin protocol, applies CMS redirects, serves `?renderJson`, runs your proxy, caches responses at the edge, adds security headers and records telemetry. [The Worker request pipeline](/v7/request-pipeline) walks through the order step by step. + +```ts title="src/worker-entry.ts" +import "./setup"; +import handler, { createServerEntry } from "@tanstack/react-start/server-entry"; +import { createDecoWorkerEntry } from "@decocms/tanstack"; +import { detectDevice } from "@decocms/blocks/sdk/detectDevice"; +import { + corsHeaders, + handleDecofileRead, + handleDecofileReload, + handleMeta, + handleRender, +} from "@decocms/blocks-admin"; + +const serverEntry = createServerEntry({ fetch: handler.fetch }); + +export default createDecoWorkerEntry(serverEntry, { + admin: { handleMeta, handleDecofileRead, handleDecofileReload, handleRender, corsHeaders }, + buildSegment: (request) => ({ + device: detectDevice(request.headers.get("user-agent") ?? ""), + }), + renderJson: false, + asJson: false, +}); +``` + +<Callout type="warning"> + +Admin routes and cache logic belong in `createDecoWorkerEntry`, not in TanStack's `createServerEntry`. Vite strips custom fetch logic from the server entry in production builds. The symptom is `/live/_meta` returning HTML and responses carrying no `X-Cache` header. Also check that `main` in `wrangler.jsonc` points at `src/worker-entry.ts`. + +</Callout> + +The options type isn't exported from the package, so the tables below spell out each option's shape. Everything is optional. + +### Admin + +| Option | Type | Default | What it does | +|---|---|---|---| +| `admin` | `{ handleMeta, handleDecofileRead, handleDecofileReload, handleRender, corsHeaders }` | none | The [admin protocol](/v7/studio) handlers from `@decocms/blocks-admin`. With them, the Worker answers `/live/_meta`, `GET` and `POST /.decofile`, `/live/previews` and `/live/previews/<component>`, and `/deco/_liveness`, all with no-store headers and admin CORS. Without them, those paths fall through to TanStack. | +| `previewShell` | `string` | built from the render shell | Custom HTML for the empty `/live/previews` page that Studio loads into its iframe. | + +### Caching + +These options shape the edge cache. [Caching](/v7/caching) explains profiles, headers and purging in depth. + +| Option | Type | Default | What it does | +|---|---|---|---| +| `cacheStorage` | `(env, request) => CacheStorage \| null` | Web Cache API for responses, memory for data | Chooses shared cache storage per request, for example `createKVCacheStorage` from `@decocms/blocks/sdk/cacheStorage`. Return `null` to opt out. | +| `detectProfile` | `(url: URL) => CacheProfileName \| null` | built-in detection | Picks the [cache profile](/v7/caching) for a URL. Return `null` to fall through to the built-in rules. | +| `deviceSpecificKeys` | `boolean` | `true` | Splits cache entries by device class. | +| `bypassPaths` | `string[]` | `["/_build", "/deco/", "/live/", "/.decofile"]` | Paths that are never cached. Your list is added to the defaults; the defaults can't be removed. | +| `extraBypassPaths` | `string[]` | `[]` | More paths to bypass, merged the same way. | +| `stripTrackingParams` | `boolean` | `true` | Removes `utm_*`, `gclid`, `fbclid` and similar parameters from the cache key. | +| `cacheVersionEnv` | `string \| false` | `"BUILD_HASH"` | Env var whose value is appended to every cache key, so each deploy gets its own cache namespace. Falls back to the build hash the Vite plugin injects. | +| `purgeTokenEnv` | `string \| false` | `"PURGE_TOKEN"` | Env var holding the bearer token for `POST /_cache/purge` and `POST /_cache/purge-loaders`. `false` turns both endpoints off. | +| `safeCookies` | `string[]` | `vtex_is_session`, `vtex_is_anonymous`, `vtex_segment`, `_deco_bucket` | Cookies that may appear in `Set-Cookie` without making a response uncacheable. They're stripped from the cached copy. Any other `Set-Cookie` bypasses the cache. | +| `staticPaths` | `string[]` | `["/fonts/"]` | Path prefixes treated as static assets. | +| `fingerprintedAssetPattern` | `RegExp` | matches `/assets/<name>-<hash>.<ext>` | Assets matching it get a one-year immutable cache. | +| `cdnCacheControl` | `"serverfn-segment" \| "no-store" \| "match-profile" \| (profile) => string \| null` | `"serverfn-segment"` | What `CDN-Cache-Control` tells Cloudflare's CDN in front of the Worker. HTML documents are never CDN-cached by the default. `"match-profile"` is honoured only when the cache key is the raw URL (no `buildSegment`, `deviceSpecificKeys: false`, `geoCacheKey: "off"`). | + +### Segments + +A **segment** is the set of request properties that change what a page looks like, such as device, logged-in state or sales channel. The Worker keys its cache on the segment so different audiences never share an entry. + +| Option | Type | Default | What it does | +|---|---|---|---| +| `buildSegment` | `(request) => { device: "mobile" \| "tablet" \| "desktop"; loggedIn?: boolean; salesChannel?: string; regionId?: string; flags?: string[]; [custom: string]: string \| boolean \| string[] \| undefined }` | none | Computes the segment. A segment with `loggedIn: true` always bypasses the cache. Extra keys become extra cache dimensions. | + +Without `buildSegment`, the Worker logs a warning at boot: it can't tell logged-in visitors apart, so they would share the anonymous cache entry. Commerce apps ship a helper for this. With VTEX, for example, `extractVtexContext` from `@decocms/apps-vtex/middleware` reads the login and sales-channel cookies (see [VTEX](/v7/vtex)). + +Only add dimensions you actually use. A `regionId` on a site without regional pricing multiplies every page into one copy per region for nothing. Location-based keying is handled by `geoCacheKey` below. + +### Geo + +| Option | Type | Default | What it does | +|---|---|---|---| +| `geoCacheKey` | `"auto" \| "off" \| "country" \| "region" \| "city"` | `"auto"` | Adds the visitor's location to the cache key. `"auto"` checks the decofile whenever it changes: if any block uses the `website/matchers/location.ts` matcher it keys by region, otherwise it doesn't key by location at all. | +| `autoInjectGeoCookies` | `boolean` | `true` | Makes Cloudflare's geolocation available to matchers as request cookies inside the Worker. They are never sent to the browser. | + +### Security + +| Option | Type | Default | What it does | +|---|---|---|---| +| `securityHeaders` | `Record<string, string> \| false` | `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, `X-XSS-Protection`, `Strict-Transport-Security`, `Cross-Origin-Opener-Policy`, and a `Content-Security-Policy` that only sets `frame-ancestors` so Studio can frame the site | Headers added to HTML responses. Your entries are merged over the defaults. `false` turns them off. | +| `csp` | `string[] \| false` | none | Content-Security-Policy directives, joined with `; `. | +| `cspMode` | `"report-only" \| "enforce"` | `"report-only"` | `"report-only"` sends `Content-Security-Policy-Report-Only`. `"enforce"` sends an enforced policy with a per-request nonce, on non-cacheable HTML only. | + +### Page JSON + +| Option | Type | Default | What it does | +|---|---|---|---| +| `renderJson` | `boolean` | `true` | Serves `?renderJson`, the lean per-section page JSON. | +| `asJson` | `boolean` | `true` | Serves `?asJson`, the legacy raw page JSON. | +| `pageJsonCors` | `string[] \| "*" \| false` | `"*"` | CORS for `?renderJson`. See [Storefront as an API](/v7/storefront-api). | + +The migration scaffold sets `renderJson` and `asJson` to `false`; turn them on when a client actually needs them. + +### Proxy + +| Option | Type | Default | What it does | +|---|---|---|---| +| `proxyHandler` | `(request, url) => Response \| null \| Promise<Response \| null>` | none | Runs after the admin routes, redirects and page JSON, and before static assets and caching. Return a `Response` to answer the request yourself (for example, by proxying checkout to the commerce platform), or `null` to continue. | + +```ts title="src/worker-entry.ts (VTEX proxy)" +import { createVtexCheckoutProxy, shouldProxyToVtex } from "@decocms/apps-vtex/utils/proxy"; + +const proxyCheckout = createVtexCheckoutProxy({ + account: "acme", + checkoutOrigin: "secure.store.example.com", +}); + +export default createDecoWorkerEntry(serverEntry, { + // ...admin, buildSegment + proxyHandler: (request, url) => + shouldProxyToVtex(url.pathname) ? proxyCheckout(request, url) : null, +}); +``` + +### Speculation rules + +| Option | Type | Default | What it does | +|---|---|---|---| +| `speculationRules` | `{ action?, eagerness?, linkSelector?, excludeHrefMatches?, overrideDefaultExclusions? }` | off | Emits a `<script type="speculationrules">` so the browser prefetches or prerenders the next document. See [Speculation rules](/v7/speculation-rules). | + +### Observability and outbound requests + +| Option | Type | Default | What it does | +|---|---|---|---| +| `observability` | `OtelOptions \| false` | on | Wraps the handler with `instrumentWorker` from `@decocms/blocks/sdk/otel`. Exporters only start when their env vars are set. `false` turns the wrapper off, for example when you wrap the handler yourself. See [Observability](/v7/observability). | +| `outboundUserAgent` | `string \| false` | `Deco/<version> (+https://deco.cx)` | User-Agent added to outgoing `fetch` calls that don't set one. Some partner firewalls block requests without a User-Agent. `false` leaves `fetch` untouched. | + +## Routes + +### The CMS routes + +`cmsRouteConfig(options)` and `cmsHomeRouteConfig(options)` return TanStack route options to spread into `createFileRoute("/$")` and `createFileRoute("/")`. They supply the loader (which resolves the page on the server), search-param handling, cache headers, the `<head>` (title, description, robots, Open Graph, canonical, JSON-LD) and an error boundary. They don't supply `component` or `notFoundComponent`: those are yours. + +```tsx title="src/routes/$.tsx" +import { createFileRoute } from "@tanstack/react-router"; +import { cmsRouteConfig, DecoPageRenderer, NotFoundPage } from "@decocms/tanstack"; +import { deferredSectionLoader } from "@decocms/tanstack/sdk/deferredSectionLoader"; + +export const Route = createFileRoute("/$")({ + ...cmsRouteConfig({ + siteName: "My Store", + defaultTitle: "My Store", + defaultDescription: "Everything for your home.", + }), + component: CmsPageRoute, + notFoundComponent: NotFoundPage, +}); + +function CmsPageRoute() { + const data = Route.useLoaderData(); + if (!data) return <NotFoundPage />; + return ( + <DecoPageRenderer + sections={data.resolvedSections ?? []} + deferredSections={data.deferredSections ?? []} + pagePath={data.pagePath} + pageUrl={data.pageUrl} + device={data.device} + loadDeferredSectionFn={deferredSectionLoader} + /> + ); +} +``` + +The home route is the same with `cmsHomeRouteConfig`. Its options are the same as below minus `ignoreSearchParams` and `ssr`, and `siteName` is optional there (it defaults to `defaultTitle`). + +| Option | Type | Default | What it does | +|---|---|---|---| +| `siteName` | `string` | required | Used in page titles: a page without an SEO title gets `<page name> \| <siteName>`. | +| `defaultTitle` | `string` | required | Title when nothing else provides one. | +| `defaultDescription` | `string` | none | Description when no section provides one. | +| `ignoreSearchParams` | `string[]` | `["skuId"]` | Search params that don't trigger a new server fetch when they change. | +| `pendingComponent` | component | none | Shown during client navigation while the loader runs. Without one, the previous page stays on screen until the next one is ready. | +| `pendingMs` | `number` | `200` | Delay before the pending component appears. | +| `pendingMinMs` | `number` | `300` | Minimum time the pending component stays once shown. | +| `errorComponent` | `({ error, reset }) => ReactNode` | built-in error page | Rendered when page resolution throws. | +| `ssr` | `boolean \| "data-only"` | `true` | `"data-only"` runs the loader on the server and renders on the client. | +| `resolveGlobals` | `boolean` | `true` | Merges the Site block's `theme`, `global` and `pageSections` into every page. `false` also skips the Theme section's `<style>` injection. | + +The built-in error page and the label of `NavigationProgress` are in Portuguese. To use your own text, pass `errorComponent`: + +```tsx title="src/routes/$.tsx (custom error page)" +function PageError({ reset }: { error: Error; reset: () => void }) { + return ( + <div role="alert"> + <p>Something went wrong loading this page.</p> + <button type="button" onClick={reset}>Try again</button> + </div> + ); +} + +export const Route = createFileRoute("/$")({ + ...cmsRouteConfig({ + siteName: "My Store", + defaultTitle: "My Store", + errorComponent: PageError, + }), + component: CmsPageRoute, + notFoundComponent: NotFoundPage, +}); +``` + +The loader returns `null` when no page block matches the path. Otherwise it returns the resolved page: `resolvedSections`, `deferredSections`, `pagePath`, `pageUrl`, `device`, `seo`, `cacheProfile` and a few more. `CmsPage` and `NotFoundPage` are exported as ready-made components, but `CmsPage` doesn't wire `loadDeferredSectionFn`, so render `DecoPageRenderer` yourself as above. + +### Admin routes + +Studio also calls three paths that TanStack serves as file routes. Build them with the factories; each call returns a fresh options object. + +```ts title="src/routes/deco/meta.ts" +import { createFileRoute } from "@tanstack/react-router"; +import { decoMetaRouteConfig } from "@decocms/tanstack"; + +export const Route = createFileRoute("/deco/meta")(decoMetaRouteConfig()); +``` + +```ts title="src/routes/deco/invoke.$.ts" +import { createFileRoute } from "@tanstack/react-router"; +import { decoInvokeRouteConfig } from "@decocms/tanstack"; + +export const Route = createFileRoute("/deco/invoke/$")(decoInvokeRouteConfig()); +``` + +```ts title="src/routes/deco/render.ts" +import { createFileRoute } from "@tanstack/react-router"; +import { decoRenderRouteConfig } from "@decocms/tanstack"; + +export const Route = createFileRoute("/deco/render")(decoRenderRouteConfig()); +``` + +<Callout type="warning"> + +Call the factory in each route file. Sharing one options object between routes breaks hot reload in development with "Route cannot have both an 'id' and a 'path' option". + +</Callout> + +`withSiteGlobals` is still exported but does nothing. Site globals are resolved inside the CMS route loaders. + +## Layout and rendering components + +### DecoRootLayout + +`DecoRootLayout` renders the whole HTML document: `<html>`, `<head>` with TanStack's head content, and `<body>` with the event bootstrap, the analytics collector, a navigation progress bar, the route outlet, the draft-preview badge, Studio's live controls and TanStack's scripts. It already renders the outlet, so don't pass `<Outlet />` as a child. + +```tsx title="src/routes/__root.tsx" +import { createRootRoute } from "@tanstack/react-router"; +import { DecoRootLayout } from "@decocms/tanstack"; +import appCss from "../styles/app.css?url"; + +export const Route = createRootRoute({ + head: () => ({ + meta: [{ charSet: "utf-8" }, { name: "viewport", content: "width=device-width, initial-scale=1" }], + links: [{ rel: "stylesheet", href: appCss }], + }), + component: () => <DecoRootLayout siteName="my-store" lang="en" />, +}); +``` + +| Prop | Type | Default | What it does | +|---|---|---|---| +| `siteName` | `string` | required | Identifies the site to Studio's live controls. | +| `lang` | `string` | `"en"` | `<html lang>`. | +| `dataTheme` | `string` | `"light"` | `<html data-theme>`. DaisyUI v4 needs it for its color variables. | +| `bodyClassName` | `string` | `"bg-base-200 text-base-content"` | Class on `<body>`. | +| `account` | `string` | none | Commerce account name exposed to analytics scripts. | +| `decoReadyDelay` | `number` | `500` | Milliseconds after hydration before the `deco:ready` event fires on `document`. | +| `speculationRules` | speculation config | from the Worker option | Overrides the Worker's speculation rules for this root. | +| `children` | `ReactNode` | none | Extra body content after the outlet, such as a toast container. | + +### DecoPageRenderer + +`DecoPageRenderer` renders a page's sections in order. Eager sections render immediately. [Deferred sections](/v7/rendering) render a skeleton first (the section's own `LoadingFallback`, or the `loadingFallback` prop), then load when they come within 300px of the viewport. + +| Prop | Type | Default | What it does | +|---|---|---|---| +| `sections` | resolved sections | required | The loader's `resolvedSections`. | +| `deferredSections` | deferred sections | none | The loader's `deferredSections`. | +| `loadDeferredSectionFn` | function | none | Fetches a deferred section. Pass `deferredSectionLoader` from `@decocms/tanstack/sdk/deferredSectionLoader`. Without it, deferred sections stay skeletons after client-side navigation. | +| `pagePath` | `string` | `"/"` | Path forwarded to deferred loaders. | +| `pageUrl` | `string` | none | Full URL, with query, forwarded to deferred loaders. | +| `device` | `"mobile" \| "tablet" \| "desktop"` | resolved at runtime | The server's device, so `useDevice()` returns the same value during hydration. | +| `loadingFallback` | `ReactNode` | built-in | Skeleton for deferred sections without their own. | +| `errorFallback` | `ReactNode` | built-in | Rendered when a section throws. | +| `deferredPromises` | record of promises | none | Server-only streaming path. It doesn't survive client navigation, so keep `loadDeferredSectionFn` either way. | + +The package also exports `SectionRenderer` and `SectionList` for rendering sections outside a page, `NavigationProgress` (a top progress bar with an optional `color`), `StableOutlet`, `DraftPreviewIndicator` and `PreviewProviders` (router and query providers for preview renders, meant for `createAdminSetup({ previewWrapper })`). + +## The router + +`createDecoRouter(options)` wraps TanStack's `createRouter` with defaults that suit storefronts. Search params are parsed and written with `URLSearchParams`, so a repeated key such as `?filter.brand=Nike&filter.brand=Acme` becomes an array instead of a JSON string. Create the `QueryClient` inside `getRouter()`: + +```tsx title="src/router.tsx" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createDecoRouter } from "@decocms/tanstack"; +import { routeTree } from "./routeTree.gen"; +import "./setup"; + +export function getRouter() { + const queryClient = new QueryClient(); + return createDecoRouter({ + routeTree, + context: { queryClient }, + Wrap: ({ children }) => <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>, + }); +} + +declare module "@tanstack/react-router" { + interface Register { + router: ReturnType<typeof getRouter>; + } +} +``` + +| Option | Type | Default | What it does | +|---|---|---|---| +| `routeTree` | route tree | required | TanStack's generated route tree. | +| `context` | object | none | Router context. | +| `Wrap` | component | none | Wraps the app, typically with providers. | +| `scrollRestoration` | `boolean` | `true` | Restores scroll on back/forward. | +| `defaultPreload` | `"intent" \| "viewport" \| "render" \| false` | `"intent"` | When links preload their route. | +| `trailingSlash` | TanStack option | TanStack's default | Trailing-slash handling. | + +`decoParseSearch` and `decoStringifySearch` are the two serializers, exported for use elsewhere. + +## The Vite plugin + +`decoVitePlugin()` from `@decocms/tanstack/vite` must be in your Vite config. The module is plain JavaScript without type declarations, so TypeScript configs need a `// @ts-expect-error` on the import. + +```ts title="vite.config.ts" +import { cloudflare } from "@cloudflare/vite-plugin"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +// @ts-expect-error -- plain JS module without type declarations +import { decoVitePlugin } from "@decocms/tanstack/vite"; + +export default defineConfig({ + plugins: [ + cloudflare({ viteEnvironment: { name: "ssr" } }), + tanstackStart({ server: { entry: "server" } }), + react(), + decoVitePlugin(), + ], + define: { + "process.env.DECO_SITE_NAME": JSON.stringify(process.env.DECO_SITE_NAME || "my-store"), + }, + resolve: { + dedupe: ["@decocms/blocks", "@decocms/blocks-admin", "@decocms/tanstack", "react", "react-dom"], + }, +}); +``` + +What the plugin does: + +- **Keeps server code out of the browser.** In the client build it replaces `react-dom/server`, `node:async_hooks`, the schema (`meta.gen.json`), the decofile (`blocks.gen.ts`) and the generated loader map (`loaders.gen.ts`) with empty stubs, so content, schema and loader source never ship to the browser. +- **Loads content on the server.** In the server build, `.deco/blocks.gen.ts` becomes a `JSON.parse` of its `.json` sibling. +- **Stamps a build hash.** It defines `__DECO_BUILD_HASH__` from the CI commit or `git rev-parse`, which the Worker uses to version its cache when `BUILD_HASH` isn't set. +- **Regenerates in development.** It watches `.deco/blocks/*.json` and applies edits live, regenerates `.deco/meta.gen.json` when files under `src/` change, and runs [`generate`](/v7/generate) for sections, loaders and invoke on start and on changes. If `blocks.gen.json` or `meta.gen.json` is missing on a fresh clone, it generates them before the first request. +- **Provides a default start entry.** If you have no `src/start.ts`, it uses one that wires `decoServerFnFetch` (next section). +- **Splits vendor chunks** for React, the router and React Query in production builds. It deliberately leaves `@decocms/*` packages unsplit: they import each other in a cycle, and splitting them gives a chunk load order that crashes at runtime. If you customize `build.rollupOptions.output.manualChunks`, don't give these packages their own chunks. + +`decoVitePlugin({ fastDeploy })` takes one option. `fastDeploy: "auto"` (the default) removes the bundled decofile from the server bundle only when the build sets `DECO_SEEDED_DEPLOY`. `true` forces that, and `false` never does it. Without the bundled copy there is no fallback if Fast Deploy can't read KV, so leave it on `"auto"` unless your pipeline seeds KV before every deploy. See [Deploying and Fast Deploy](/v7/releases). + +### React Compiler + +This isn't done by the plugin: the migrator's `vite.config.ts` also turns on the [React Compiler](https://react.dev/learn/react-compiler) with `react({ babel: { plugins: [["babel-plugin-react-compiler", { target: "19" }]] } })` and `babel-plugin-react-compiler` as a dev dependency. It's optional for new sites. + +## Server function fetch + +TanStack Start calls server functions (such as the deferred-section loader) over `/_serverFn`. `decoServerFnFetch` is a `fetch` that adds a cache-segment marker to those calls, so the CDN can cache them safely. The Vite plugin wires it automatically unless your site has its own `src/start.ts`. If it does, add it there: + +```ts title="src/start.ts" +import { createStart } from "@tanstack/react-start"; +import { decoServerFnFetch } from "@decocms/tanstack/sdk/serverFnFetch"; + +export const startInstance = createStart(() => ({ + serverFns: { fetch: decoServerFnFetch }, +})); +``` + +Import it from its subpath, not the package root: `src/start.ts` is part of the client bundle. + +## Cookie passthrough + +`@decocms/tanstack/sdk/cookiePassthrough` bridges cookies between the browser and an upstream API inside a server function: + +- `getRequestCookieHeader()` returns the incoming `Cookie` header, or `undefined` outside a request. +- `forwardResponseCookies(cookies: string[])` appends `Set-Cookie` headers to the current response, keeping any already set. Outside a request it does nothing. + +You don't need them for the VTEX actions: the invoke file that [`generate`](/v7/generate) writes carries its own cookie bridge, so those actions already pass the platform's `Set-Cookie` headers to the browser. Use the helpers in your own server functions that talk to a cookie-based API. + +## wrangler.jsonc + +```jsonc title="wrangler.jsonc" +{ + "name": "my-store", + "main": "src/worker-entry.ts", + "compatibility_date": "2025-05-01", + "compatibility_flags": ["nodejs_compat", "no_handle_cross_request_promise_resolution"], + "version_metadata": { "binding": "CF_VERSION_METADATA" }, + "vars": { + "DECO_SITE_NAME": "my-store", + "DECO_ENV_NAME": "production" + } +} +``` + +- `main` must be your worker entry, not TanStack's server entry. +- `nodejs_compat` is required: [request context](/v7/request-context) uses `AsyncLocalStorage`. +- `no_handle_cross_request_promise_resolution` is required: the framework's caches share in-flight promises between requests, and without the flag the Worker hangs. +- `version_metadata` lets telemetry report which deploy served a request. +- For Fast Deploy, add a `DECO_KV` binding and `"DECO_FAST_DEPLOY": "1"`, and call `setupTanstackFastDeploy()` in `src/setup.ts`. See [Deploying and Fast Deploy](/v7/releases). + +Pass `BUILD_HASH` per deploy so each deploy gets a fresh cache namespace: + +```bash +npx wrangler deploy --var BUILD_HASH:$(git rev-parse --short HEAD) +``` + +## Related + +- [Quickstart: TanStack Start](/v7/quickstart) builds the files on this page from scratch. +- [Caching](/v7/caching) covers profiles, `X-Cache` headers and purging. +- [The Worker request pipeline](/v7/request-pipeline) shows what `createDecoWorkerEntry` does with each request. +- [Deferred sections](/v7/rendering) explains when sections are deferred. +- [Troubleshooting](/v7/troubleshooting) lists symptoms and fixes. diff --git a/docs/content/v7/troubleshooting.mdx b/docs/content/v7/troubleshooting.mdx new file mode 100644 index 00000000..619912fb --- /dev/null +++ b/docs/content/v7/troubleshooting.mdx @@ -0,0 +1,258 @@ +--- +title: Troubleshooting +group: Production +order: 40 +description: Symptoms you may hit on a v7 site, what causes each one, and how to fix it. +--- + +# Troubleshooting + +Each entry below starts from what you see, explains the cause, and gives the fix. Most are about wiring: a file imported in the wrong order, a handler mounted in the wrong place, a module that runs in a different bundle than you expect. Entries for both bindings come first, then TanStack Start, then Next.js. + +## Both bindings + +### Sections render empty, or with "Cannot read properties of undefined" + +**Cause.** A section has its own `loader` export, and you registered a [section loader](/v7/glossary#section-loader) for it built only from mixins, such as `compose(withDevice(), withSearchParam())`. A registered section loader replaces the section's own loader, so the section's data is never fetched and the component receives props it doesn't expect. + +**Fix.** Add `withSectionLoader` to the composition, last, so the section's own loader runs after the mixins: + +```ts title="src/setup/section-loaders.ts" +import { compose, registerSectionLoaders, withDevice, withSearchParam, withSectionLoader } from "@decocms/blocks/cms"; + +registerSectionLoaders({ + "site/sections/Product/SearchResult.tsx": compose( + withDevice(), + withSearchParam(), + withSectionLoader(() => import("../sections/Product/SearchResult")), + ), +}); +``` + +See [Loaders and actions](/v7/loaders). + +### Hydration mismatch on `dangerouslySetInnerHTML.__html` + +**Cause.** `useScript(fn, ...args)` serializes a function with `fn.toString()`, and the server and client builds compile the same function to different text. React then sees different `__html` on each side. `useScript` and `useScriptAsDataURI` are deprecated for this reason; in development they warn once per function. + +**Fix.** Write the script as a string and use `inlineScript`: + +```tsx title="src/sections/Hero.tsx" +import { inlineScript } from "@decocms/blocks/sdk/useScript"; + +const markReady = (id: string) => `document.getElementById("${id}").dataset.ready = "true"`; + +export default function Hero() { + return ( + <div id="hero"> + <script {...inlineScript(markReady("hero"))} /> + </div> + ); +} +``` + +### `node:async_hooks` error in a client bundle + +**Cause.** A file that runs in the browser imports from `@decocms/blocks/cms`. That barrel is server-only: it imports `AsyncLocalStorage` from `node:async_hooks`. On Next.js, Turbopack rejects the import; other bundlers may fail later or ship a broken module. + +**Fix.** In Client Components and other browser code, import registry lookups (`getResolvedComponent`, `getSection`, `registerSection`, the section mixins) from `@decocms/blocks/cms/client`. Read request state through `@decocms/blocks/sdk/requestContext`, which resolves to a browser-safe stub in client bundles (see [Request context](/v7/request-context)). + +### A section is `null`, and the console says `[CMS] Unhandled resolver` + +**Cause.** A block in the decofile has a `__resolveType` that nothing registered: a section key that doesn't match a file, or a loader from an app that wasn't configured. It resolves to `null` with a warning rather than an error. + +**Fix.** Check that the key matches `site/sections/<path>.tsx` exactly, that the section file exists, and that the app providing the loader is installed and configured (see [Apps](/v7/apps)). For loaders and actions with no registration at all, `onDanglingReference` in `createSiteSetup` lets you log or replace them. + +### `/deco/invoke` returns 404 "Unknown handler" + +**Cause.** The key you invoked isn't registered. Site loaders and actions come from the generated `.deco/loaders.gen.ts`; app loaders and actions come from configured apps. + +**Fix.** Run `generate` again after adding a loader, and make sure the module that registers the invoke handlers is imported by the server (see [Loaders and actions](/v7/loaders)). Keys work with and without the `.ts` suffix. + +### Product or listing pages show "no product" after an upgrade + +**Cause.** One of two things, both only on sites that moved from older packages: + +- **Loader keys with `.ts`.** Older decofiles reference app loaders with the file extension (`shopify/loaders/ProductDetailsPage.ts`), while apps register them without it. `@decocms/blocks` falls back to the extension-less key from 7.11.2; on earlier versions the inner loader isn't found and returns `null`. +- **An app registry entry that fails in the production build only.** Registry entries load their module with a dynamic `import()`, which resolves in `vite dev` but can fail in the production Worker bundle. The app is then skipped, and its loaders resolve to `null`. Since autoconfig logs `[autoconfigApps] failed to configure app`, check the Worker's logs. + +**Fix.** Upgrade `@decocms/blocks` (or register both key forms on older versions), and pass app modules statically to `autoconfigApps`: + +```ts title="src/setup/apps.ts" +import { autoconfigApps } from "@decocms/blocks-admin/apps/autoconfig"; +import { SHOPIFY_REGISTRY_ENTRY } from "@decocms/apps-shopify/registry"; +import * as shopifyMod from "@decocms/apps-shopify/mod"; + +export const setupApps = (blocks: Record<string, unknown>) => + autoconfigApps(blocks, [{ ...SHOPIFY_REGISTRY_ENTRY, module: async () => shopifyMod }]); +``` + +Test with the production build (`bun run preview` on TanStack), not only the dev server, and load a real product page in a browser: commerce sections are often [deferred](/v7/rendering), so a `curl` of the HTML doesn't exercise them. + +### Signals don't re-render a component + +**Cause.** Migrated code reads `signal.value` during render. In Preact that subscribed the component; in React it doesn't. + +**Fix.** `signal()` from `@decocms/blocks/sdk/signal` is backed by a TanStack store and exposes it as `.store`. Subscribe to that store with `useStore` from `@tanstack/react-store` and render the value it returns: + +```tsx title="src/components/CartCount.tsx" +import { useStore } from "@tanstack/react-store"; +import { signal } from "@decocms/blocks/sdk/signal"; + +export const cartCount = signal(0); + +export function CartCount() { + const count = useStore(cartCount.store); + return <span>{count}</span>; +} +``` + +Writes through `cartCount.value = n` still work and now re-render every subscribed component. + +## TanStack Start + +### Client navigation shows "No CMS page block matches this URL", but reloading works + +**Cause.** The server rendered the first page correctly, but the server-function call made during client navigation ran in a module that loaded before your setup. TanStack Start splits server functions into separate chunks, and if `src/setup.ts` isn't imported first, those chunks can run before the content and sections are registered. + +**Fix.** Make `import "./setup";` the **first** import in both `src/server.ts` and `src/worker-entry.ts`. + +### `/live/_meta` returns HTML, and responses have no `X-Cache` header + +**Cause.** The Worker isn't running `createDecoWorkerEntry`. Either `wrangler.jsonc`'s `main` doesn't point at `src/worker-entry.ts`, or the admin and cache logic was placed inside TanStack's `createServerEntry`, where Vite strips custom request handling from production builds. + +**Fix.** Set `"main": "src/worker-entry.ts"` and wrap the server entry there, passing the admin handlers (see [TanStack Start on Cloudflare Workers](/v7/tanstack)). To check a build, search the built worker in `dist/` for `X-Cache` or `_cache/purge`; if they're missing, the wrapper isn't in the bundle. + +### "Failed to fetch dynamically imported module" after a deploy + +**Cause.** The edge served HTML cached by the previous deploy, which points at JavaScript chunks the new deploy no longer has. + +**Fix.** Pass a new `BUILD_HASH` on every deploy (`wrangler deploy --var BUILD_HASH:$(git rev-parse --short HEAD)`) so each deploy uses its own cache namespace (see [Deploying and Fast Deploy](/v7/releases)). To clear pages immediately, call `POST /_cache/purge` (see [Caching](/v7/caching#purging)). + +### Eager sections flash or disappear during hydration + +**Cause.** A section rendered above the fold isn't registered synchronously, so on the client it loads through `React.lazy` and suspends while hydrating. In development the console says `[DecoPageRenderer] Eager section "…" is not in registerSectionsSync()`. + +**Fix.** Mark the section with `export const sync = true` and regenerate, so it's bundled synchronously instead of lazy-loaded, or register it yourself with `registerSectionsSync`. See [Section conventions](/v7/sections). + +### Deferred sections stay as skeletons after client navigation + +**Cause.** `DecoPageRenderer` didn't get `loadDeferredSectionFn`. Deferred sections that stream during the first render have nothing to load them on later navigations. + +**Fix.** Pass `deferredSectionLoader` from `@decocms/tanstack/sdk/deferredSectionLoader`: + +```tsx +<DecoPageRenderer + sections={data.resolvedSections ?? []} + deferredSections={data.deferredSections ?? []} + pagePath={data.pagePath} + pageUrl={data.pageUrl} + loadDeferredSectionFn={deferredSectionLoader} +/> +``` + +### Pages always show `X-Cache: BYPASS` + +**Cause.** Read `X-Cache-Reason`. The most common: + +- `private-set-cookie`: something sets a cookie on every response. +- `logged-in`: `buildSegment` returned `loggedIn: true`. +- `profile:<name>` or `non-cacheable:<name>`: the URL maps to `private`, `cart` or `none`. +- `status:<code>` or `degraded`: the origin errored, or a section loader failed. + +**Fix.** For cookies, set them after the cache (in middleware or the browser) or add them to `safeCookies` if they're safe to share. For profiles, check the URL rules and your `detectProfile`. The full table is in [Caching](/v7/caching#reading-the-cache-headers). + +### A Studio publish doesn't show up with Fast Deploy + +**Cause.** The publish reached one isolate's memory but not KV. Either `setupTanstackFastDeploy()` isn't called in setup, or no deployment id resolved, or the KV write failed. The `POST /.decofile` response says `"kvWritten": false` in the last two cases. + +**Fix.** Call `setupTanstackFastDeploy()` in your setup module, deploy with `--var DECO_DEPLOYMENT_ID:<sha>` (or `BUILD_HASH`), and check that both `DECO_FAST_DEPLOY` and the `DECO_KV` binding are set. Code of your own that reads `loadBlocks()` at module scope won't see updates either; move it into the request path. See [Deploying and Fast Deploy](/v7/releases). + +### Dev HMR error: "Route cannot have both an 'id' and a 'path' option" + +**Cause.** An admin route passes a shared config object, the pattern from before the admin routes became factories. The router mutates the object it receives, so the second evaluation in dev fails and every route returns 500 until restart. + +**Fix.** Call the factory in each route file: + +```ts title="src/routes/deco/meta.ts" +import { createFileRoute } from "@tanstack/react-router"; +import { decoMetaRouteConfig } from "@decocms/tanstack"; + +export const Route = createFileRoute("/deco/meta")(decoMetaRouteConfig()); +``` + +Do the same with `decoRenderRouteConfig()` and `decoInvokeRouteConfig()`. + +### `/_serverFn` returns 500 "Invalid server function ID" + +**Cause.** `@decocms/tanstack` was pre-bundled by Vite's dependency optimizer for the server, so its server functions weren't registered. + +**Fix.** Keep `decoVitePlugin()` in `vite.config.ts`. It adds `@decocms/tanstack` to the SSR environment's `resolve.noExternal`, so the package goes through TanStack Start's compiler instead of being pre-bundled. If you set `noExternal` for SSR yourself, make sure `@decocms/tanstack` stays in it (or set it to `true`). + +### Dev only: intermittent 500s on parallel `/_serverFn` requests + +**Cause.** In `vite dev`, the Cloudflare Vite plugin's local Worker runner sometimes fails concurrent server-function requests with `TypeError: Cannot read properties of undefined (reading 'method')`. With many deferred sections on a page, some stay empty. Deployed Workers aren't affected. + +**Fix.** Nothing in your code. Scroll slowly or reload to retry the failed sections, and check behaviour that depends on many parallel requests on a preview deploy. + +### Dev server: "tsImport(@decocms/blocks-cli/generate-blocks) returned an empty module namespace" + +**Cause.** Your lockfile resolves `tsx` 4.22.0 to 4.22.4, which have a loader bug inside the Vite dev server. + +**Fix.** Upgrade `tsx` to 4.22.5 or later. + +### Outbound requests rejected by a partner's firewall + +**Cause.** Some upstream APIs block requests without a `User-Agent`. + +**Fix.** Nothing to do by default: the Worker entry sets `User-Agent: Deco/<version> (+https://deco.cx)` on outgoing `fetch` calls that don't set one. To send your own, pass `outboundUserAgent: "<value>"`; `false` leaves `fetch` untouched. + +### A production build crashes on load with "undefined is not a function" + +**Cause.** A custom `manualChunks` rule put `@decocms/blocks`, `@decocms/blocks-admin`, `@decocms/tanstack` or an `@decocms/apps-*` package in a chunk of its own. These packages import each other in a cycle, so separate chunks load in an order that breaks. + +**Fix.** Remove the rule for `@decocms/*` packages and let the plugin's own splitting stand. See [The Vite plugin](/v7/tanstack#the-vite-plugin). + +## Next.js + +### `route.ts` crashes with "createContext is not a function" or "Class extends value undefined" + +**Cause.** The route handler imports from the root of `@decocms/nextjs`. Route handlers run under React's server build, and the root barrel includes Client Component code that can't load there. + +**Fix.** In `route.ts` files, import only from `@decocms/nextjs/routeHandlers` (and `/config`, `/setup`). The root package is for `page.tsx` and `layout.tsx`. + +### `/live/_meta` returns 503 "Schema not initialized" + +**Cause.** `createNextSetup` didn't get a `meta` option, so there's no schema to serve. + +**Fix.** Pass the generated schema lazily: + +```ts title="src/deco/setup.ts" +meta: () => import("deco/meta.gen.json").then((m) => m.default), +``` + +### Pages render without content, or Studio previews are empty + +**Cause.** `ensureSetup()` wasn't awaited before rendering. `createNextSetup` returns a function; nothing happens when the module is imported. + +**Fix.** Await it in the root layout, and pass it as `setup` to `createDecoRouteHandlers` and `createDecoPreviewPage` (see [Next.js App Router](/v7/nextjs)). + +### A Client Component section fails in Studio previews + +**Cause.** The preview was rendered with plain `renderToString`, which can't run Next's client references. + +**Fix.** Mount `createDecoPreviewPage` at `app/deco/preview/[[...path]]/page.tsx`; the catch-all route redirects preview requests there. Don't remove `"use client"` from the section to make the preview render. + +## Apps + +### VTEX checkout or session calls return 503 with an empty body for some shoppers + +**Cause.** VTEX's gateway rejects requests whose cookies contain non-ASCII characters, for example when a third-party tag writes an accented category name into a cookie. + +**Fix.** Make the call through `vtexFetchWithCookies`, which drops those cookies and logs `[vtex.cookie.dropped]` once per cookie. Passing the raw `Cookie` header to `vtexFetch` doesn't filter it. See [VTEX](/v7/vtex#rules-for-actions). + +## Related + +- [Configuration reference](/v7/configuration) +- [Caching](/v7/caching) +- [Observability](/v7/observability) diff --git a/docs/content/v7/upgrade-from-start.mdx b/docs/content/v7/upgrade-from-start.mdx new file mode 100644 index 00000000..3aff0149 --- /dev/null +++ b/docs/content/v7/upgrade-from-start.mdx @@ -0,0 +1,231 @@ +--- +title: Upgrading from @decocms/start 6.x +nav: From @decocms/start 6.x +group: Upgrading +order: 41 +description: Move a TanStack Start site from the single @decocms/start 6.x and @decocms/apps 5.x packages to the split v7 packages. +--- + +# Upgrading from @decocms/start 6.x + +Before v7, the framework shipped as two large packages: `@decocms/start` and `@decocms/apps`. v7 splits them into `@decocms/blocks`, `@decocms/blocks-admin`, `@decocms/blocks-cli`, `@decocms/tanstack` and one `@decocms/apps-*` package per platform. The functions are mostly the same; their import paths change. This page is for sites **already on TanStack Start** with `@decocms/start` 6.x and `@decocms/apps` 5.x in `package.json`. A codemod does the mechanical part; the rest is a short list of manual changes and checks. + +If your site is still on Fresh and Deno, see [Migrating from Fresh and Deno](/v7/migrate-from-fresh). For a Next.js site on `@decocms/start` 5.x, see [Moving a Next.js site off @decocms/start 5.x](/v7/nextjs-from-start). + +## Plan the work as four commits + +Each step leaves the repository in a state you can review and type-check on its own: + +1. **Dependencies.** Swap the packages and the `generate` script. +2. **Imports.** Run the codemod; no behaviour changes. +3. **Generated files.** Move generated artifacts into `.deco/`. +4. **Bump and verify.** Pin versions, regenerate, run the checks. + +Using an AI coding agent? The repository ships an [Agent Skill](https://github.com/decocms/blocks/tree/main/.agents/skills/decocms-v6-to-v7-upgrade) for this upgrade, a folder of instructions an agent loads. Install it with the [`skills` CLI](https://www.npmjs.com/package/skills): `npx skills add decocms/blocks --skill decocms-v6-to-v7-upgrade`. + +Before you start, record the current type-check output (`tsc --noEmit`) on the unchanged branch. Many sites have pre-existing errors; the goal is **no new errors**, not zero. + +## 1. Swap the dependencies + +Remove `@decocms/start` and `@decocms/apps`. Add the framework packages: + +```bash +bun add @decocms/blocks @decocms/blocks-admin @decocms/tanstack +``` + +```bash +bun add -d @decocms/blocks-cli +``` + +Then add only the app packages the site imports. List them with: + +```bash +grep -rhoE '@decocms/apps/[a-z-]+' src/ | sort -u +``` + +Each `@decocms/apps/<vendor>` becomes `@decocms/apps-<vendor>`. Almost every site also needs `@decocms/apps-commerce` (shared types and helpers) and `@decocms/apps-website` (SEO and analytics sections). The repository's own guidance targets 7.6.0 or later; 7.7.0 or later removes two workarounds noted below. + +In the same commit: + +- **Replace the chain of `generate:*` scripts** with the single orchestrator, folding in any per-generator flags (`--exclude`, `--namespace`, `--skip-apps`, `--platform`): + + ```json title="package.json" + { + "scripts": { + "generate": "tsx node_modules/@decocms/blocks-cli/scripts/generate.ts --site my-store", + "build": "npm run generate && tsr generate && vite build" + } + } + ``` + + Commit `.deco/generate.digests.json`, which it writes, so fresh clones and CI skip unchanged generators. `.deco/.cache/` stays out of git (see [Code generation](/v7/generate)). +- **Update `resolve.dedupe`** in `vite.config.ts`: replace `@decocms/start` and `@decocms/apps` with the new package names. +- **Delete a stale `package-lock.json`** if the site uses Bun. An npm install from an old lockfile installs the 6.x packages again, and the upgrade silently doesn't take effect. + +## 2. Rewrite the imports + +The `deco-upgrade-6-to-7` codemod rewrites import paths in `src/` and updates `package.json`. Run it without flags first to see what it would change, then apply: + +```bash +npx -p @decocms/blocks-cli deco-upgrade-6-to-7 +``` + +```bash +npx -p @decocms/blocks-cli deco-upgrade-6-to-7 --write +``` + +`--src-dir <dir>` scans another directory. The codemod adds the framework packages and `@decocms/apps-commerce` to `package.json` at `latest`; pin exact versions after installing. It prints lines starting with `MANUAL:` for changes it can't make itself. + +### The import mapping + +| Before (6.x) | After (v7) | +|---|---| +| `@decocms/start/sdk/<name>` | `@decocms/blocks/sdk/<name>` (same subpath) | +| `@decocms/start/cms`, server code | `@decocms/blocks/cms` | +| `@decocms/start/cms`, client code (`getSection`, `getSectionRegistry`) | `@decocms/blocks/cms/client` | +| `@decocms/start/setup` | `@decocms/blocks/setup` + `@decocms/blocks-admin/setup` (see below) | +| `@decocms/start/types/widgets` | `@decocms/blocks/types/widgets` | +| `@decocms/start` root (types) | `@decocms/blocks/types` | +| `@decocms/start/matchers/builtins` | Usually delete it (see below) | +| `@decocms/start/admin` | `@decocms/blocks-admin` | +| `@decocms/start/routes`: `cmsRouteConfig`, `cmsHomeRouteConfig`, `loadCmsPage`, `loadCmsHomePage`, `loadDeferredSection`, `withSiteGlobals` | `@decocms/tanstack` | +| `@decocms/start/routes`: `decoMetaRoute`, `decoRenderRoute`, `decoInvokeRoute` | `decoMetaRouteConfig()`, `decoRenderRouteConfig()`, `decoInvokeRouteConfig()` from `@decocms/tanstack` (now factories) | +| `@decocms/start/routes`: `deferredSectionLoader` | `@decocms/tanstack/sdk/deferredSectionLoader` | +| `@decocms/start/hooks`: `DecoPageRenderer`, `DecoRootLayout`, `SectionRenderer`, `PreviewProviders` | `@decocms/tanstack` | +| `@decocms/start/hooks`: `RenderSection` | `@decocms/blocks/hooks` (the codemod instead renames it to `SectionRenderer` from `@decocms/tanstack`; either works) | +| `@decocms/start/sdk/router` (`createDecoRouter`), `@decocms/start/sdk/workerEntry` (`createDecoWorkerEntry`) | `@decocms/tanstack` | +| `@decocms/start/vite` | `@decocms/tanstack/vite` | +| `@decocms/start/sdk/cookiePassthrough` | `@decocms/tanstack/sdk/cookiePassthrough` | +| `@decocms/start/sdk/createInvoke` | `@decocms/tanstack/sdk/createInvoke` | +| `@decocms/start/sdk/useHydrated` | `useHydrated` from `@tanstack/react-router` | +| `@decocms/apps/<vendor>/<path>` | `@decocms/apps-<vendor>/<path>` | +| `@decocms/apps/commerce/{sdk,types,utils}/*` | `@decocms/apps-commerce/*` | +| `@decocms/apps/commerce/components/{Image,Picture,JsonLd}` | `@decocms/blocks/hooks` | +| `@decocms/apps/website/*` | `@decocms/apps-website/*` | +| `@decocms/apps/registry` (`APP_REGISTRY`) | Each app's own `@decocms/apps-<vendor>/registry` entry (see below) | + +The codemod covers the framework paths and `@decocms/apps/{commerce,vtex}`; rewrite the other `@decocms/apps/<vendor>` paths by hand with the same rule. + +### Split the setup call + +v6's setup took every option in one call. In v7, the framework half and the admin half are separate functions in separate packages: + +```ts title="src/setup.ts" +import { createSiteSetup } from "@decocms/blocks/setup"; +import { createAdminSetup } from "@decocms/blocks-admin/setup"; +import { PreviewProviders } from "@decocms/tanstack"; +import { blocks } from "../.deco/blocks.gen"; +import appCss from "./styles/app.css?url"; + +createSiteSetup({ + sections: import.meta.glob("./sections/**/*.tsx"), + blocks, + productionOrigins: ["https://www.example.com"], +}); + +createAdminSetup({ + meta: () => import("../.deco/meta.gen.json").then((m) => m.default), + css: appCss, + previewWrapper: PreviewProviders, +}); +``` + +- `createSiteSetup` takes `sections`, `blocks`, `productionOrigins`, `customMatchers`, `onResolveError`, `onDanglingReference` and `initPlatform`. +- `createAdminSetup` takes `meta`, `css`, `fonts`, `previewWrapper` and `getCommerceLoaders`. +- Remove `customMatchers: [registerBuiltinMatchers]` and its import: `createSiteSetup` always registers the built-in matchers. + +### Use the admin route factories + +The admin routes are now factories. Call them in each route file, and never pass a shared object: + +```ts title="src/routes/deco/meta.ts" +import { createFileRoute } from "@tanstack/react-router"; +import { decoMetaRouteConfig } from "@decocms/tanstack"; + +export const Route = createFileRoute("/deco/meta")(decoMetaRouteConfig()); +``` + +Same for `/deco/render` with `decoRenderRouteConfig()` and `/deco/invoke/$` with `decoInvokeRouteConfig()`. These factories exist from 7.10.0. + +### Rebuild the app registry + +6.x exported one aggregate `APP_REGISTRY` from `@decocms/apps/registry`. v7 has none: each app package exports its own entry from `./registry`, and you list the ones whose loaders appear in your content. Pass the modules statically, because the entries' dynamic imports can fail in the production Worker bundle: + +```ts title="src/setup/apps.ts" +import { autoconfigApps, type AppRegistry } from "@decocms/blocks-admin/apps"; +import { VTEX_REGISTRY_ENTRY } from "@decocms/apps-vtex/registry"; +import * as vtexMod from "@decocms/apps-vtex/mod"; + +const APP_REGISTRY: AppRegistry = [{ ...VTEX_REGISTRY_ENTRY, module: async () => vtexMod }]; + +export const setupApps = (blocks: Record<string, unknown>) => autoconfigApps(blocks, APP_REGISTRY); +``` + +To see which app namespaces your content uses: + +```bash +grep -rhoE '"(shopify|vtex|commerce|website|algolia|magento|salesforce)/[^"]+"' .deco/blocks/ | sort -u +``` + +A namespace with no configured app leaves its loaders unresolved, and their sections render empty. See [Apps](/v7/apps). + +### Remove old shims + +On 7.7.0 or later, delete local copies of `deferredSectionLoader` and pass the public one to `DecoPageRenderer`. On 7.6.0 or later, use `@decocms/tanstack/sdk/cookiePassthrough` instead of a local cookie-passthrough shim, and import it only from server-only modules. + +## 3. Move generated files into `.deco/` + +v7's generators write to `.deco/` instead of `src/server/`: + +```bash +git mv src/server/cms/blocks.gen.json .deco/blocks.gen.json +``` + +Do the same for `blocks.gen.ts`, `loaders.gen.ts` and `src/server/admin/meta.gen.json` (to `.deco/meta.gen.json`), regenerate `sections.gen.ts` into `.deco/`, point the imports in `src/setup.ts` at `../.deco/…`, and delete the empty `src/server/cms/` and `src/server/admin/` directories. + +<Callout type="warning"> + +**`src/server/invoke.gen.ts` stays in `src/`.** TanStack Start's compiler must see it there; moved under `.deco/`, every `/_serverFn` call fails on the server. + +</Callout> + +Don't name any file of your own `*blocks.gen.ts`: the Vite plugin replaces modules with that suffix with an empty stub in the client bundle. + +## 4. Bump, regenerate and verify + +Pin the final versions, install, and regenerate everything (a version change invalidates the generate cache): + +```bash +bun install +``` + +```bash +bun run generate --force +``` + +Then run these checks. A clean type-check alone isn't enough. + +1. **Clean install.** The lockfile contains no `@decocms/start` or `@decocms/apps` entries. +2. **Regeneration is stable.** After `generate --force` and a build, `git status` shows no changes. +3. **No new type errors** compared with the baseline you recorded. +4. **The build passes.** `bun run build` catches server-only imports that reach the client bundle. +5. **Dev smoke test.** `bun run dev`, then `/` renders, and `/live/_meta` and `/.decofile` return JSON. +6. **The production build works.** Run `bun run preview` and load a real product page and listing page in a browser, checking that products render. Commerce sections are often [deferred](/v7/rendering), and some failures (such as app modules that don't resolve) only happen in the production bundle. +7. **Parity with production.** `/live/_meta` matches the deployed site's schema, and `/.decofile` differs only by content changes you expect. + +## Version notes + +The repository records these changes within 7.x: + +| Version | Change | +|---|---| +| 7.6.0 | `getRequestCookieHeader` and `forwardResponseCookies` public at `@decocms/tanstack/sdk/cookiePassthrough`. | +| 7.7.0 | `deferredSectionLoader` public at `@decocms/tanstack/sdk/deferredSectionLoader`. `generate` finds the VTEX app's invoke file without `--apps-dir`, and generated `sections.gen.ts` declares `neverDefer`. | +| 7.10.0 | Admin routes exist only as factories (`decoMetaRouteConfig()` and friends). | +| 7.11.2 (`@decocms/blocks`) | Loader keys with a `.ts` suffix fall back to the extension-less registration. | + +## Related + +- [Packages and exports](/v7/packages): every v7 import path. +- [Troubleshooting](/v7/troubleshooting#product-or-listing-pages-show-no-product-after-an-upgrade) +- [CLI reference](/v7/cli) diff --git a/docs/content/v7/variants.mdx b/docs/content/v7/variants.mdx new file mode 100644 index 00000000..4d2beb60 --- /dev/null +++ b/docs/content/v7/variants.mdx @@ -0,0 +1,220 @@ +--- +title: Matchers and variants +nav: Matchers & variants +group: Core concepts +order: 10 +description: Show different content to different visitors with multivariate flags and matchers, including A/B splits, device and location rules, and custom matchers. +--- + +# Matchers and variants + +The same page can show different content to different visitors: a mobile banner on phones, a sale hero until midnight, the new checkout to half of your traffic. In v7, editors set this up in content with a *variant* block that holds several alternatives, each guarded by a *matcher*, a rule that's true or false for the current request. This page shows how variants are stored, which matchers are built in, and how to add your own. + +<Terms> + <Term name="Matcher">A rule `(rule, context) => boolean` evaluated per request: device, cookie, date, location, a random traffic split. See the [glossary](/v7/glossary#matcher).</Term> + <Term name="Variant">One alternative in a multivariate flag: a `rule` (a matcher) and a `value` (the content to use when it matches).</Term> + <Term name="Multivariate flag">A block with a list of variants. The first variant whose rule matches is used.</Term> +</Terms> + +## A variant block + +A multivariate flag is a value with `__resolveType: "website/flags/multivariate.ts"` and a `variants` list. Each variant has a `rule`, which is a matcher block, and a `value`, which is whatever content to use. Here's a Hero that differs on mobile: + +```json title="A section with a mobile variant" +{ + "__resolveType": "website/flags/multivariate.ts", + "variants": [ + { + "rule": { "__resolveType": "website/matchers/device.ts", "mobile": true }, + "value": { "__resolveType": "site/sections/Hero.tsx", "title": "Tap to shop the sale", "image": "https://www.example.com/hero-mobile.jpg" } + }, + { + "rule": { "__resolveType": "website/matchers/always.ts" }, + "value": { "__resolveType": "site/sections/Hero.tsx", "title": "Summer sale", "image": "https://www.example.com/hero.jpg" } + } + ] +} +``` + +During resolution, the runtime checks each variant's rule in order and resolves the first matching `value`; the others are never resolved, so their loaders don't run. Put a catch-all (`always.ts`) last. If nothing matches, the flag resolves to nothing, and in a list of sections the entry is simply dropped. `website/flags/multivariate/section.ts` works the same way and is what Studio uses for section variants. + +Variants work anywhere a value can go: a whole section, a single prop, a loader's props, or a list of sections. + +## Built-in matchers + +These matchers are always available. Their keys are what you put in a rule's `__resolveType`, and the other fields of the rule are its settings. + +| Matcher key | Rule fields | True when | +|---|---|---| +| `website/matchers/always.ts` | none | Always | +| `website/matchers/never.ts` | none | Never | +| `website/matchers/device.ts` | `mobile`, `tablet`, `desktop` (booleans) | The user agent is one of the checked devices. With none checked, always. | +| `website/matchers/random.ts` | `traffic` (0 to 1, default `0.5`) | A random draw falls under `traffic`. Sticky per visitor; see below. | +| `website/matchers/date.ts` | `start`, `end` (ISO dates) | Now is strictly between them. Either may be omitted. | +| `website/matchers/cron.ts` | `start`, `end` | Like `date.ts`, inclusive at both ends. | +| `website/matchers/cookie.ts` | `name`, `value?` | The cookie exists (and equals `value`, when given). | +| `website/matchers/queryString.ts` | `key` (or `param`), `value?` | The search param exists (and equals `value`). | +| `website/matchers/pathname.ts` | `case: { type, pathname }` with `type` one of `Equals`, `Includes`, `Not Includes`, `Starts With`; or `pattern` / `includes` / `excludes` | The request path fits. | +| `website/matchers/host.ts` | `host` | The request host equals it. | +| `website/matchers/userAgent.ts` | `includes?`, `match?` (a regular expression) | The user agent contains `includes` and matches `match`. | +| `website/matchers/location.ts` | `includeLocations`, `excludeLocations`: lists of `{ country?, regionCode?, city?, coordinates? }` | The visitor's location (from the platform's geo headers) is in an included location and not in an excluded one. `coordinates` is `"lat,lng,radius-in-meters"`. | +| `website/matchers/environment.ts` | `environment`: `"production"` or `"development"` | `NODE_ENV` matches. | +| `website/matchers/multi.ts` | `op`: `"and"` or `"or"`, `matchers`: a list of rules | All (or any) of the inner rules match. | +| `website/matchers/negate.ts` | `matcher`: a rule | The inner rule doesn't match. | + +A rule can also be a reference to a saved matcher block by name, such as `{ "__resolveType": "MobileVisitors" }`, so editors can define a segment once and reuse it. + +<Callout>**Location rules and the edge cache.** On TanStack Start, a page whose content depends on location must be cached per location, or every visitor gets the first visitor's variant. The Worker detects `location.ts` in your content and adds the visitor's region to its cache key automatically. Don't target finer than the cache key (city or coordinates) on cached pages. See [Caching](/v7/caching).</Callout> + +## A/B tests with random traffic + +`website/matchers/random.ts` splits traffic. With `"traffic": 0.5`, about half of your visitors match: + +```json title="A 50/50 test of two heroes" +{ + "__resolveType": "website/flags/multivariate.ts", + "variants": [ + { + "rule": { "__resolveType": "website/matchers/random.ts", "traffic": 0.5 }, + "value": { "__resolveType": "site/sections/Hero.tsx", "title": "Free shipping on orders over $50" } + }, + { + "rule": { "__resolveType": "website/matchers/always.ts" }, + "value": { "__resolveType": "site/sections/Hero.tsx", "title": "10% off your first order" } + } + ] +} +``` + +On TanStack Start the decision is sticky: it's stored in the `deco_segment` cookie, so a visitor keeps seeing the same variant on every page and visit. If an editor changes `traffic`, each visitor is re-rolled once. The edge cache keeps a separate entry per cohort, so cached pages stay consistent with the cookie. + +<Callout> + +**Give each test its own saved matcher.** The sticky decision is stored under the matcher's name. A saved matcher block (say, `HeroTest`) gets its own entry, but every inline `random.ts` rule shares the name `website/matchers/random.ts`, so two unrelated inline 50% tests put each visitor in the same group for both. A saved matcher also gives the same answer everywhere you reuse it. + +</Callout> + +## Vary a whole page + +A page's `sections` can itself be a variant whose values are complete section lists, for example a different layout for mobile visitors: + +```json title=".deco/blocks/pages-home.json (excerpt)" +"sections": { + "__resolveType": "website/flags/multivariate.ts", + "variants": [ + { + "rule": { "__resolveType": "website/matchers/device.ts", "mobile": true }, + "value": [{ "__resolveType": "Header" }, { "__resolveType": "site/sections/MobileHero.tsx" }] + }, + { + "rule": { "__resolveType": "website/matchers/always.ts" }, + "value": [{ "__resolveType": "Header" }, { "__resolveType": "site/sections/Hero.tsx" }] + } + ] +} +``` + +Variants nest: a variant's value can be another variant block. + +## Schedule a campaign + +`website/matchers/date.ts` matches between two instants. Here's a Black Friday hero that replaces the regular one only during the event: + +```json title="A hero scheduled for Black Friday" +{ + "__resolveType": "website/flags/multivariate.ts", + "variants": [ + { + "rule": { + "__resolveType": "website/matchers/date.ts", + "start": "2026-11-27T00:00:00-03:00", + "end": "2026-11-30T23:59:59-03:00" + }, + "value": { "__resolveType": "site/sections/Hero.tsx", "title": "Black Friday: up to 50% off" } + }, + { + "rule": { "__resolveType": "website/matchers/always.ts" }, + "value": { "__resolveType": "site/sections/Hero.tsx", "title": "New season arrivals" } + } + ] +} +``` + +Write the dates with an explicit time-zone offset, so the campaign starts at midnight in your store's time zone, not the server's. The match is strictly between `start` and `end`. Cached pages switch when their edge-cache entry expires, so the change can lag `start` and `end` by up to the page's cache lifetime; see [Caching](/v7/caching). + +## Write a custom matcher + +A matcher is a function from the rule's settings and the request context to a boolean. Register it with `registerMatcher` from `@decocms/blocks/cms`, by passing a registration function in `customMatchers`: + +```ts title="src/matchers/vip.ts" +import { registerMatcher } from "@decocms/blocks/cms"; + +export function registerVipMatcher() { + registerMatcher("site/matchers/vip.ts", (rule, ctx) => { + const tier = typeof rule.tier === "string" ? rule.tier : "gold"; + return ctx.cookies?.loyalty_tier === tier; + }); +} +``` + +```ts title="src/setup.ts (excerpt)" +import { registerVipMatcher } from "./matchers/vip"; + +createSiteSetup({ + sections, + blocks, + customMatchers: [registerVipMatcher], +}); +``` + +Content can now use `{ "__resolveType": "site/matchers/vip.ts", "tier": "platinum" }` as a rule. The context gives you: + +| Field | What it holds | +|---|---| +| `url`, `path` | The page's full URL and path | +| `userAgent` | The `User-Agent` header | +| `cookies` | Cookies as an object | +| `headers` | Request headers as an object | +| `request` | The `Request` itself | + +Matchers run during resolution, on every request that resolves the page, so keep them fast and synchronous: read from the request, don't fetch. An unknown matcher key evaluates to `false` with a warning in the log. + +To make a custom matcher selectable in Studio, describe it with `registerMatcherSchema({ key: "site/matchers/vip.ts", title: "Loyalty tier", namespace: "site" })` from the same package. Add a `propsSchema` (a JSON Schema object) to give its settings, such as `tier`, a form. + +## Force a variant for testing + +To check what a variant looks like without meeting its rule, send the `x-deco-matchers-override` header (or the same name as a query parameter). Its value is space-separated `name=1` (force true) or `name=0` (force false) pairs, where `name` is a saved matcher block's name. Because pairs are separated by spaces, a name that contains a space only works in the query parameter: + +```bash +curl -s https://www.example.com/ -H "x-deco-matchers-override: MobileVisitors=1" +``` + +As a query parameter, URL-encode the pair, and repeat the parameter for several: `?x-deco-matchers-override=Mobile%20visitors%3D1`. On TanStack Start, requests that carry it bypass the edge cache. + +## Feature flags from PostHog + +If you run feature flags in PostHog, `@decocms/blocks/matchers/posthog` bridges them into matchers, without adding PostHog as a dependency. Configure an adapter once, at module scope in setup, and register the matcher: + +```ts title="src/setup.ts (excerpt)" +import { registerMatcher } from "@decocms/blocks/cms"; +import { configurePostHogMatcher, createPostHogMatcher } from "@decocms/blocks/matchers/posthog"; +import posthog from "posthog-js"; + +configurePostHogMatcher({ + isFeatureEnabled: (key) => posthog.isFeatureEnabled(key) ?? false, + getFeatureFlagVariant: (key) => posthog.getFeatureFlag(key), +}); +registerMatcher("posthog/matchers/featureFlag.ts", createPostHogMatcher()); +``` + +Rules then look like `{ "__resolveType": "posthog/matchers/featureFlag.ts", "flagKey": "new-checkout", "variant": "test" }`. The adapter is called synchronously, so flags must already be loaded when the page resolves. + +## Other flag APIs + +Two more modules exist for advanced cases. `@decocms/blocks/flags/*` is a separate, function-composition flag API carried over from the Fresh-era `website` app; it doesn't use the matcher registry above. `@decocms/blocks/sdk/experiments` reads N-way experiment configurations published to a KV store; it's experimental and depends on platform infrastructure, so prefer multivariate flags. + +## Next steps + +- [Caching](/v7/caching): how variants interact with the edge cache. +- [Previews and draft preview](/v7/preview): preview variants before publishing. +- [Content and the decofile](/v7/content): where variant blocks live. diff --git a/docs/content/v7/vtex.mdx b/docs/content/v7/vtex.mdx new file mode 100644 index 00000000..a6bafa7a --- /dev/null +++ b/docs/content/v7/vtex.mdx @@ -0,0 +1,380 @@ +--- +title: VTEX +group: Apps +order: 28 +description: Configure the VTEX app, register its cached commerce loaders, wire checkout, segments and sitemaps into the Worker, and build the cart with Cart v2. +--- + +# VTEX + +`@decocms/apps-vtex` connects a site to VTEX. It wraps VTEX's APIs (Intelligent Search, the catalog, checkout and order forms, VTEX ID, sessions, Master Data) as [loaders](/v7/loaders) that return the shared [commerce types](/v7/apps-commerce) and actions that change carts, sessions and profiles. It also ships React hooks for the cart, user and wishlist, a request middleware for segments and logged-in visitors, and proxies that serve VTEX checkout and sitemaps from your storefront's domain. + +It's the most complete commerce app and the reference implementation of [Cart v2](/v7/apps-commerce#cart-v2-sections-and-projection). This page goes in the order you'll wire it up. + +```bash +bun add @decocms/apps-vtex @decocms/apps-commerce @decocms/apps-website +``` + +The TanStack Query hooks also need `@tanstack/react-query`, an optional peer dependency. + +## Key terms + +<Terms> + <Term name="Account">Your VTEX account name, such as `acme`. API hosts are derived from it.</Term> + <Term name="Order form">VTEX's cart. Its id travels in the `checkout.vtex.com__orderFormId` cookie.</Term> + <Term name="Segment">VTEX's per-visitor context (sales channel, region, price tables), carried in the `vtex_segment` cookie.</Term> + <Term name="Sales channel">A VTEX trade policy, `"1"` by default. Prices and availability can differ per channel.</Term> +</Terms> + +## Configuring + +Editors configure the app in a `deco-vtex` block. Install it with the registry entry, passing the module statically (see [Apps](/v7/apps#installing-apps-with-autoconfig)): + +```ts title="src/setup/apps.ts" +import { autoconfigApps, type AppRegistry } from "@decocms/blocks-admin/apps"; +import { loadBlocks } from "@decocms/blocks/cms"; +import { VTEX_REGISTRY_ENTRY } from "@decocms/apps-vtex/registry"; +import * as vtexMod from "@decocms/apps-vtex/mod"; + +const APP_REGISTRY: AppRegistry = [{ ...VTEX_REGISTRY_ENTRY, module: async () => vtexMod }]; + +await autoconfigApps(loadBlocks(), APP_REGISTRY); +``` + +`configure` returns `null`, and the app isn't installed, when the block has no `account`. The block's fields: + +| Field | What it does | +|---|---| +| `account` | Required. Your VTEX account name. | +| `publicUrl` | The public domain registered in VTEX's License Manager, such as `secure.mystore.com.br`. Product URLs normally use the host of the current request; this is the fallback when there's no request. | +| `appKey`, `appToken` | API credentials, as plain text or an encrypted secret. Fall back to the `VTEX_APP_KEY` and `VTEX_APP_TOKEN` environment variables. Sent only when both are set. Create them in VTEX as described in [API authentication using API keys](https://developers.vtex.com/docs/guides/api-authentication-using-api-keys). | +| `salesChannel` | Deprecated. The default sales channel, `"1"` when empty. | +| `locale` (or `defaultLocale`), `country`, `domain` | Locale for Intelligent Search, the ISO alpha-3 country for simulations (`BRA` by default), and the VTEX domain suffix (`com.br` by default). | + +The other fields Studio shows (`setRefreshToken`, `defaultSegment`, `usePortalSitemap`, `advancedConfigs`, `cachedSearchTerms`) exist so existing content validates. + +If you'd rather configure by hand, call `configureVtex(config)` from the package root, or `initVtexFromBlocks(blocks)` in `createSiteSetup`'s `initPlatform`. `initVtexFromBlocks` reads the `vtex` or `deco-vtex` block, but uses `appKey` and `appToken` only when they're plain strings, so prefer autoconfig when they're encrypted. + +## The instrumented, resilient fetch + +Call `setVtexFetch(createVtexFetch())` once, at module scope in your setup: + +```ts title="src/setup.ts" +import { setVtexFetch, createVtexFetch } from "@decocms/apps-vtex"; + +setVtexFetch(createVtexFetch()); +``` + +`createVtexFetch` gives every VTEX call two layers: + +- **Resilience.** A per-attempt timeout and a total timeout, retries for idempotent requests only (GET and HEAD without a body) within a per-host retry budget, and a per-host circuit breaker that stops sending requests to a failing host for a few seconds. Pass `resilience: false` to turn it off, or a partial config to tune it. Setting the environment variable `VTEX_RESILIENCE_DISABLED=true` (read from `process.env`) turns it off at runtime. +- **Instrumentation.** A span per call named by operation (such as `checkout.simulation` or `catalog.products.search`), and the upstream duration metric labelled `provider: "vtex"`. See [Observability](/v7/observability). + +Without `setVtexFetch`, VTEX calls use a plain fetch with a 10-second timeout and aren't measured. + +Read requests to the catalog, page types and Intelligent Search also go through a shared in-memory cache that serves stale data while it refreshes, keeps serving stale data for up to a day when VTEX errors, and merges concurrent identical requests. Cart and session calls are never cached. + +## Commerce loaders + +Content refers to VTEX data with blocks such as `"__resolveType": "vtex/loaders/intelligentSearch/productListingPage.ts"`. `createVtexCommerceLoaders` returns a ready-made map of these keys to cached loaders; register it with `registerCommerceLoaders`: + +```ts title="src/setup/commerce-loaders.ts" +import { registerCommerceLoaders } from "@decocms/blocks/cms"; +import { createVtexCommerceLoaders } from "@decocms/apps-vtex/commerceLoaders"; + +registerCommerceLoaders(createVtexCommerceLoaders()); +``` + +Each loader is wrapped in the framework's loader cache with a [cache profile](/v7/caching): `listing` for listing pages and shelves, `product` for product pages, `search` for suggestions. Override them with `cacheProfiles`, and add your own loaders under `extra`: + +```ts +createVtexCommerceLoaders({ + cacheProfiles: { product: "listing" }, + extra: { "site/loaders/featuredBrands.ts": featuredBrands }, +}); +``` + +The map covers these keys. Every key ending in `.ts` is also registered without the extension. + +| Key | Returns | +|---|---| +| `vtex/loaders/intelligentSearch/productListingPage.ts`, `vtex/loaders/ProductListingPage.ts` | `ProductListingPage` | +| `vtex/loaders/intelligentSearch/productDetailsPage.ts`, `vtex/loaders/legacy/productDetailsPage.ts`, `vtex/loaders/ProductDetailsPage.ts` | `ProductDetailsPage` | +| `vtex/loaders/intelligentSearch/productList.ts`, `vtex/loaders/legacy/productList.ts`, `vtex/loaders/ProductList.ts` | `Product[]` for shelves | +| `vtex/loaders/intelligentSearch/suggestions.ts` | `Suggestion` | +| `vtex/loaders/legacy/relatedProductsLoader.ts` | Related products | +| `vtex/loaders/workflow/products.ts` | Products for back-office workflows | +| `vtex/loaders/categories/tree` | The category tree (`categoryLevels`, 3 by default) | +| `commerce/loaders/navbar.ts`, `commerce/loaders/product/extensions/detailsPage.ts`, `website/functions/requestToParam.ts` | Small compatibility loaders older content uses | + +They handle a few details for you. A product page with no `slug` takes it from the page path. Listing pages understand legacy `?map=` URLs, including collection pages, and drop sort values on them that Intelligent Search doesn't accept. `createCachedPDPLoader(profile?)` returns a new cached product loader for your own section loaders. + +### Calling loaders from code + +The same loaders are plain functions. The stable entry points are the `inline-loaders` subpaths: + +| Import | Loader | +|---|---| +| `@decocms/apps-vtex/inline-loaders/productDetailsPage` | Product page by `slug` | +| `@decocms/apps-vtex/inline-loaders/productListingPage` | Listing page by `query`, `selectedFacets`, `sort`, `page`, `count` | +| `@decocms/apps-vtex/inline-loaders/productListShelf` | A shelf by collection, query, ids or facets (12 products by default) | +| `@decocms/apps-vtex/inline-loaders/productList` | A full product list | +| `@decocms/apps-vtex/inline-loaders/relatedProducts` | Related products | +| `@decocms/apps-vtex/inline-loaders/suggestions` | Autocomplete suggestions | +| `@decocms/apps-vtex/inline-loaders/minicart` | The visitor's cart as a `Minicart` (an empty one, without creating an order form, when there's no cart cookie) | + +Lower-level functions (catalog, logistics, orders, profile, session and more) are in `@decocms/apps-vtex/loaders` and its subpaths. + +## Wiring the Worker + +On TanStack, three pieces of VTEX behaviour plug into `createDecoWorkerEntry` (see [TanStack Start on Cloudflare Workers](/v7/tanstack)): + +- **`buildSegment`** tells the edge cache which visitors may share a cached page. `extractVtexContext(request)` reads the sales channel, region and login state from VTEX's cookies. +- **The checkout proxy** serves VTEX's checkout, account pages and APIs from your own domain, so cookies stay first-party. `shouldProxyToVtex(pathname)` decides which paths go to VTEX. +- **The sitemap proxy** serves VTEX's `/sitemap.xml` and `/sitemap/*` with your host in every URL. + +```ts title="src/worker-entry.ts" +import "./setup"; +import "./setup/apps"; +import "./setup/commerce-loaders"; +import handler, { createServerEntry } from "@tanstack/react-start/server-entry"; +import { createDecoWorkerEntry } from "@decocms/tanstack"; +import { + corsHeaders, + handleDecofileRead, + handleDecofileReload, + handleMeta, + handleRender, +} from "@decocms/blocks-admin"; +import { extractVtexContext } from "@decocms/apps-vtex/middleware"; +import { createVtexCheckoutProxy, shouldProxyToVtex } from "@decocms/apps-vtex/utils/proxy"; +import { createVtexSitemapProxy } from "@decocms/apps-vtex/utils/sitemap"; + +const serverEntry = createServerEntry({ fetch: handler.fetch }); + +const proxyCheckout = createVtexCheckoutProxy({ + account: "acme", + checkoutOrigin: "secure.store.example.com", +}); +const proxySitemap = createVtexSitemapProxy(); + +export default createDecoWorkerEntry(serverEntry, { + admin: { handleMeta, handleDecofileRead, handleDecofileReload, handleRender, corsHeaders }, + buildSegment: (request) => { + const vtex = extractVtexContext(request); + const ua = request.headers.get("user-agent") ?? ""; + return { + device: /mobile|android|iphone/i.test(ua) ? "mobile" : "desktop", + loggedIn: vtex.isLoggedIn, + salesChannel: vtex.salesChannel, + regionId: vtex.regionId ?? undefined, + }; + }, + proxyHandler: async (request, url) => { + const sitemap = await proxySitemap(request, url); + if (sitemap) return sitemap; + if (!shouldProxyToVtex(url.pathname)) return null; + return proxyCheckout(request, url); + }, +}); +``` + +By default `shouldProxyToVtex` matches `/checkout`, `/account`, `/api/`, `/files/`, `/arquivos/`, `/_v/`, `/no-cache/`, `/graphql/`, `/login`, `/logout`, `/assets/vtex`, `/_secure/account` and `/XMLData/`. Pass `{ excludePaths }` to keep paths of your own (such as a site `/api/` route) away from VTEX, or `{ extraPaths }` to add more. + +`createVtexCheckoutProxy` options: + +| Option | Default | What it does | +|---|---|---| +| `account` | — | Required. Your VTEX account. | +| `checkoutOrigin` | — | Required. Your checkout domain, such as `secure.store.example.com`. Checkout, account and `/files/` go here. | +| `apiOrigin` | `https://<account>.vtexcommercestable.<domain>` | Where API calls go. | +| `myvtexOrigin` | `https://<account>.myvtex.com` | Used to rewrite redirects. | +| `domain` | `com.br` | The VTEX domain suffix. | +| `expireCookiesOnPaths` | — | Extra cookies to expire on given path prefixes, for example on logout. | +| `htmlTransform` | — | Rewrite proxied HTML. | +| `hardenAuthenticatedCache` | `true` | Keep responses for logged-in visitors out of every cache. | + +The proxy rewrites the `Domain` of VTEX cookies and `Location` redirects to your host. `createVtexSitemapProxy` takes `extraSitemaps` (entries to add to the sitemap index, such as `/sitemap-busca.xml`), `environment` and `cacheControl`. + +On Next.js, there's no Worker entry; mount the proxies in your own route handlers or rewrites if you need them. + +## Logged-in visitors and caching + +On TanStack, when the app is installed through autoconfig, the Worker entry runs its middleware on every request. For anonymous visitors it leaves caching to the framework. For a logged-in visitor, or one with custom price tables, it marks the response `private, no-cache, no-store` and removes `CDN-Cache-Control`, so personalized pages are never cached. It also sets the Intelligent Search session cookies (`vtex_is_session`, `vtex_is_anonymous`) when they're missing. + +On outgoing calls the app forwards the visitor's `vtex_segment` cookie, so regional sellers and price tables apply, and adds the segment's region to Intelligent Search queries. [Caching](/v7/caching) covers the edge cache. + +The visitor's sales channel is decided in this order: the `VTEXSC` cookie (VTEX's sales-channel cookie), then an `?sc=` query parameter, then the `vtex_segment` cookie, then the default `"1"`. The legacy `useCart` hook also appends `sc` from the `VTEXSC` cookie to its browser calls; Cart v2 calls go through the server, which reads the cookie directly. + +## Cart + +There are two cart APIs. Use **Cart v2** for new code; the legacy hooks stay for sites migrated from the earlier framework. They keep separate state, so don't show a v1 badge next to a v2 drawer. + +### Cart v2 + +`createCart({ invoke })`, from `@decocms/apps-vtex/hooks/createCart`, returns a set of hooks bound to your invoke functions. Call it once, at module scope: + +```ts title="src/hooks/cart.ts" +import { createCart } from "@decocms/apps-vtex/hooks/createCart"; +import { invoke } from "~/server/invoke"; + +export const { + useCart, + useCartSummary, + useAddToCart, + useShipping, + useGifts, + useAttachments, + resetCart, +} = createCart({ invoke }); +``` + +```tsx title="src/components/AddToCartButton.tsx" +import { useAddToCart } from "~/hooks/cart"; + +export function AddToCartButton({ skuId, sellerId }: { skuId: string; sellerId: string }) { + const { add, loading } = useAddToCart(); + return ( + <button type="button" disabled={loading} onClick={() => add({ id: skuId, seller: sellerId })}> + Add to cart + </button> + ); +} +``` + +How it behaves: + +- **No cart until the first add.** Visitors who never add anything never create an order form, and the badge loader doesn't call VTEX when there's no cart cookie. +- **Optimistic updates.** `add` bumps the badge immediately, reconciles with the server's answer, and rolls back if the call fails. +- **Small payloads.** Adding to the cart returns `summary+items` computed from `SECTIONS_MINIMAL`. `useCart({ include: { full: true } })` loads the drawer's full `Minicart`. Pass `projection` and `sections` to `useAddToCart` to change what comes back (see [Cart v2](/v7/apps-commerce#cart-v2-sections-and-projection)). +- `useShipping` simulates shipping for a postal code; results are cached for five minutes per isolate. To share them across isolates, provide your own store with `setSimulationCache` from `@decocms/apps-vtex/utils/simulationCache`. + +Using an AI coding agent? The [`vtex-cart-v2` Agent Skill](https://github.com/decocms/blocks/tree/main/.agents/skills/vtex-cart-v2), a folder of instructions an agent loads, teaches it to wire Cart v2. Install it with the [`skills` CLI](https://www.npmjs.com/package/skills): `npx skills add decocms/blocks --skill vtex-cart-v2`. + +`createCartQuery({ invoke })`, from `@decocms/apps-vtex/hooks/cartQuery`, offers the same operations as TanStack Query hooks, for sites that already manage data that way. + +### What `invoke` must provide + +`invoke` is an object of server functions shaped like the VTEX keys: `invoke.vtex.actions.addItemsToCartV2(...)`, `invoke.vtex.loaders.cart.summary(...)` and so on. + +On TanStack, the [generate](/v7/generate) command writes the actions for you: it reads the VTEX app's invoke contract and emits `src/server/invoke.gen.ts`, with one top-level `createServerFn` per action and an `invoke` object containing `vtex.actions`. Keep that file in `src/`; don't move it into `.deco/`. The generator emits cart, session, newsletter and notify-me actions (`getOrCreateCartV2`, `addItemsToCartV2`, `updateCartItemsV2`, `addCouponToCartV2`, their v1 equivalents, `simulateCart`, `setShippingPostalCode`, `createSession`, `editSession`, `subscribe`, `notifyMe` and a few more). It runs only for TanStack sites. + +It emits actions only. Cart v2 also needs `vtex.loaders.cart.summary`, `full`, `shipping`, `gifts` and `attachments`, so add them in your own `src/server/invoke.ts`, next to the generated actions: + +```ts title="src/server/invoke.ts" +import { createServerFn } from "@tanstack/react-start"; +import { RequestContext } from "@decocms/blocks/sdk/requestContext"; +import { forwardResponseCookies } from "@decocms/tanstack/sdk/cookiePassthrough"; +import cartSummary from "@decocms/apps-vtex/loaders/cart/summary"; +import cartFull from "@decocms/apps-vtex/loaders/cart/full"; +import { vtexActions } from "./invoke.gen"; + +function forwardCookies() { + forwardResponseCookies(RequestContext.current?.responseHeaders.getSetCookie() ?? []); +} + +const summary = createServerFn({ method: "POST" }) + .inputValidator((data: { orderFormId?: string } | undefined) => data ?? {}) + .handler(async ({ data }) => { + const result = await cartSummary(data); + forwardCookies(); + return result; + }); + +const full = createServerFn({ method: "POST" }) + .inputValidator((data: Parameters<typeof cartFull>[0] | undefined) => data ?? {}) + .handler(async ({ data }) => { + const result = await cartFull(data); + forwardCookies(); + return result; + }); + +// …the same for shipping, gifts and attachments, from +// @decocms/apps-vtex/loaders/cart/{shipping,gifts,attachments} + +export const invoke = { + vtex: { + actions: vtexActions, + loaders: { cart: { summary, full /* , shipping, gifts, attachments */ } }, + }, +}; +``` + +Each server function must be a top-level `const`, because TanStack Start only compiles `createServerFn(...).handler(...)` calls at the top level of a module. + +On Next.js there's no generated file. When the app is installed through autoconfig, call the cart loaders and actions through [invoke](/v7/loaders) at `/deco/invoke/<key>`, such as `/deco/invoke/vtex/loaders/cart/summary`. + +### Legacy hooks + +Sites migrated from the earlier framework use invoke-based factories that hold their state in a signal-like `.value`: + +| Factory | Import | Needs on `invoke` | +|---|---|---| +| `createUseCart({ invoke })` | `@decocms/apps-vtex/hooks/createUseCart` | `vtex.actions` `getOrCreateCart`, `addItemsToCart`, `updateCartItems`, `addCouponToCart`, `updateOrderFormAttachment`, `simulateCart` | +| `createUseUser({ invoke })` | `@decocms/apps-vtex/hooks/createUseUser` | `vtex.loaders.user` | +| `createUseWishlist({ invoke })` | `@decocms/apps-vtex/hooks/createUseWishlist` | `vtex.loaders.wishlist`, `vtex.actions.addToWishlist`, `vtex.actions.removeFromWishlist` | + +The user and wishlist functions aren't generated; add them to your `invoke.ts` the same way as the cart loaders above. + +### TanStack Query hooks + +`useCart`, `useUser`, `useWishlist` and `useAutocomplete` (each at `@decocms/apps-vtex/hooks/<name>`) are TanStack Query hooks. They need `@tanstack/react-query` and a `QueryClientProvider` above them, which the router setup in the [quickstart](/v7/quickstart) provides. `useCart` and `useUser` call VTEX's `/api/checkout/...` and `/api/sessions` from the browser, so the checkout proxy above must be mounted. + +`useAutocomplete({ debounceMs, count, fetchSuggestions })` (defaults 250 ms and 4 products) returns `{ setSearch, query, suggestions, loading }`. Its default fetcher calls `/api/vtex/suggestions`, which nothing in the framework serves, so pass `fetchSuggestions`: a function of `(query, count)` that returns a `Suggestion`, for example a call through [invoke](/v7/loaders#call-loaders-and-actions-over-http) to the `vtex/loaders/intelligentSearch/suggestions.ts` loader. + +## Account pages + +`vtexAccountLoaders`, from `@decocms/apps-vtex/utils/accountLoaders`, returns [section loaders](/v7/loaders) for "My account" sections: `personalData`, `orders`, `cards`, `addresses`, `authentication` and `loggedIn`. Each reads the visitor's VTEX cookies from the request: + +```ts title="src/setup/section-loaders.ts" +import { registerSectionLoaders } from "@decocms/blocks/cms"; +import { vtexAccountLoaders } from "@decocms/apps-vtex/utils/accountLoaders"; + +registerSectionLoaders({ + "site/sections/Account/PersonalData.tsx": vtexAccountLoaders.personalData(), + "site/sections/Account/Orders.tsx": vtexAccountLoaders.orders(), + "site/sections/Account/Addresses.tsx": vtexAccountLoaders.addresses(), +}); +``` + +`personalData` accepts `extraProfileFields` and a `mapProfile` function to shape the result. + +## Sign-in + +VTEX ID actions are in `@decocms/apps-vtex/actions/auth`: + +- `startAuthentication` +- `classicSignIn` (email and password; starts authentication itself when you don't pass a token) +- `accessKeySignIn` (a code sent by email) +- `recoveryPassword` and `resetPassword` +- `refreshToken` +- `sendEmailVerification` +- `logout()`, which makes no request and returns the names of the cookies to clear + +They send VTEX the shopper's cookies and put VTEX's `Set-Cookie` headers on `RequestContext.responseHeaders`, but they aren't in `invoke.gen.ts`. Wrap the ones you need in your own server functions and forward the cookies with `forwardResponseCookies`, the same way as the cart server functions in [What `invoke` must provide](#what-invoke-must-provide) (see [Cookie passthrough](/v7/tanstack#cookie-passthrough)). + +`useUser` reads `/api/sessions` from the browser, because the sign-in cookie is HttpOnly, and caches the answer for 30 seconds under the query key `["vtex", "user"]`. After a sign-in, call its `refetch()` or invalidate that key. Many stores instead send shoppers to VTEX's own `/login`, which the checkout proxy serves unless `/login` is excluded. Sites made with `deco-migrate` exclude `/login` and `/logout` from the proxy, so check your `proxyHandler`. + +## Rules for actions + +<Callout type="warning"> + +**Write to the cart only through the app's actions.** Cart and session actions use a cookie-forwarding fetch that sends the shopper's cookies to VTEX and copies VTEX's `Set-Cookie` headers back to the browser, with the domain rewritten to your store. A custom cart write that uses the plain read helpers (`vtexFetch`, `vtexCachedFetch`) won't propagate those cookies, and the browser's cart drifts from VTEX's order form. If you need a custom checkout call, use `vtexFetchWithCookies`. + +**Keep Master Data on the server.** The generic Master Data functions (`createDocument`, `getDocument`, `patchDocument`, `searchDocuments`, `searchDocumentsFull`, `uploadAttachment`) run with your app credentials. Call them only from server code, and expose to the browser narrow actions of your own that fix the entity, validate the input and check who's asking. The invoke generator deliberately leaves them out of `invoke.gen.ts`. + +</Callout> + +## Environment variables + +| Variable | What it does | +|---|---| +| `VTEX_APP_KEY`, `VTEX_APP_TOKEN` | Fallback API credentials when the block doesn't hold them. | +| `VTEX_RESILIENCE_DISABLED` | Set to `true` to turn off retries, timeouts and the circuit breaker in `createVtexFetch`. | +| `DECO_CRYPTO_KEY` | Decrypts credentials stored encrypted in the block. See [Apps](/v7/apps#secrets). | + +## Related + +- [Commerce types and utilities](/v7/apps-commerce): the types these loaders return. +- [Loaders and actions](/v7/loaders): commerce loaders, section loaders and invoke. +- [Caching](/v7/caching): segments, cache profiles and the `X-Cache` headers. diff --git a/docs/content/v7/wake.mdx b/docs/content/v7/wake.mdx new file mode 100644 index 00000000..904d98d0 --- /dev/null +++ b/docs/content/v7/wake.mdx @@ -0,0 +1,151 @@ +--- +title: Wake +group: Apps +order: 30 +description: Connect a site to Wake Commerce for catalog pages, search, carts, wishlists and checkout routing. +--- + +# Wake + +`@decocms/apps-wake` connects a site to Wake Commerce through its Storefront GraphQL API and checkout REST API. It provides cached catalog loaders (product pages, listings, shelves, search suggestions, recommendations, shop info and partners), per-visitor loaders for the cart, user and wishlist, and actions for the cart, coupons, kits, wishlists, newsletters, reviews, notify-me and shipping simulation. Everything returns the shared [commerce types](/v7/apps-commerce). + +```bash +bun add @decocms/apps-wake @decocms/apps-commerce @decocms/apps-website +``` + +## Configuring + +Wake keeps its credentials out of content. The block holds only where to connect; the token always comes from the environment: + +| Block field | What it does | +|---|---| +| `account` | Required. Your Wake account name. | +| `checkoutUrl` | Your checkout and login domain, such as `https://secure.store.example.com`. Defaults to `https://<account>.checkout.fbits.store`. | +| `storefrontEndpoint` | Optional. The Storefront API endpoint; defaults to Wake's public one. | + +| Variable | What it does | +|---|---| +| `WAKE_TOKEN` | Required. The Storefront API token. | +| `WAKE_KEY` | The admin API token. Read, but not used by any loader or action yet. | + +The app reads both from `process.env` only, not from the Worker's per-request environment. On Cloudflare Workers, `process.env` is available only with the `nodejs_compat` compatibility flag, so check that `WAKE_TOKEN` is visible there: if it isn't, `configure` returns `null` and the app silently isn't installed. + +Install it with the registry entry; its block key is `deco-wake`. `configure` returns `null`, and the app isn't installed, when the block has no `account` or `WAKE_TOKEN` isn't set: + +```ts title="src/setup/apps.ts" +import { autoconfigApps, type AppRegistry } from "@decocms/blocks-admin/apps"; +import { loadBlocks } from "@decocms/blocks/cms"; +import { WAKE_REGISTRY_ENTRY } from "@decocms/apps-wake/registry"; +import * as wakeMod from "@decocms/apps-wake/mod"; + +const APP_REGISTRY: AppRegistry = [{ ...WAKE_REGISTRY_ENTRY, module: async () => wakeMod }]; + +await autoconfigApps(loadBlocks(), APP_REGISTRY); +``` + +Or configure it by hand: `initWakeFromBlocks(blocks)` reads the `wake` or `deco-wake` block, and `configureWake(config)` takes the config directly. Both are exported from the package root. + +## Instrumented fetch + +Call `setWakeFetch(createWakeFetch())` once, at module scope in your setup: + +```ts title="src/setup.ts" +import { setWakeFetch, createWakeFetch } from "@decocms/apps-wake"; + +setWakeFetch(createWakeFetch()); +``` + +Each call is then measured and traced, named after its GraphQL operation or checkout endpoint. Without it, calls use a plain fetch with a timeout and aren't measured. See [Observability](/v7/observability). + +## Cached catalog loaders + +`createWakeCommerceLoaders()` returns the catalog loaders wrapped in the framework's loader cache, ready for `registerCommerceLoaders`: + +```ts title="src/setup/commerce-loaders.ts" +import { registerCommerceLoaders } from "@decocms/blocks/cms"; +import { createWakeCommerceLoaders } from "@decocms/apps-wake/commerceLoaders"; + +registerCommerceLoaders(createWakeCommerceLoaders()); +``` + +| Key | Cache profile | Notes | +|---|---|---| +| `wake/loaders/productDetailsPage.ts` | `product` | Uses the page path when `slug` is empty. Reads `?skuId` to pick the variant. | +| `wake/loaders/productListingPage.ts` | `listing` | Uses the current page URL for paging, sorting and filters. | +| `wake/loaders/productList.ts` | `listing` | Shelves, with filters by category, brand, attributes, price and stock. | +| `wake/loaders/suggestion.ts` | `search` | Autocomplete. | +| `wake/loaders/recommendations.ts` | `product` | Recommendations for a product. | +| `wake/loaders/shop.ts`, `wake/loaders/partners.ts` | `static` | Shop info and partners. | + +Each key is also registered without `.ts`. Override profiles with `cacheProfiles` and add loaders with `extra`, as with [VTEX](/v7/vtex#commerce-loaders). The cart, user and wishlist loaders are deliberately left out: they're per visitor and never cached. + +The listing loader reads Wake's URL conventions: `busca` for the search term, `sort` or `ordenacao` for sorting (`SALES:DESC` by default), `page` for the page number, `tamanho` for the page size, and `filtro` and `precoPor` for filters. Its props include `limit` (12), `sort`, `query`, `onlyMainVariant` (`true`), `filters` and `pageOffset` (`0` or `1`, `0` by default). + +## Cart, user and wishlist + +Session state lives in Wake's cookies: + +| Cookie | Holds | +|---|---| +| `carrinho-id` | The cart id. Readable by browser scripts, so client code can reuse it. | +| `fbits-login` | The login, exchanged with Wake's checkout for a customer token. | +| `partner-token` | The active partner, when partner pricing applies. `HttpOnly`. | + +The cart, user and wishlist loaders and every action read these from the current request through [request context](/v7/request-context), and write `Set-Cookie` headers to the request's response headers, which [invoke](/v7/loaders) copies onto its response. That's why calling them through `/deco/invoke` needs no extra wiring. The `cart` loader creates a cart when the visitor has none; cart actions fail with `Missing cart cookie` (HTTP 400) until one exists. + +The actions, at `@decocms/apps-wake/actions/...` and as `wake/actions/...` invoke keys: + +| Action | Props | +|---|---| +| `cart/addItem`, `cart/addItems` | `productVariantId`, `quantity`, optional `customization` and `subscription` (a list under `products` for `addItems`) | +| `cart/updateItemQuantity` | `productVariantId`, `quantity` | +| `cart/addCoupon`, `cart/removeCoupon` | `coupon` | +| `cart/addKit`, `cart/removeKit` | `products`, `quantity`, `kitId` | +| `cart/partnerAssociate`, `cart/partnerDisassociate` | `partnerAccessToken` | +| `wishlist/addProduct`, `wishlist/removeProduct` | `productId` | +| `newsletter/register` | `email`, `name` | +| `review/create` | `email`, `name`, `productVariantId`, `rating`, `review` | +| `notifyme` | `email`, `name`, `productVariantId` | +| `shippingSimulation` | `cep`, plus either `productVariantId` and `quantity` for one product, or `simulateCartItems` to quote the visitor's cart; `useSelectedAddress` uses the address already on the cart | +| `submmitForm` | `body`, `recaptchaToken` (the module name really is spelled this way) | + +The package doesn't ship React hooks. Call these through your own server functions or `/deco/invoke`. + +## Checkout routes and the sitemap + +Wake's checkout, login, cart and account pages are served by Wake. To keep them on your domain, your Worker forwards those paths to the checkout URL. + +`@decocms/apps-wake/loaders/proxy` returns the list of routes to forward, as plain descriptors: `/checkout`, `/Fechamento` and `/Fechamento/*`, `/Login`, `/Login/*`, `/login/*` and `/Login/Authenticate`, `/Carrinho/*`, `/api/*`, `/MinhaConta` and `/MinhaConta/*`, any `extraPathsToProxy` you pass, and a `sitemap` route for `/Sitemap.xml`. Each proxy descriptor carries the target in `url` (your checkout URL). It doesn't proxy anything itself: your Worker's `proxyHandler` does the forwarding. + +`@decocms/apps-wake/handlers/sitemap` serves Wake's sitemap for your account with your host in every URL; pass `include` to add entries to it. + +```ts title="src/worker-entry.ts (excerpt)" +import proxyRoutes from "@decocms/apps-wake/loaders/proxy"; +import Sitemap from "@decocms/apps-wake/handlers/sitemap"; + +function matches(template: string, pathname: string) { + return template.endsWith("/*") + ? pathname.startsWith(template.slice(0, -1)) + : pathname === template; +} + +export default createDecoWorkerEntry(serverEntry, { + proxyHandler: async (request, url) => { + if (url.pathname === "/Sitemap.xml") return Sitemap()(request); + for (const route of proxyRoutes({})) { + if (route.type === "proxy" && matches(route.pathTemplate, url.pathname)) { + return fetch(new Request(new URL(url.pathname + url.search, route.url), request)); + } + } + return null; + }, +}); +``` + +<Callout type="warning">This sketch forwards requests as they are. A production proxy also has to rewrite the `Domain` of the checkout's `Set-Cookie` headers and its `Location` redirects to your host, the way [VTEX's checkout proxy](/v7/vtex#wiring-the-worker) does.</Callout> + +## Related + +- [Apps](/v7/apps): installing apps. +- [Commerce types and utilities](/v7/apps-commerce): the shapes these loaders return. +- [Caching](/v7/caching): cache profiles. diff --git a/docs/content/v7/walkthrough.mdx b/docs/content/v7/walkthrough.mdx new file mode 100644 index 00000000..fd06bb36 --- /dev/null +++ b/docs/content/v7/walkthrough.mdx @@ -0,0 +1,123 @@ +--- +title: How resolution works +group: Under the hood +kind: internals +order: 2 +description: What the runtime does to turn a page block's JSON into sections ready to render, in order, with a worked example. +--- + +# How resolution works + +**Resolution** turns a page's raw content into the props its sections render with: it follows block names, picks variants and calls loaders, wherever a `__resolveType` appears. This page walks through it in the order the runtime does it, first for a whole page and then for a single value, and finishes with a worked example. It describes `resolveDecoPage` and the resolver behind it, both in `@decocms/blocks/cms`. + +## From URL to sections + +<Flow label="Resolving a page"> + <FlowNode title="Find the page">The first page block whose `path` pattern matches the URL</FlowNode> + <FlowNode title="Split the sections">Each top-level section is either deferred or resolved now</FlowNode> + <FlowNode title="Resolve eager sections">All at once, in parallel; layout sections from their cache</FlowNode> + <FlowNode title="Resolve the SEO block">Always now, never deferred</FlowNode> + <FlowNode title="Section loaders">The binding enriches each section's props</FlowNode> +</Flow> + +1. **Find the page.** The runtime matches the path against every page block's `path` and takes the first match, along with its route parameters (`:slug` and the like). No match means no page, and the binding answers 404. [Pages and routing](/v7/routing) has the matching rules. +2. **Build the matcher context.** The request's URL, path, user agent, cookies and headers, which every matcher in the page will see. See [Matchers and variants](/v7/variants). +3. **Get the section list.** A page's `sections` is usually an array. It can also be a variant or a named block that produces an array; that outer value is resolved first, without resolving the sections inside it yet. +4. **Split the sections.** For each top-level section the runtime decides whether to defer it, using the rules in [Deferred sections](/v7/rendering#which-sections-are-deferred). A deferred section is followed through names, async wrappers and variants just far enough to know which section component it is, without calling any loader. What's left is recorded as a deferred section, and its raw props stay on the server for the browser's later request. +5. **Resolve the eager sections.** Each eager section is resolved fully (the next section of this page), all of them concurrently. A [layout section](/v7/sections#layout-sections) is served from a 5-minute cache keyed by section and device class, and concurrent requests share one resolution. Each result keeps its position on the page, so eager and deferred sections can be merged back in order. +6. **Resolve the SEO block.** The page's `seo` field is resolved last, eagerly, so it can reuse named blocks the sections already resolved. Async wrappers are ignored for it. See [SEO](/v7/seo). + +`resolveDecoPage` returns the page's name, path and route parameters, the resolved sections, the deferred sections and the SEO block. The binding then runs section loaders on the result (TanStack Start's route loader does; see [Loaders and actions](/v7/loaders)) and renders. + +## Resolving a value + +Inside a section, resolution is recursive. Given any JSON value, the resolver checks these cases in order and stops at the first that applies: + +1. **Not an object** (a string, number, boolean or `null`): returned as is. +2. **An array:** each item is resolved, in parallel. Items that resolve to "not present" (a variant with no match) are dropped, so a hidden item leaves no hole. +3. **An object without `__resolveType`:** each property is resolved, in parallel. +4. **A type the runtime skips:** a few legacy types handled elsewhere (SEO sections from the website and commerce apps, the analytics section, the commerce proxies, the redirect and page-list loaders) resolve to `null`. Your own types can be added with `addSkipResolveType`. +5. **Too deep:** past 20 levels of nesting, the value resolves to `null` and an error is logged. This stops a block that refers to itself from looping. +6. **Already resolved:** `{ "__resolveType": "resolved", "data": … }` returns `data` untouched. This is what `asResolved(value)` produces. With `deferred: true`, it returns a function that resolves `data` only when called; see [Defer a single prop](/v7/rendering#defer-a-single-prop). +7. **An async wrapper** (`website/sections/Rendering/Lazy.tsx` or `Deferred.tsx`, what Studio adds for ⚡): unwrapped and its section resolved. Whether the section is deferred was already decided at the page level; nested wrappers just resolve. +8. **A route parameter** (`website/functions/requestToParam.ts`): the named parameter from the page's `path`, such as the product slug. +9. **A commerce extension wrapper:** unwrapped to its `data`. +10. **A variant** (`website/flags/multivariate.ts` or `website/flags/multivariate/section.ts`): the rules are evaluated in order and the first matching variant's `value` is resolved. The others are never touched, so their loaders don't run. No match resolves to "not present". +11. **A commerce loader** (a key registered with `registerCommerceLoaders`): its props are resolved first. Then the page's path and URL are added as `__pagePath` and `__pageUrl`, with tracking parameters removed, and the URL's query parameters are copied into props that the content didn't set (except `page`). The loader is called and its result is the value. If it throws, `onResolveError` is called, the value is `null`, and the page is marked degraded. +12. **A named block** (a `__resolveType` that names a block in the decofile): the block's JSON is merged with the reference's other fields, which override it, and the result is resolved. Results are memoized for the rest of this page's resolution, keyed by the reference including its overrides. +13. **An unregistered loader or action** (a name containing `/loaders/` or `/actions/` that matched nothing above): passed to `onDanglingReference`, which by default logs a warning and returns `null`. +14. **Anything else is a section.** If the section exports `onBeforeResolveProps`, it first receives the raw props. Then every prop is resolved and the value keeps its `__resolveType`, which names the component. + +Studio previews add one more case: `{ "__resolveType": "preview", "block": "<name>" }` resolves the named block, so Studio can preview a saved block with edited props. + +After resolution, sections nested in props are turned into `{ Component, props }` objects, the shape [`RenderSection`](/v7/model) renders, and a top-level section whose component isn't registered is skipped with a warning. + +## A worked example + +Here's a page and the blocks it refers to: + +```json title=".deco/blocks (excerpt)" +{ + "pages-summer-sale": { + "__resolveType": "website/pages/Page.tsx", + "name": "Summer sale", + "path": "/summer-sale", + "sections": [ + { "__resolveType": "Header" }, + { + "__resolveType": "website/flags/multivariate.ts", + "variants": [ + { + "rule": { "__resolveType": "website/matchers/device.ts", "mobile": true }, + "value": { "__resolveType": "site/sections/Hero.tsx", "title": "Tap to shop the sale" } + }, + { + "rule": { "__resolveType": "website/matchers/always.ts" }, + "value": { "__resolveType": "site/sections/Hero.tsx", "title": "Summer sale" } + } + ] + }, + { "__resolveType": "Summer shelf", "title": "Picked for summer" }, + { + "__resolveType": "website/sections/Rendering/Lazy.tsx", + "section": { "__resolveType": "Footer" } + } + ] + }, + "Header": { "__resolveType": "site/sections/Header/Header.tsx", "links": [] }, + "Footer": { "__resolveType": "site/sections/Footer/Footer.tsx" }, + "Summer shelf": { + "__resolveType": "site/sections/ProductShelf.tsx", + "title": "Summer", + "products": { + "__resolveType": "vtex/loaders/intelligentSearch/productList.ts", + "props": { "query": "summer", "count": 12 } + } + } +} +``` + +A phone requests `/summer-sale`, on a site where `Header.tsx` exports `layout = true`: + +1. `pages-summer-sale` matches the path. It has four top-level sections. +2. **Splitting.** The fourth section is wrapped in `Lazy.tsx`, so an editor marked it ⚡: it's deferred. The runtime follows the wrapper and the `Footer` name to learn it's `site/sections/Footer/Footer.tsx`, and records it as deferred at position 3. The other three are eager. +3. **Section 0, `Header`.** It's a named block whose component is a layout section, so the runtime looks in the layout cache under the Header key and `mobile`. On a miss, it resolves the block (case 12, then case 14) and caches the result for 5 minutes. +4. **Section 1, the variant** (case 10). The device rule matches a phone, so the first `value` is resolved (case 14): `site/sections/Hero.tsx` with the title "Tap to shop the sale". The desktop Hero is never resolved. +5. **Section 2, `Summer shelf`** (case 12). The block's JSON is merged with the reference's `title`, which overrides it: the shelf's title is "Picked for summer". Resolving the merged object reaches `products` (case 11): the VTEX loader's props are resolved, `__pagePath` and `__pageUrl` are added, and the loader runs. `products` becomes its list of products. +6. **SEO.** The page has no `seo` field, so there's no SEO block. +7. The result has three resolved sections (positions 0, 1 and 2) and one deferred section (position 3). The binding runs section loaders on the three, renders them, and renders the Footer's skeleton. When the visitor scrolls near the bottom, the browser requests the Footer, and the server resolves it from the raw props it kept. + +On a desktop request, step 4 picks the second Hero, and the Header comes from its `desktop` cache entry. + +## Errors and limits + +- **A failing commerce loader** doesn't fail the page. Its value becomes `null`, `onResolveError` (an option of `createSiteSetup`) is called, and the page is marked degraded. On TanStack Start the edge cache doesn't store a degraded page; see [Caching](/v7/caching#degraded-pages). +- **A section that throws while resolving** resolves to nothing, and the rest of the page renders. +- **Memoization is per page resolution.** A named block referenced twice with the same overrides is resolved once per request, not across requests. Commerce loaders aren't deduplicated by it; their own cache does that (see [Caching](/v7/caching#caching-loaders)). +- **References that go nowhere:** an unregistered loader or action is handled by `onDanglingReference`. Any other unknown name is treated as a section, and a section without a registered component is skipped with a warning, so a typo in a block name shows up as a missing section rather than an error. + +## Next steps + +- [Content and the decofile](/v7/content): the content model this page resolves. +- [Deferred sections](/v7/rendering): the deferral rules used in step 4. +- [The Worker request pipeline](/v7/request-pipeline): what happens to the request before and after resolution on TanStack Start. diff --git a/docs/data/roadmap.json b/docs/data/roadmap.json new file mode 100644 index 00000000..d3f429dd --- /dev/null +++ b/docs/data/roadmap.json @@ -0,0 +1,4734 @@ +{ + "statuses": [ + { + "id": "to-build", + "label": "To build", + "word": "to build", + "word_one": "to build", + "tile": "Nothing in the proposed API yet", + "legend": "nothing in the proposed API yet; needs a framework change" + }, + { + "id": "to-finish", + "label": "To finish", + "word": "to finish", + "word_one": "to finish", + "tile": "The core is there; pieces are missing", + "legend": "the core is there; material pieces are missing or undocumented" + }, + { + "id": "site-code", + "label": "Site code", + "word": "site code", + "word_one": "site code", + "tile": "Left to a few lines of site code", + "legend": "deliberately left to the site; a few lines of app code" + }, + { + "id": "done", + "label": "Done", + "word": "done", + "word_one": "done", + "tile": "Works as documented", + "legend": "works as documented" + }, + { + "id": "goes-away", + "label": "Goes away", + "word": "go away", + "word_one": "goes away", + "tile": "Disappears with the migration", + "legend": "disappears with the migration" + } + ], + "sections": [ + { + "id": "roadmap", + "nav": "Overview", + "eyebrow": "Next-major roadmap", + "title": "Roadmap to the next major" + }, + { + "id": "roadmap-blockers", + "nav": "The ten blockers", + "eyebrow": "Release blockers", + "title": "Ten blockers to clear before release" + }, + { + "id": "roadmap-studio-new", + "nav": "On a next-major site", + "eyebrow": "Site editor support", + "title": "Site editor on a next-major site" + }, + { + "id": "roadmap-studio-legacy", + "nav": "With legacy content", + "eyebrow": "Site editor support", + "title": "Site editor with legacy content" + }, + { + "id": "roadmap-api", + "nav": "API additions", + "eyebrow": "Work items", + "title": "API additions" + }, + { + "id": "roadmap-cli", + "nav": "CLI and manifest", + "eyebrow": "Work items", + "title": "CLI and manifest" + }, + { + "id": "roadmap-platform", + "nav": "Site editor and Deco API", + "eyebrow": "Work items", + "title": "Site editor and Deco API" + }, + { + "id": "roadmap-docs", + "nav": "Docs fixes", + "eyebrow": "Work items", + "title": "Docs fixes and additions" + }, + { + "id": "roadmap-later", + "nav": "Follow-ups", + "eyebrow": "After the first release", + "title": "Planned after the first release" + }, + { + "id": "roadmap-storefront", + "nav": "TanStack storefront", + "eyebrow": "Site migrations", + "title": "Migrate the TanStack storefront" + }, + { + "id": "roadmap-blog", + "nav": "TanStack blog", + "eyebrow": "Site migrations", + "title": "Migrate the TanStack blog" + }, + { + "id": "roadmap-faststore", + "nav": "Next.js storefront", + "eyebrow": "Site migrations", + "title": "Migrate the Next.js storefront" + }, + { + "id": "roadmap-features", + "nav": "Every feature", + "eyebrow": "Feature readiness", + "title": "Every feature, and where it stands" + } + ], + "overview": { + "intro": "<p>Deco CMS is the next major version of Deco's framework, which ships today as the <code>@decocms</code> 7.x packages. The API in these docs is proposed and hasn't shipped yet; this page is the to-do list for releasing it (a release of the framework, not to be confused with content releases). To build it, we checked the proposed API feature by feature against three real sites that will move to it: a <a href=\"#roadmap-storefront\">TanStack storefront</a> on the current packages, a <a href=\"#roadmap-blog\">TanStack blog</a> on an older package, and a <a href=\"#roadmap-faststore\">Next.js storefront</a> on another CMS.</p>\n<p>No site can move using only what these docs describe. Each first needs something missing: site editor support, a way for blocks to read the current request, or SDK functions beyond the CMS. The <a href=\"#roadmap-blockers\">ten release blockers</a> gate the release; the status of every feature the three sites use is under <a href=\"#roadmap--release-readiness\">Release readiness</a>.</p>", + "readiness_lead": "None of the 111 features those sites use is done yet: 11 are still to build, 66 are to finish, 28 are left to site code, and 6 go away with the migration.", + "fix_docs": "<a class=\"rm-do-t\" href=\"#roadmap-docs\">Fix the docs</a> A few statements in these docs are still loose or wrong. The fixes are the first two docs work items: {{item:roadmap-docs--resolve-contradictions-in-these-docs}} and {{item:roadmap-docs--correct-studio-compatibility}}." + }, + "blockers": [ + { + "id": "roadmap-blockers--let-studio-talk-to-a-next-major-site", + "title": "Let the site editor talk to a next-major site", + "today": "The site editor reads the schema and content from the running site (<code>/live/_meta</code>, <code>/.decofile</code>) and calls it for previews, <code>/deco/invoke</code> and the secrets encrypt action. A next-major site serves none of these.", + "plan": "The site editor talks to stored content through the content protocol instead, which needs only the committed schema and <code>.deco/blocks</code>: the site editor's GitHub backend in production, <code>deco serve</code> on a developer's machine. Features that need the site's code degrade in this version (see <a href=\"#site-editor--what-works-without-your-code\">What works without your code</a>).", + "features": [ + "admin-protocol-endpoints", + "invoke-and-actions", + "section-catalog", + "global-layout-sections", + "dev-studio-content-loop", + "site-bootstrap" + ], + "delivered_by": [ + "roadmap-api--publish-the-content-protocol-and-its-package", + "roadmap-platform--build-studio-s-github-content-backend", + "roadmap-platform--move-studio-s-editor-onto-the-protocol-client", + "roadmap-cli--ship-deco-serve-a-local-server-for-studio", + "roadmap-docs--correct-studio-compatibility", + "roadmap-platform--synchronize-editor-drafts" + ] + }, + { + "id": "roadmap-blockers--make-the-cli-write-a-schema-studio-can-read", + "title": "Make the CLI write a schema the site editor can read", + "today": "The CLI writes <code>.deco/schema.gen.json</code> with top-level <code>definitions</code>; the site editor reads <code>.deco/meta.gen.json</code> in LiveMeta shape (btoa keys, <code>schema.root</code>). There are no <code>apps</code> or <code>actions</code> groups, no per-type variant definitions, and the widget vocabulary is undocumented, so 22 image pickers would become text inputs and HTML fields plain strings.", + "plan": "Emit the site editor's exact format as <code>.deco/schema.gen.json</code>, one multivariate definition per field type a variant can fill, write static option lists into the schema, publish the JSDoc/@format table, and keep widget-alias detection and return-type loader unions.", + "features": [ + "studio-schema-generation", + "codegen-pipeline", + "ci-and-release-workflows", + "editor-widgets-and-image-fields", + "rich-text-html-props", + "app-domain-types", + "inline-loader-props" + ], + "delivered_by": [ + "roadmap-cli--write-a-studio-compatible-schema-file", + "roadmap-cli--publish-the-tag-and-widget-vocabulary", + "roadmap-cli--define-the-loader-picker-rule", + "roadmap-cli--write-static-option-lists-into-the-schema" + ] + }, + { + "id": "roadmap-blockers--give-blocks-the-page-url-and-route-params", + "title": "Give blocks the page URL and route params", + "today": "On SPA navigation <code>getRequest()</code> returns the <code>/_serverFn</code> URL, Next's <code>headers()</code> has no URL, app packages' loaders have no standard way to receive the URL or <code>match.params</code>, and apps-vtex's RequestContext is never populated.", + "plan": "Add one request scope (<code>url</code>, <code>params</code>, <code>request</code>, <code>client</code>, response headers) that the templates' openPage populates and that block functions and site code can read, passing what a client needs as arguments.", + "features": [ + "section-loaders", + "plp-filters-sort-pagination", + "request-context-cookies", + "commerce-platform-binding", + "code-composed-pdp" + ], + "delivered_by": [ + "roadmap-api--add-a-request-scope" + ] + }, + { + "id": "roadmap-blockers--make-draft-preview-work-inside-studio-s-iframe", + "title": "Make draft preview work inside the site editor's iframe", + "today": "The SameSite=Lax cookie is rejected cross-site, the TanStack guide never sets it, and <code>?__draft=off</code> is ignored. There's no no-store rule, and no StudioBridge or <code>data-manifest-key</code>, so click-to-select is lost.", + "plan": "Give the templates <code>withDeco()</code>/<code>decoProxy()</code> recipes, which set the cookie as <code>Secure; SameSite=None; Partitioned</code>, treat <code>?__draft=off</code> as exit and mark drafts private/no-store. Add <code><StudioBridge/></code>, and have the templates wrap each rendered block in the site editor's <code>section[data-manifest-key]</code> marker.", + "features": [ + "studio-preview", + "worker-server-entry", + "root-document-layout", + "router-and-client-navigation" + ], + "delivered_by": [ + "roadmap-api--ship-the-withdeco-and-decoproxy-entry-wrappers", + "roadmap-api--add-a-studio-bridge" + ] + }, + { + "id": "roadmap-blockers--define-the-non-cms-sdk-surface", + "title": "Define the non-CMS SDK surface", + "today": "apps-* and the ClickHouse telemetry depend on <code>sdk/requestContext</code>, <code>instrumentedFetch</code>, <code>fetchCache</code> and <code>cachedLoader</code>, and sites' widgets on <code>sdk/useDevice</code> and the Image and JsonLd hooks. These docs only list 'loaders, client, resolution, router'.", + "plan": "The framework doesn't fetch data: the apps become thin, instrumented API clients over the framework's instrumented fetch, the core drops <code>/deco/invoke</code> and <code>cachedLoader</code>, and caching moves to template recipes. Ship <code>@decocms/blocks/image</code>.", + "features": [ + "framework-import-surface", + "in-isolate-caches", + "upstream-fetch-instrumentation", + "image-optimization", + "json-ld-structured-data", + "interactive-ui-widgets" + ], + "delivered_by": [ + "roadmap-api--turn-the-apps-into-thin-instrumented-clients", + "roadmap-api--remove-deco-invoke-and-cachedloader", + "roadmap-api--cache-upstream-requests-in-the-bindings", + "roadmap-api--add-an-image-module" + ] + }, + { + "id": "roadmap-blockers--add-a-revision-handle-an-edge-cache-kit-and-an-isr-recipe", + "title": "Add a revision handle, an edge cache recipe and an ISR recipe", + "today": "Clients expose no revision and there's no <code>forRevision</code>. Degraded pages get cached. The planned check for a new release runs once a minute, at an idle moment (inside <code>ctx.waitUntil</code> on Workers), so propagation depends on the check interval and manifest cache lifetime; a cold isolate serves the content module it was deployed with until its first check.", + "plan": "Add a revision handle, a way to read and pin the revision a client serves (<code>client.revision()</code>, <code>cms.forRevision()</code>, <code>onUpdate</code>), and <code>markDegraded()</code>. Add an edge cache recipe for Workers templates, <code>withEdgeCache()</code>, for caching rendered pages at the edge, and an ISR (Incremental Static Regeneration: re-rendering cached pages in the background) recipe for Next.js.", + "features": [ + "edge-html-cache-profiles", + "fast-deploy-kv", + "lazy-deferred-sections", + "otel-observability" + ], + "delivered_by": [ + "roadmap-api--add-a-revision-handle", + "roadmap-api--add-an-edge-cache-kit", + "roadmap-api--add-remoteloader-options", + "roadmap-docs--fix-the-next-guide" + ] + }, + { + "id": "roadmap-blockers--stop-resolving-everything-eagerly", + "title": "Stop resolving everything eagerly", + "today": "The one registry rule resolves inputs bottom-up. Lazy defers nothing (43 wrappers on the storefront), hidden variants still run their loaders, and <code>Resolved<T></code> can't be expressed.", + "plan": "Add the built-in <code>lazy</code> block, so multivariate runs only the chosen variant. The alias bridge unwraps the legacy Lazy/SingleDeferred/Deferred block wrappers: the wrapped block renders normally, with no client-side deferral.", + "features": [ + "lazy-deferred-sections", + "matchers-and-variants", + "site-search-and-autocomplete" + ], + "delivered_by": [ + "roadmap-api--add-the-lazy-block", + "roadmap-cli--widen-the-legacy-alias-bridge" + ] + }, + { + "id": "roadmap-blockers--make-the-next-major-load-and-resolve-legacy-content", + "title": "Make the next major load and resolve legacy content", + "today": "Filename decoding is unspecified, and the alias bridge covers only pages, matchers and multivariate, while the site editor writes Lazy, SeoV2, <code>site/apps/site.ts</code>, redirect, secret and the <code>multi</code> matcher. UNKNOWN_BLOCK now fails the parent block.", + "plan": "Use the protocol's one filename rule (decode exactly once), widen the alias bridge, ship <code>deco content</code> (with <code>deco schema</code> reporting collisions), and have the CLI emit a runtime alias table.", + "features": [ + "block-type-discriminator", + "orphan-and-dangling-blocks", + "content-schema-drift", + "content-storage-and-delivery" + ], + "delivered_by": [ + "roadmap-cli--specify-the-filename-rule-and-ship-a-content-bundle", + "roadmap-cli--widen-the-legacy-alias-bridge", + "roadmap-cli--add-validation-commands-to-the-cli", + "roadmap-cli--emit-a-runtime-alias-table" + ] + }, + { + "id": "roadmap-blockers--make-routing-accept-the-content-sites-already-store", + "title": "Make routing accept the content sites already store", + "today": "There's no splat, so <code>/*</code> PLPs never match. <code>matchRoute</code> throws per request on an ambiguity a single site editor commit can introduce. <code>Page.seo</code> is required but content stores <code>null</code>, and with no 404 gate unknown URLs return soft 200s.", + "plan": "Add a trailing splat and make <code>matchRoute</code> non-throwing, with a deterministic tie-break. A hosted publish isn't validated: the tie-break keeps the site up and <code>deco check</code> catches ambiguous routes in CI. Make seo optional and add a critical-block notFound/redirect sentinel.", + "features": [ + "cms-page-routing", + "not-found-and-error-pages", + "redirects", + "page-template-targeting" + ], + "delivered_by": [ + "roadmap-api--add-a-splat-to-matchroute-and-stop-request-time-throws", + "roadmap-api--add-a-not-found-and-redirect-gate-before-streaming", + "roadmap-platform--build-the-deco-api-release-service", + "roadmap-docs--resolve-contradictions-in-these-docs" + ] + }, + { + "id": "roadmap-blockers--define-a-model-for-apps-config-and-secrets", + "title": "Define a model for apps, config and secrets", + "today": "App loaders get no config, there's no <code>apps</code> manifest group, <code>website/loaders/secret.ts</code> can't decrypt, and the site editor encrypts Secret fields through an action the site serves.", + "plan": "Define the client contract (a factory that takes its config from site code), and vendored loaders keep their old type names as aliases. No apps group in this version. Ship a built-in <code>secret</code> block: content holds only ciphertext, encrypted with a public key committed in <code>.deco/</code> and decrypted on the server, and the site editor gets a write-only <code>Secret</code> field.", + "features": [ + "app-installation-and-store-config", + "env-vars-and-secrets", + "commerce-platform-binding" + ], + "delivered_by": [ + "roadmap-api--define-an-app-contract", + "roadmap-api--ship-decocms-blocks-secrets", + "roadmap-platform--encrypt-secret-fields-in-the-site-editor", + "roadmap-cli--write-a-studio-compatible-schema-file" + ] + } + ], + "studio_new": [ + { + "id": "roadmap-studio-new--give-studio-a-schema-it-can-read-so-the-editor-loads", + "title": "Give the site editor a schema it can read, so the editor loads", + "today": "The site editor reads <code>.deco/meta.gen.json</code> or <code>/live/_meta</code> in LiveMeta shape, so a next-major site shows 'live meta unavailable'. On protocol projects it reads the committed <code>.deco/schema.gen.json</code>, which must be in that shape.", + "features": [ + "studio-schema-generation", + "ci-and-release-workflows", + "codegen-pipeline" + ], + "delivered_by": [ + "roadmap-cli--write-a-studio-compatible-schema-file", + "roadmap-platform--build-studio-s-github-content-backend" + ] + }, + { + "id": "roadmap-studio-new--keep-gallery-theme-and-in-place-previews-working", + "title": "Replace gallery, theme and in-place previews", + "today": "The site editor's section thumbnails (its legacy name for block previews), global and theme previews, and Fast Preview in-place render all go through <code>/live/previews</code>, which runs site code. The protocol never does: these become name cards, or open the real page.", + "features": [ + "section-catalog", + "theming-and-styling", + "global-layout-sections" + ], + "delivered_by": [ + "roadmap-platform--move-studio-s-editor-onto-the-protocol-client", + "roadmap-platform--fix-the-add-section-gate", + "roadmap-platform--align-the-preview-protocol" + ] + }, + { + "id": "roadmap-studio-new--let-editors-save-secret-fields-on-next-major-sites", + "title": "Let editors set Secret fields on next-major sites", + "today": "The site editor encrypts Secret fields through an encrypt action the site serves, with a symmetric key (DECO_CRYPTO_KEY). Protocol projects have no such action, so nothing can encrypt a new secret.", + "features": [ + "env-vars-and-secrets", + "app-installation-and-store-config", + "invoke-and-actions" + ], + "delivered_by": [ + "roadmap-api--ship-decocms-blocks-secrets", + "roadmap-platform--encrypt-secret-fields-in-the-site-editor", + "roadmap-platform--move-studio-s-editor-onto-the-protocol-client" + ] + }, + { + "id": "roadmap-studio-new--keep-options-run-and-pickers-working", + "title": "Fall back for <code>@options</code>, Run and pickers", + "today": "These need <code>/deco/invoke</code> and <code>/live/invoke</code>, which the protocol replaces. The blog ProductShelf picker even calls production's <code>/deco/invoke</code> with legacy VTEX keys. Pickers fall back to the schema's options, else free text; Run is hidden.", + "features": [ + "invoke-and-actions", + "app-installation-and-store-config", + "icon-by-name-enums" + ], + "delivered_by": [ + "roadmap-cli--write-static-option-lists-into-the-schema", + "roadmap-platform--move-studio-s-editor-onto-the-protocol-client" + ] + }, + { + "id": "roadmap-studio-new--restore-click-to-select", + "title": "Restore click-to-select", + "today": "There's no <code>editor::inject</code> listener, and the guides' renderers emit no site editor <code>section[data-manifest-key]</code> marker.", + "features": [ + "root-document-layout", + "interactive-ui-widgets" + ], + "delivered_by": [ + "roadmap-api--add-a-studio-bridge" + ] + }, + { + "id": "roadmap-studio-new--keep-the-draft-past-the-first-in-frame-click", + "title": "Keep the draft past the first in-frame click", + "today": "The Lax cookie isn't stored cross-site and the TanStack guide never sets it. <code>?__draft=off</code> can leave a stale draft behind.", + "features": [ + "studio-preview", + "worker-server-entry" + ], + "delivered_by": [ + "roadmap-api--ship-the-withdeco-and-decoproxy-entry-wrappers", + "roadmap-docs--fix-the-tanstack-guides", + "roadmap-platform--align-the-preview-protocol" + ] + }, + { + "id": "roadmap-studio-new--give-field-level-variants-a-schema-contract", + "title": "Give field-level variants a schema contract", + "today": "The site editor's field variant UI needs a single-option block-ref whose definition has <code>variants</code>, and takes the inner field from <code>variants.items.properties.value</code>, else a plain string (schema-form.tsx:76-87, 254-280). The proposed API's one generic <code>multivariate<T></code> gives the CLI no concrete T, so it has to take T from the field the variant fills. Legacy schemas had one flag per kind, e.g. <code>website/flags/multivariate/image.ts</code>.", + "features": [ + "matchers-and-variants", + "editor-widgets-and-image-fields", + "studio-schema-generation" + ], + "delivered_by": [ + "roadmap-cli--write-a-studio-compatible-schema-file", + "roadmap-docs--fill-in-the-schema-reference-details" + ] + }, + { + "id": "roadmap-studio-new--keep-variant-tabs-and-the-device-toggle-working", + "title": "Keep variant tabs and the device toggle working", + "today": "<code>x-deco-matchers-override</code> and <code>?deviceHint</code> have no reader in the built-in multivariate.", + "features": [ + "device-detection-and-targeting", + "matchers-and-variants" + ], + "delivered_by": [ + "roadmap-api--add-a-request-scope", + "roadmap-platform--align-the-preview-protocol" + ] + }, + { + "id": "roadmap-studio-new--stop-studio-reading-short-type-names-as-saved-entries", + "title": "Stop the site editor reading short type names as saved entries", + "today": "The site editor classifies keys by file extension, so a site's UI blocks must keep legacy <code>site/sections/X.tsx</code> keys until it does a manifest lookup.", + "features": [ + "block-type-discriminator", + "section-registration-conventions" + ], + "delivered_by": [ + "roadmap-platform--use-manifest-lookup-instead-of-path-heuristics", + "roadmap-cli--emit-a-runtime-alias-table" + ] + }, + { + "id": "roadmap-studio-new--prevent-entry-names-from-colliding-with-types", + "title": "Prevent entry names from colliding with types", + "today": "The site editor's only write guard rejects keys with a source-file extension (block-key.ts:48-50). An editor can save an entry named <code>hero</code>, <code>seo</code> or <code>page</code>; under the proposed API's <code>{ ...savedEntries, ...blocks }</code> the function wins and the entry is silently ignored. Only <code>deco schema</code> in CI catches it, after the commit is on the production branch.", + "features": [ + "block-type-discriminator", + "orphan-and-dangling-blocks" + ], + "delivered_by": [ + "roadmap-platform--use-manifest-lookup-instead-of-path-heuristics" + ] + }, + { + "id": "roadmap-studio-new--let-studio-see-and-edit-flat-redirect-entries", + "title": "Let the site editor see and edit flat redirect entries", + "today": "The Redirects collection lists only blocks typed exactly <code>website/loaders/redirect.ts</code> and writes <code>{ redirect: { from, to, type, discardQueryParameters } }</code>, with temporary = 307 (redirect-data.ts:14,29-32,77). Flat <code>redirect</code> entries written by developers or agents never appear there and can't be edited.", + "features": [ + "redirects" + ], + "delivered_by": [ + "roadmap-platform--read-manifest-groups-for-pages-redirects-content-and-apps", + "roadmap-api--add-a-splat-to-matchroute-and-stop-request-time-throws", + "roadmap-cli--widen-the-legacy-alias-bridge" + ] + }, + { + "id": "roadmap-studio-new--fix-local-and-tunnel-modes-show-local-edits-in-dev", + "title": "Edit local files from the site editor; show local edits in dev", + "today": "The dev server serves neither endpoint the site editor's local and tunnel modes use. With a site and token in <code>.dev.vars</code>, <code>remoteLoader</code> swaps in the production release over local edits.", + "features": [ + "dev-studio-content-loop", + "bundler-config" + ], + "delivered_by": [ + "roadmap-cli--ship-deco-serve-a-local-server-for-studio", + "roadmap-api--add-remoteloader-options" + ] + }, + { + "id": "roadmap-studio-new--speed-up-publishes-and-make-failures-visible", + "title": "Speed up publishes and make failures visible", + "today": "The planned once-a-minute check delays propagation, and nothing tells the editor which release is prepared, promoted or served. A failed <code>forDraft</code> silently shows published content.", + "features": [ + "fast-deploy-kv", + "otel-observability" + ], + "delivered_by": [ + "roadmap-platform--add-a-content-live-signal", + "roadmap-api--add-remoteloader-options" + ] + }, + { + "id": "roadmap-studio-new--keep-experiments-working-on-next-major-sites", + "title": "Keep experiments working on next-major sites", + "today": "Results are keyed on the random matcher's saved-entry name, which a pure block function never sees.", + "features": [ + "analytics-trackers" + ], + "delivered_by": [ + "roadmap-platform--drop-legacy-names-from-the-blog-tab-and-experiments", + "roadmap-api--add-an-analytics-bootstrap-and-experiment-support", + "roadmap-cli--widen-the-legacy-alias-bridge" + ] + } + ], + "studio_legacy": [ + { + "id": "roadmap-studio-legacy--handle-the-legacy-types-studio-keeps-writing", + "title": "Handle the legacy types the site editor keeps writing", + "today": "<code>website/pages/Page.tsx</code>, SeoV2 on every new page, <code>Rendering/Lazy.tsx</code> (the ⚡ toggle), <code>flags/multivariate/section.ts</code> (hide), nested <code>website/loaders/redirect.ts</code>, <code>website/loaders/secret.ts</code>, <code>site/apps/site.ts</code>, the <code>multi</code> matcher, Theme. The bridge only aliases pages, matchers and multivariate, so these site editor actions produce UNKNOWN_BLOCK unless each site registers them.", + "features": [ + "lazy-deferred-sections", + "page-seo-blocks", + "site-config-block", + "redirects", + "theming-and-styling", + "matchers-and-variants" + ], + "delivered_by": [ + "roadmap-cli--widen-the-legacy-alias-bridge" + ] + }, + { + "id": "roadmap-studio-legacy--stop-hidden-sections-from-running", + "title": "Stop hidden blocks from running", + "today": "'Every variant resolves': a block hidden with a never-rule still runs its loaders, and its <code>undefined</code> renders 'temporarily unavailable'.", + "features": [ + "matchers-and-variants" + ], + "delivered_by": [ + "roadmap-api--add-the-lazy-block", + "roadmap-docs--resolve-contradictions-in-these-docs" + ] + }, + { + "id": "roadmap-studio-legacy--drop-hidden-array-items-instead-of-leaving-holes", + "title": "Drop hidden array items instead of leaving holes", + "today": "The site editor hides a banner or link by wrapping it in <code>website/flags/multivariate.ts</code> with a <code>never</code> rule (array-item-hidden.ts:35-40). Today's resolver drops <code>undefined</code> array items (resolve.ts:849-860). The proposed API's rule and <code>resolve(page.sections)</code> ('An array of results, one per block in the list') keep them, so components receive <code>undefined</code> entries.", + "features": [ + "matchers-and-variants" + ], + "delivered_by": [ + "roadmap-docs--resolve-contradictions-in-these-docs" + ] + }, + { + "id": "roadmap-studio-legacy--fix-the-default-variant-rule-that-makes-other-variants-unreachable", + "title": "Flag variants that an <code>always</code> rule makes unreachable", + "today": "The site editor seeds every new variant with an <code>always</code> rule (section-types.ts:26; a new field variant starts as two) and keeps the first <code>always</code>, else the last variant (section-variants.ts:158-168). These docs say 'put the always() variant first as the default' and 'first match wins', which makes every other variant unreachable; their own HomeHero puts <code>always</code> last.", + "features": [ + "matchers-and-variants" + ], + "delivered_by": [ + "roadmap-cli--add-validation-commands-to-the-cli", + "roadmap-docs--resolve-contradictions-in-these-docs" + ] + }, + { + "id": "roadmap-studio-legacy--decode-filenames-exactly-once", + "title": "Decode filenames exactly once", + "today": "The site editor writes <code>encodeURIComponent(key)</code>. Real stems include <code>pages-Category%2520Page-*</code> and <code>collections%2Fblog%2F*</code>.", + "features": [ + "block-type-discriminator", + "content-storage-and-delivery" + ], + "delivered_by": [ + "roadmap-cli--specify-the-filename-rule-and-ship-a-content-bundle", + "roadmap-api--publish-the-content-protocol-and-its-package" + ] + }, + { + "id": "roadmap-studio-legacy--handle-short-form-pages-in-the-page-list-and-seo", + "title": "Handle short-form pages in the page list and SEO", + "today": "<code>page</code> entries are missing from the page list and can be mistaken for the site-SEO owner.", + "features": [ + "cms-page-routing", + "site-seo-defaults" + ], + "delivered_by": [] + }, + { + "id": "roadmap-studio-legacy--stop-keying-the-blog-tab-on-legacy-names", + "title": "Stop keying the Blog tab on legacy names", + "today": "It looks for <code>blog/loaders/*.ts</code> and <code>@decocms/apps-blog</code> ≥ 7.53.0. The TanStack blog reads as unsupported-runtime today.", + "features": [ + "key-prefix-content-collections", + "studio-workspace-spaces" + ], + "delivered_by": [ + "roadmap-platform--drop-legacy-names-from-the-blog-tab-and-experiments" + ] + }, + { + "id": "roadmap-studio-legacy--upgrade-both-tanstack-sites-so-studio-can-frame-them", + "title": "Upgrade both TanStack sites so the site editor can frame them", + "today": "Both TanStack sites send <code>X-Frame-Options: SAMEORIGIN</code> and lack <code>?__draft</code>, so both plans start with an upgrade to the latest 7.x, past #390 (7.20.10) and #442 (7.34.0).", + "features": [ + "csp-security-headers", + "worker-server-entry" + ], + "delivered_by": [ + "roadmap-storefront--upgrade-7-20-7-to-the-latest-7-x", + "roadmap-blog--upgrade-6-12-to-the-latest-7-x-first" + ] + }, + { + "id": "roadmap-studio-legacy--make-content-drift-visible-to-editors", + "title": "Catch content drift in CI", + "today": "Fields missing from the schema are hidden in the editor and dropped on save, and that stays as it is. Defaults apply only on save. The storefront's Header children are typed <code>string[]</code> today.", + "features": [ + "content-schema-drift", + "cms-navigation-menus" + ], + "delivered_by": [ + "roadmap-cli--add-validation-commands-to-the-cli" + ] + } + ], + "work_items": [ + { + "id": "roadmap-api--add-a-request-scope", + "group": "api", + "title": "Add a request scope", + "plan": "<p>One scope in <code>@decocms/blocks</code>, behind conditional exports: <code>requestScope()</code> returns <code>{ url, params, request, client, device, forcedRules }</code>, plus <code>appendResponseHeader()</code>. The templates' openPage populates it with the real page href (port <code>derivePageUrl</code>), <code>match.params</code> and the per-request client. It replaces <code>sdk/requestContext</code>, the page-scope helpers and <code>currentClient()</code>, so apps import one framework-neutral port and content-reading loaders stay revision-pinned. <code>device</code> honors <code>?deviceHint</code> and <code>forcedRules</code> exposes <code>x-deco-matchers-override</code>, both on drafts only.</p>", + "features": [ + "section-loaders", + "inline-loader-props", + "request-context-cookies", + "route-params-request-to-param", + "code-composed-pdp", + "plp-filters-sort-pagination", + "home-route-override", + "commerce-platform-binding", + "section-registration-conventions", + "saved-loader-blocks", + "session-regionalization", + "cart-and-minicart", + "shipping-simulation", + "otel-observability", + "key-prefix-content-collections", + "denormalized-collection-references", + "page-seo-blocks", + "in-isolate-caches", + "site-search-and-autocomplete", + "device-detection-and-targeting", + "matchers-and-variants", + "studio-preview" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--publish-the-content-protocol-and-its-package", + "group": "api", + "title": "Publish the content protocol in <code>@decocms/blocks</code>", + "plan": "<p>Write down the protocol between the site editor and stored content: JSON-RPC 2.0 over one HTTP endpoint (<code>POST /rpc</code>, batches allowed) with four methods, <code>describe</code>, <code>schema.get</code>, <code>blocks.list</code> and <code>blocks.apply</code>, plus its errors, limits and conditional reads for polling. Ship it as a subpath of <code>@decocms/blocks</code> (<code>@decocms/blocks/protocol</code>), not a separate package. It holds the types and method definitions, a client, a server core over a <code>ContentStorage</code> interface, a filesystem storage and a black-box conformance suite any implementation runs over HTTP. Its key module holds the one filename rule (decode exactly once) that the site editor, <code>deco serve</code> and <code>deco content</code> all import. See <a href=\"#studio-compatibility\">Site editor compatibility</a>.</p><p>Add advertised durable idempotency receipts and optional schema preconditions, bound uncompressed reads and batch responses, and recheck guards after synchronization and storage retries. Preserve atomic apply semantics through autosave coalescing. Extend conformance for lost responses, restart recovery, racing writes, oversized payloads and tenant isolation. Hosted draft lifecycle, publishing and rollback stay outside the four-method protocol.</p><p>Use the <a href=\"#studio-implementation\">Studio implementation handoff</a> for module ownership, durable state and APIs, operation sequencing, proposed defaults and acceptance scenarios; completion includes the documented failure and concurrency cases.</p>", + "features": [ + "admin-protocol-endpoints", + "dev-studio-content-loop", + "block-type-discriminator", + "content-storage-and-delivery", + "studio-schema-generation", + "section-catalog", + "site-bootstrap" + ], + "docs": [ + "studio-compatibility", + "content-protocol", + "studio-implementation" + ] + }, + { + "id": "roadmap-api--let-studio-follow-the-deployed-schema", + "group": "api", + "title": "Read the schema from where the site editor points", + "plan": "<p>The site editor builds its forms from the schema of the site it targets. Targeting localhost, it reads the schema from <code>deco serve</code>, so new block types show up as soon as <code>deco schema</code> writes them. Targeting a draft on any other server, it reads the committed <code>.deco/schema.gen.json</code>. There's no option to follow the production deployment's schema: <code>deco check</code> in <code>prebuild</code> already stops content that uses undeployed types from shipping. Update <a href=\"#schema\">Schema generation</a> once it ships.</p>", + "features": [ + "admin-protocol-endpoints", + "content-schema-drift", + "studio-schema-generation" + ], + "docs": [ + "schema", + "studio-compatibility" + ], + "pinned": false + }, + { + "id": "roadmap-api--add-the-lazy-block", + "group": "api", + "title": "Add the <code>lazy</code> block and <code>Lazy<T></code>", + "plan": "<p>The resolver's one special case: <code>{ \"__resolveType\": \"lazy\", \"value\": … }</code> resolves to <code>() => Promise<T></code>, which resolves <code>value</code> on the first call and returns the same result after that. Export <code>type Lazy<T> = () => Promise<T></code>. The built-in <code>multivariate</code> takes <code>{ rule: boolean; value: Lazy<T> }[]</code> and calls only the first value whose rule is true. <code>deco schema</code> turns a <code>Lazy<T></code> field into a lazy block whose value is <code>T</code>; the site editor shows the <code>T</code> form and writes the wrapper; <code>deco check</code> flags a <code>Lazy<T></code> field without a lazy block and a lazy block in a field that isn't <code>Lazy<T></code>. The alias bridge wraps the values of legacy <code>website/flags/multivariate.ts</code> content.</p>", + "features": [ + "matchers-and-variants", + "lazy-deferred-sections", + "studio-schema-generation" + ], + "docs": [ + "blocks", + "matchers-and-variants", + "schema" + ], + "pinned": false + }, + { + "id": "roadmap-api--ship-the-withdeco-and-decoproxy-entry-wrappers", + "group": "api", + "title": "Add <code>withDeco()</code> and <code>decoProxy()</code> entry wrappers to the templates", + "plan": "<p>Template code, not a package: <code>withDeco(handler, { cms })</code> in the TanStack templates and <code>decoProxy()</code> in the Next templates own the per-request plumbing. They set the draft cookie as <code>Secure; SameSite=None; Partitioned; HttpOnly</code> and treat <code>__draft=off</code> as exit. Drafted responses are private/no-store/noindex, and security headers carry frame-ancestors from an exported <code>STUDIO_FRAME_ANCESTORS</code>, never X-Frame-Options. Site editor routes are optional. The CMS keys draft caches on the API ETag, bounded by an LRU.</p>", + "features": [ + "worker-server-entry", + "csp-security-headers", + "env-vars-and-secrets", + "hosting-and-deploy-config", + "fast-deploy-kv", + "studio-preview", + "router-and-client-navigation", + "plp-filters-sort-pagination", + "edge-html-cache-profiles", + "in-isolate-caches", + "bundler-config" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-a-revision-handle", + "group": "api", + "title": "Add a revision handle", + "plan": "<p><code>client.revision()</code>, <code>cms.forRevision(rev)</code> / <code>forRelease({ revision })</code>, <code>CMS.onUpdate(cb)</code> and <code>update({ force })</code>.</p>", + "features": [ + "edge-html-cache-profiles", + "in-isolate-caches", + "lazy-deferred-sections", + "tabbed-shelf-partial", + "plp-filters-sort-pagination", + "section-registration-conventions", + "otel-observability", + "app-installation-and-store-config" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--turn-the-apps-into-thin-instrumented-clients", + "group": "api", + "title": "Turn the apps into thin, instrumented clients", + "plan": "<p>The framework doesn't fetch data. Each companion app (VTEX, Shopify, Wake, Magento, Algolia, Resend, …) becomes a thin API client for its platform: typed request functions over the framework's instrumented fetch, keeping the platform's API and generated types. Latency is measured to the response headers, retries count as one data point with a <code>retries</code> attribute, and error logs are structured and never contain upstream bodies, tokens or cookies. The VTEX client keeps retry and circuit breaking on by default. Converters to commerce types, the cart, user and wishlist hooks, cart, session and auth flows, and website features beyond the built-in matchers and multivariate (SEO helpers, sitemaps, redirect logic, analytics scripts) move to platform templates and site code. <a href=\"#upstream-clients\">Upstream API clients</a> has the recipe for writing a client.</p>", + "features": [ + "framework-import-surface", + "upstream-fetch-instrumentation", + "commerce-platform-binding", + "cart-customer-server-functions", + "client-commerce-state-hooks", + "vtex-io-app-settings", + "bff-custom-endpoints", + "web-fonts" + ], + "docs": [ + "upstream-clients" + ] + }, + { + "id": "roadmap-api--remove-deco-invoke-and-cachedloader", + "group": "api", + "title": "Remove <code>/deco/invoke</code> and <code>cachedLoader</code>", + "plan": "<p>The core drops the <code>/deco/invoke</code> endpoint and <code>cachedLoader</code>. Per-shopper reads and actions become the framework's own server functions or route handlers, which call the clients directly. A block function may still fetch data when a site wants it to; it calls a client like any other code.</p>", + "features": [ + "invoke-and-actions", + "in-isolate-caches", + "site-local-loaders", + "saved-loader-blocks" + ], + "docs": [ + "upstream-clients", + "api-reference" + ] + }, + { + "id": "roadmap-api--cache-upstream-requests-in-the-bindings", + "group": "api", + "title": "Document upstream caching recipes", + "plan": "<p>Caching depends on the platform, so it lives in the templates, not in a package or the clients: a short recipe per platform, a fetch over the Cloudflare Cache API on Workers and over Next's data cache on Next.js, passed as a client's <code>fetch</code> option. The instrumented fetch reports whether each response came from the cache (the <code>cached</code> label), and drafts bypass the cache.</p>", + "features": [ + "in-isolate-caches", + "upstream-fetch-instrumentation", + "edge-html-cache-profiles" + ], + "docs": [ + "upstream-clients", + "caching" + ] + }, + { + "id": "roadmap-api--add-remoteloader-options", + "group": "api", + "title": "Add <code>remoteLoader</code> options", + "plan": "<p><code>{ interval, coldStartTimeoutMs, channel, onUpdate, onError }</code>, also accepted by <code>createCMS</code>. <code>interval</code> is in milliseconds, defaults to <code>DECO_CONTENT_INTERVAL</code> or 60 000, and has a 60 000 minimum (lower values are raised to one minute with a warning). Each check runs one interval ± 10 s after the previous one and fetches only the revision hash. The SDK schedules due checks itself at an idle moment: <code>requestIdleCallback</code>, then <code>scheduler.postTask</code>, then <code>setTimeout</code>; an unref'd timer on Node; and after the response inside <code>ctx.waitUntil</code> on Workers. Instances are process-wide singletons keyed by configuration, with a <code>resetForTests()</code> helper. In dev, the local <code>.deco/blocks</code> wins over the published release even with a site token set, while <code>?__draft=</code> links still fetch the draft from the API. A draft that fails to load is the app's call, not the framework's: <code>cms.forDraft(pointer).resolve()</code> returns <code>[null, error]</code> and never falls back to published content silently.</p>", + "features": [ + "fast-deploy-kv", + "env-vars-and-secrets", + "hosting-and-deploy-config", + "otel-observability", + "dev-studio-content-loop", + "bundler-config", + "edge-html-cache-profiles" + ], + "docs": [ + "hosted-publishing" + ], + "pinned": false + }, + { + "id": "roadmap-api--document-and-support-kv-only-content-for-large-workers-sites", + "group": "api", + "title": "Document and support KV-only content for large Workers sites", + "plan": "<p>With <code>content</code> plus <code>site</code> and <code>token</code>, the bundled content module stays in memory as the fallback next to the live release, against a Worker isolate's 128 MB limit, and it also counts toward the script size limit. Document passing a KV-backed <code>Loader</code> as <code>content</code> for large sites, so no copy is bundled and the fallback is read from KV only when needed. Document a few-line KV loader as template code (no new API: any object with <code>load()</code>), and a deploy step that writes the content to the key before the new Worker takes traffic, since with a missing key every new isolate fails its first reads until its first release check.</p>", + "features": [ + "fast-deploy-kv", + "hosting-and-deploy-config", + "in-isolate-caches", + "content-storage-and-delivery" + ], + "docs": [ + "hosted-publishing" + ], + "pinned": false + }, + { + "id": "roadmap-api--add-session-region-delivery-promise-and-prices-to-apps-vtex", + "group": "api", + "title": "Add session, region, delivery promise and prices to <code>apps-vtex</code>", + "plan": "<p>Typed requests for delivery promise in Intelligent Search, tax-inclusive prices, full installments with InterestRate, Intelligent Search events, and <code>createVtexIoClient</code> for persisted IO queries. The session module (<code>resolveRegion</code>, <code>setSegment</code>), the session and cart hooks move to the VTEX platform template, which sites copy.</p>", + "features": [ + "session-regionalization", + "delivery-promise", + "product-card", + "graphql-schema-extensions", + "cart-and-minicart", + "vtex-io-app-settings", + "ecommerce-analytics-events" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-a-studio-bridge", + "group": "api", + "title": "Add a site editor bridge", + "plan": "<p>A client-safe <code><StudioBridge /></code> that accepts <code>editor::inject</code> only from an explicit list of site editor origins, configurable for a self-hosted or native site editor. Also <code>resolve(t, { withType: true })</code> to recover the block type.</p>", + "features": [ + "root-document-layout", + "studio-preview", + "interactive-ui-widgets", + "section-catalog", + "framework-import-surface", + "lazy-deferred-sections" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--ship-an-seo-kit-in-apps-website-and-apps-commerce", + "group": "api", + "title": "Ship an SEO kit in the platform templates", + "plan": "<p>SEO helpers are website features, so they live in the platform templates, as code a site owns: <code>PageSeo</code> and SEO functions pre-aliased to SeoV2/Seo/SeoPDPV2/SeoPLPV2, <code>mergeSiteSeo(siteSeo, pageSeo)</code> as the one site-SEO merge (sharing a test suite with the site editor), <code>seoToNextMetadata</code> / <code>seoToTanStackHead</code>, JSON-LD builders and a <code><JsonLd></code> renderer.</p>", + "features": [ + "page-seo-blocks", + "site-seo-defaults", + "commerce-seo-sections-in-sections", + "json-ld-structured-data", + "site-config-block", + "robots-and-static-seo-files" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--define-an-app-contract", + "group": "api", + "title": "Define the client contract", + "plan": "<p>Each client package exports a factory that takes its configuration as arguments (for example <code>createVtexClient({ account, appKey, appToken })</code>, read from environment variables in site code) and typed request functions. No block map, no framework package dependency, no dynamic <code>module</code> import. Installing an app from the site editor's store is unavailable on next-major sites: a client is added in code.</p>", + "features": [ + "app-installation-and-store-config", + "commerce-platform-binding", + "app-loader-overrides", + "inline-loader-props", + "cart-customer-server-functions", + "shopify-autoconfig-workaround" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-an-edge-cache-kit", + "group": "api", + "title": "Add an edge cache recipe", + "plan": "<p>A Workers template recipe, <code>withEdgeCache(entry, { segment, profiles })</code>, keyed by URL, build, revision and segment. It skips drafts, cold-start fallback and degraded pages. Plus <code>cacheHeaders(profile)</code> and <code>markDegraded()</code>.</p>", + "features": [ + "edge-html-cache-profiles", + "saved-loader-blocks", + "inline-loader-props", + "device-detection-and-targeting", + "session-regionalization", + "cart-customer-server-functions" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--define-a-per-client-memoization-contract", + "group": "api", + "title": "Define a per-client memoization contract", + "plan": "<p>A saved entry, or an identical inline block, resolves once per client (keyed by type plus canonical inputs), and concurrent calls share in-flight promises.</p>", + "features": [ + "saved-loader-blocks", + "inline-loader-props", + "native-section-overrides", + "commerce-seo-sections-in-sections", + "implicit-page-data-context" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-a-splat-to-matchroute-and-stop-request-time-throws", + "group": "api", + "title": "Add a splat to <code>matchRoute</code> and stop request-time throws", + "plan": "<p>A trailing <code>/*</code> that matches one or more segments, at lowest precedence (also for <code>Redirect.from</code>, with substitution that keeps segments percent-encoded), and a deterministic tie-break (the entry earlier in the array wins, so <code>matchRoute</code> never throws). The built-in <code>redirect</code> also takes an optional <code>status</code> (301, 302, 307 or 308) and <code>discardQueryParameters</code>, so legacy redirects keep their exact behaviour.</p>", + "features": [ + "cms-page-routing", + "redirects", + "page-template-targeting", + "plp-filters-sort-pagination", + "commerce-platform-binding" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--ship-decocms-blocks-secrets", + "group": "api", + "title": "Ship the built-in <code>secret</code> block", + "plan": "<p>A built-in <code>secret</code> block, <code>{ \"__resolveType\": \"secret\", \"ciphertext\": \"…\" }</code>, resolves to the decrypted string on the server. Fields typed <code>Secret</code> (a branded string) get a write-only password widget in the schema. Encryption is asymmetric: the public key is committed in <code>.deco/</code> (for example <code>.deco/secrets.pub</code>), so the site editor, <code>deco serve</code> and agents can encrypt, and the private key lives only on the server, passed as <code>createCMS({ secrets: { key: process.env.DECO_SECRETS_KEY } })</code>. Only ciphertext is committed.</p><p>Guardrails: a secret resolves only on the server (it throws in the browser and when passed to a client component), <code>{ run: false }</code> returns the ciphertext, telemetry redacts it, and <code>deco check</code> validates the ciphertext's format only. Open-source users create the key pair by hand with one standard command the docs show; there's no new CLI command. Migrating a v7 site re-encrypts its secrets once: decrypt with DECO_CRYPTO_KEY, encrypt with the new public key.</p>", + "features": [ + "app-installation-and-store-config", + "invoke-and-actions", + "env-vars-and-secrets", + "commerce-platform-binding" + ], + "docs": [ + "built-in-blocks" + ], + "pinned": false + }, + { + "id": "roadmap-api--add-a-site-wide-layout-helper", + "group": "api", + "title": "Add a site-wide layout helper", + "plan": "<p><code>mergeGlobals(site, page)</code>, with layout blocks resolved on the page's client. Site SEO defaults go through the SEO kit's <code>mergeSiteSeo</code>.</p>", + "features": [ + "site-config-block", + "global-layout-sections", + "theming-and-styling", + "build-time-site-globals-snapshot" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--keep-the-tanstack-router-helpers-available", + "group": "api", + "title": "Export the TanStack router helpers", + "plan": "<p>Drop <code>createDecoRouter</code>. Export its pieces instead, <code>decoParseSearch</code>/<code>decoStringifySearch</code>, the CSP-nonce wiring and the scroll and intent defaults, so sites pass them to TanStack's own <code>createRouter</code>.</p>", + "features": [ + "router-and-client-navigation", + "home-route-override", + "plp-filters-sort-pagination", + "interactive-ui-widgets" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-an-image-module", + "group": "api", + "title": "Add an image module", + "plan": "<p><code>@decocms/blocks/image</code> (optimized URLs, srcset, site editor quality params), a Next <code>loaderFile</code>, and TanStack <code><Image></code>/<code><Picture></code>. Export the widget aliases.</p>", + "features": [ + "image-optimization", + "editor-widgets-and-image-fields", + "framework-import-surface" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-an-analytics-bootstrap-and-experiment-support", + "group": "api", + "title": "Support experiments; move the analytics bootstrap to the templates", + "plan": "<p>Analytics scripts are a website feature: the DECO.events bootstrap and a typed <code>useSendEvent</code>/dispatch move to the platform templates. Template ecommerce events call <code>track()</code> alongside their GTM push; the core stays out of ecommerce. The framework keeps what experiments need: an <code>assignExperiments()</code> middleware helper and an explicit experiment id prop that keys results, which the site editor fills (the legacy alias bridge copies the old saved entry name into it, so existing results carry over). Variant exposure events for real-user monitoring come after the first release (see {{item:roadmap-later--add-real-user-monitoring}}).</p>", + "features": [ + "analytics-trackers", + "ecommerce-analytics-events", + "root-document-layout" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-a-not-found-and-redirect-gate-before-streaming", + "group": "api", + "title": "Add a not-found and redirect gate before streaming", + "plan": "<p>A critical block (or seo) may return <code>{ notFound }</code> or <code>{ redirect }</code>, which the app maps to <code>notFound()</code>/<code>redirect()</code>. Framework control-flow errors are rethrown.</p>", + "features": [ + "not-found-and-error-pages", + "code-composed-pdp", + "redirects" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-observability-seams", + "group": "api", + "title": "Send the SDK's measurements to any collector", + "plan": "<p>The SDK reports what it sees: resolution, upstream fetches through <code>createInstrumentedFetch</code>, and errors. Inbound requests and page caches come from the framework's own OpenTelemetry setup, not from Deco CMS. Where the SDK's measurements go is the <code>telemetry</code> option of <code>createCMS</code>: <code>{ endpoint, headers? }</code> sends OpenTelemetry over OTLP/HTTP to any collector, <code>{ site, token }</code> sends to the hosted Deco CMS collector, and <code>false</code> turns it off; when the option is omitted, <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> is used if set, otherwise nothing is sent. Sending happens in the background (<code>ctx.waitUntil</code> on Workers), so a slow collector never delays a response.</p>", + "features": [ + "otel-observability", + "upstream-fetch-instrumentation" + ], + "docs": [ + "telemetry" + ], + "pinned": false + }, + { + "id": "roadmap-api--route-telemetry-with-several-cms-instances", + "group": "api", + "title": "Route telemetry with several CMS instances in one process", + "plan": "<p>With several CMS instances in one process (several sites in one app, for example), each instrumented-fetch measurement goes to the CMS whose client is handling the current request, so every site reports only its own upstream traffic.</p>", + "features": [ + "otel-observability", + "upstream-fetch-instrumentation" + ], + "docs": [ + "telemetry-internals", + "telemetry" + ], + "pinned": false + }, + { + "id": "roadmap-api--add-the-telemetry-block-sampling-and-aggregation", + "group": "api", + "title": "Add the Telemetry block, sampling and aggregation", + "plan": "<p><code>createCMS({ blocks, content, telemetry })</code>: <code>telemetry</code> is <code>{ endpoint, headers? }</code> for your own OpenTelemetry (OTLP/HTTP) collector, <code>{ site, token }</code> for the hosted Deco CMS collector, or <code>false</code>. The top-level <code>site</code> and <code>token</code> load hosted releases and drafts only; they never turn telemetry on by themselves. Telemetry settings (<code>enabled</code>, <code>metrics</code>, <code>errorSampleRate</code>, <code>traceSampleRate</code>) live in an optional well-known saved block, <code>Telemetry</code>, of the built-in <code>telemetry</code> type; <code>deco content</code> doesn't scaffold it, and without it the defaults apply. Editors change it in the site editor or by hand like any content edit; it travels with every release. Code caps what content can raise with <code>telemetry.limits</code> (<code>errorSampleRate</code>, <code>traceSampleRate</code>), so an editor can't increase what leaves for a third party. Error logs are sampled; metrics are aggregated in memory before they're sent; credentials, cookies and bodies are scrubbed in the SDK.</p>", + "features": [ + "otel-observability", + "upstream-fetch-instrumentation", + "env-vars-and-secrets" + ], + "docs": [ + "api-reference", + "built-in-blocks", + "telemetry" + ] + }, + { + "id": "roadmap-api--add-open-source-analytics", + "group": "api", + "title": "Add open-source analytics compatible with One Dollar Stats", + "plan": "<p>Ship a built-in <code>analytics</code> block, separate from telemetry (it doesn't read the <code>telemetry</code> option or the <code>Telemetry</code> block). It's a settings function, <code>(props?: Analytics) => Required<Analytics></code>: it returns <code>{ enabled: true, collector: <hosted Deco CMS collector>, ...props }</code>, so <code>{ \"__resolveType\": \"analytics\" }</code> with no arguments uses the hosted collector, <code>collector</code> points it at your own endpoint and <code>enabled: false</code> turns it off. A site saves an <code>Analytics</code> block, resolves it in its root layout and renders <code><AnalyticsScript {...analytics} /></code> (nothing when <code>enabled</code> is false), the same on every framework, and editors change or switch it off in the site editor. There's no site ID: the collector tells sites apart by the page's hostname, as One Dollar Stats does. The script sends page views (path without query string, referrer, hostname) from the browser with no cookies; <code>track(name, props?)</code> from <code>@decocms/blocks/analytics</code> sends custom events through it. Events use the One Dollar Stats tracker's wire format (base64 JSON in <code>GET ?data=</code> when short, otherwise <code>POST</code>/<code>sendBeacon</code>), tested against a pinned tracker version, so <code>collector</code> can be One Dollar Stats' own collector or any endpoint that accepts that format; by default, the hosted Deco CMS collector receives them.</p>", + "features": [ + "analytics-trackers", + "ecommerce-analytics-events" + ], + "docs": [ + "analytics", + "api-reference", + "built-in-blocks", + "telemetry-internals" + ] + }, + { + "id": "roadmap-api--rename-apps-salesforce-to-a-marketing-cloud-personalization-client", + "group": "api", + "title": "Rename <code>apps-salesforce</code> to <code>apps-sfmc-personalization</code>", + "plan": "<p>The package is a client for Salesforce Marketing Cloud Personalization (formerly Evergage), so it becomes <code>apps-sfmc-personalization</code>, on the same thin-client model as the other apps.</p>", + "features": [ + "commerce-platform-binding", + "upstream-fetch-instrumentation" + ], + "docs": [ + "upstream-clients" + ] + }, + { + "id": "roadmap-api--add-predictive-search-and-shipping-rates-to-apps-shopify", + "group": "api", + "title": "Add predictive search and shipping rates to <code>apps-shopify</code>", + "plan": "<p>A predictive-search suggestions loader and a shipping-rates helper.</p>", + "features": [ + "site-search-and-autocomplete", + "shipping-simulation" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--generate-the-sitemap-from-a-client", + "group": "api", + "title": "Put sitemap generation in the platform templates", + "plan": "<p>Sitemaps are a website feature, so the templates ship them as site code: <code>sitemapEntries(client, { types, expand, exclude })</code>, <code>toSitemapXml</code>/<code>toSitemapIndex</code>, plus a Shopify sitemap merge.</p>", + "features": [ + "sitemap" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--give-the-traffic-split-a-home-and-bypass-drafts-by-default", + "group": "api", + "title": "Give the traffic split a home and bypass drafts by default", + "plan": "<p>Move <code>withABTesting</code> into the TanStack templates as a recipe and bypass drafts by default.</p>", + "features": [ + "ab-traffic-split" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-cli--write-a-studio-compatible-schema-file", + "group": "cli", + "title": "Write a site-editor-compatible schema file", + "plan": "<p>Write <code>.deco/schema.gen.json</code> in the site editor's format (<code>deco-meta@1</code>, byte-compatible with today's <code>meta.gen.json</code>, which the protocol still reads as a fallback): <code>manifest.blocks</code>, <code>schema.definitions</code> keyed by padded, path-independent btoa, and <code>schema.root</code> unions. Add <code>apps</code> and <code>actions</code> groups. For every field a variant can fill (a field of type T), emit a monomorphized multivariate definition per T (value typed as T) under the site editor's legacy per-kind keys (<code>website/flags/multivariate/section.ts</code>, <code>…/image.ts</code>, …), so the field variant UI renders the right input.</p>", + "features": [ + "studio-schema-generation", + "codegen-pipeline", + "ci-and-release-workflows", + "site-bootstrap", + "content-storage-and-delivery", + "app-domain-types", + "cms-schema-upload-pipeline", + "section-catalog", + "app-installation-and-store-config", + "workspace-packages", + "matchers-and-variants", + "editor-widgets-and-image-fields" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-cli--write-static-option-lists-into-the-schema", + "group": "cli", + "title": "Write static option lists into the schema", + "plan": "<p>The site editor no longer calls the site for picker options, so <code>deco schema</code> writes every static list into the schema as an <code>enum</code>: string-literal unions, TypeScript enums and <code>@options</code> literal lists, with labels where the code gives them. A picker whose options come from a function falls back to free text in this version.</p>", + "features": [ + "icon-by-name-enums", + "editor-widgets-and-image-fields", + "studio-schema-generation", + "conditional-schema-fields" + ], + "docs": [ + "schema", + "how-it-works" + ] + }, + { + "id": "roadmap-cli--widen-the-legacy-alias-bridge", + "group": "cli", + "title": "Widen the legacy alias bridge", + "plan": "<p>Unwrap Lazy/SingleDeferred/Deferred (the site editor's legacy <code>Lazy</code> section wrapper: the wrapped block renders normally, with no client-side deferral; it is unrelated to the new <code>lazy</code> block), Seo*, <code>site/apps/site.ts</code>, <code>resolved</code>, requestToParam, secret (after the one-time re-encryption by the migration), redirect (nested shape flattened, <code>temporary</code> kept as status 307 and <code>discardQueryParameters</code> kept), the device and random matchers (a random variant's saved entry name is copied into its experiment id), the <code>multi</code> matcher (<code>website/matchers/multi.ts</code> and <code>$live/matchers/MatchMulti.ts</code>, whose and/or combinators keep the <code>{ op, matchers }</code> shape that the site editor's variant calendar and rule labels read) and Theme, and list exactly which paths. Legacy Analytics (GTM/GA) isn't aliased to the built-in <code>analytics</code> block, which stays One Dollar Stats only: the migration moves it into a site-owned tag-manager block from the template.</p>", + "features": [ + "block-type-discriminator", + "section-catalog", + "lazy-deferred-sections", + "site-config-block", + "site-search-and-autocomplete", + "matchers-and-variants", + "analytics-trackers", + "redirects", + "theming-and-styling", + "page-seo-blocks", + "i18n-and-currency" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-cli--add-validation-commands-to-the-cli", + "group": "cli", + "title": "Add <code>deco check</code>, which validates saved content against the code", + "plan": "<p><code>deco check [--root <dir>]</code> reads <code>.deco/schema.gen.json</code> and <code>.deco/blocks</code> as they are (it writes nothing, generates no schema, not even in memory, loads no TypeScript and doesn't check whether <code>schema.gen.json</code> is stale; run <code>deco schema</code> first), validates every saved block against that schema, lists the problems per file and exits 1 if there is any. Saved content is valid when every block's props match its block type's schema (required fields, allowed values, limits); every <code>__resolveType</code> names a block type (built-ins and aliases included) or an existing saved block; a reference in a typed field points to a block that returns that type; no saved block is named like a block type, built-in or alias; no two entries match the same URL; and every <code>secret</code> holds well-formed ciphertext (its format only). It also warns, without failing, about a variant after an <code>always</code> rule, which can never be chosen. It catches both directions, code that breaks saved content and content that uses types or fields the code lacks, and runs on content-only changes too, the same locally and in CI. It also runs in <code>prebuild</code> (<code>deco schema && deco content && deco check</code>; <code>predev</code> skips it), so a broken mix of code and content fails the build and never deploys. The docs ship a CI example: <code>npx @decocms/blocks schema && npx @decocms/blocks check</code>, then <code>git diff --exit-code -- .deco/schema.gen.json</code> so a stale committed schema (the file the site editor reads) fails, plus an optional “Require branches to be up to date before merging” (or a merge queue), to catch two changes that pass alone but break together at pull-request time instead of at build time. The Deco API and <code>remoteLoader</code> don't validate content at runtime. Ships with the first release.</p><p>Implementation: validate with a JSON Schema validator (such as Ajv) against <code>.deco/schema.gen.json</code> as it is on disk, not by type-checking generated TypeScript with <code>tsc</code>: it takes milliseconds, its errors name the file and field, and it enforces JSDoc constraints and exactly what the site editor allows. Dispatch on <code>__resolveType</code> instead of reporting every <code>anyOf</code> branch, check references against the return type of the function behind them, and rewrite validator errors into short plain lines.</p>", + "features": [ + "orphan-and-dangling-blocks", + "content-schema-drift", + "matchers-and-variants", + "newsletter-subscription", + "dev-tooling-and-repo-hygiene", + "e2e-lighthouse-config", + "ci-and-release-workflows", + "cms-schema-upload-pipeline", + "page-seo-blocks", + "site-bootstrap", + "codegen-pipeline", + "content-storage-and-delivery" + ], + "docs": [ + "checking", + "cli" + ], + "pinned": false + }, + { + "id": "roadmap-cli--publish-the-tag-and-widget-vocabulary", + "group": "cli", + "title": "Publish the tag and widget vocabulary", + "plan": "<p>A full JSDoc/@format table (image-uri, video-uri, html, rich-text, rich-text-inline, textarea, markdown, color-input, icon-select, @titleBy, @hide, @options, @label, @minItems…), with widget-alias detection kept, labelled literals, and a golden test against <code>generate-schema.ts</code>.</p>", + "features": [ + "editor-widgets-and-image-fields", + "app-domain-types", + "icon-by-name-enums", + "rich-text-html-props", + "framework-import-surface", + "conditional-schema-fields", + "web-fonts", + "theming-and-styling", + "cms-navigation-menus" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-cli--ship-migration-codemods", + "group": "cli", + "title": "Ship migration codemods", + "plan": "<p><code>deco migrate block-map</code> from 7.x manifests, one 7 → next import codemod (6.x sites upgrade to 7.x first), an SEO-hoist rule, APP_REGISTRY → block maps, a requestToParam scaffold, a rule that moves legacy Analytics (GTM/GA) content into the template's tag-manager block, and a one-time re-encryption of v7 secrets for the <code>secret</code> block.</p>", + "features": [ + "codegen-pipeline", + "site-bootstrap", + "framework-import-surface", + "commerce-seo-sections-in-sections", + "app-installation-and-store-config", + "route-params-request-to-param", + "section-registration-conventions" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-cli--vendor-the-loaders-and-actions-a-site-uses-during-migration", + "group": "cli", + "title": "Vendor the loaders and actions a site uses during migration", + "plan": "<p>The companion apps stop shipping loaders and actions, so the migration copies the ones a site actually uses (found from its saved content and imports) into the site's own code, rewritten over the thin clients, with their legacy type names kept as aliases so saved content still resolves.</p>", + "features": [ + "site-local-loaders", + "saved-loader-blocks", + "commerce-platform-binding", + "app-loader-overrides", + "inline-loader-props" + ], + "docs": [ + "renames-and-migrations", + "upstream-clients" + ] + }, + { + "id": "roadmap-cli--define-the-loader-picker-rule", + "group": "cli", + "title": "Match fields to block functions by return type", + "plan": "<p>A field's type is the value the function receives. <code>deco schema</code> reads each block function's awaited return type, so an object field of type T accepts a plain T or any block whose function returns <code>T</code> or <code>Promise<T></code> (anyOf $refs), and the site editor's block picker offers those functions plus the saved blocks that resolve through them. <code>ReactNode</code> and <code>ReactNode[]</code> fields take only functions that return JSX (a React element, or a promise of one; the site editor's legacy <code>__SECTION_REF__</code>): <code>ReactNode</code> also includes strings, numbers and booleans, but functions returning those are left out, so matchers never fit <code>sections</code>. Simple types (<code>string</code>, <code>number</code>, <code>boolean</code>, literal unions) stay plain inputs. The generic <code>multivariate<T></code> fits any field, with T taken from the field and its <code>undefined</code> (no rule matched) allowed, and functions that return <code>boolean</code> fill each rule in <code>variants</code>. The CLI follows imported app maps with last-key-wins and warns on <code>any</code>.</p>", + "features": [ + "inline-loader-props", + "saved-loader-blocks", + "app-loader-overrides", + "web-fonts", + "product-card", + "commerce-platform-binding", + "route-params-request-to-param", + "nested-section-props", + "section-catalog" + ], + "docs": [ + "schema", + "blocks" + ], + "pinned": false + }, + { + "id": "roadmap-cli--specify-the-filename-rule-and-ship-a-content-bundle", + "group": "cli", + "title": "Specify the filename rule and ship a content bundle", + "plan": "<p>Use the protocol module's one filename rule: a file name is decoded exactly once, and the site editor, <code>deco serve</code> and <code>deco content</code> import the same module, with a collision report. <code>deco content</code> writes <code>.deco/blocks.gen.ts</code>, one import per JSON file, exporting the snapshot <code>{ revision, blocks }</code> with the revision computed the same way the Deco API computes it, and <code>computeRevision</code> is exported.</p>", + "features": [ + "block-type-discriminator", + "content-storage-and-delivery", + "codegen-pipeline", + "site-bootstrap", + "dev-studio-content-loop", + "inline-loader-props" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-cli--emit-a-runtime-alias-table", + "group": "cli", + "title": "Emit a runtime alias table", + "plan": "<p>The CLI emits an alias table, a list of second names for types. <code>deco content</code> writes it into the content module (the snapshot's <code>aliases</code> field) and <code>createCMS</code> reads it from there, so <code>list()</code> and <code>resolve()</code> work through aliases. The schema carries it too, mapping the short names (<code>page</code>, <code>redirect</code>, <code>always</code>, <code>never</code>, <code>multivariate</code>, <code>secret</code>) to the well-known types the site editor's special screens look for, so those screens keep working for renamed types.</p>", + "features": [ + "cms-page-routing", + "codegen-pipeline", + "studio-schema-generation", + "key-prefix-content-collections", + "redirects", + "block-type-discriminator" + ], + "docs": [ + "studio-compatibility", + "renames-and-migrations" + ], + "pinned": false + }, + { + "id": "roadmap-cli--ship-deco-serve-a-local-server-for-studio", + "group": "cli", + "title": "Ship <code>deco serve</code>, a local server for the site editor", + "plan": "<p>A command of the <code>deco</code> CLI in <code>@decocms/blocks</code>, <code>deco serve</code>, that serves the content protocol on <code>localhost:<port></code> for the developer's working tree (the app root from <code>--root</code>, or the nearest folder with a <code>.deco/</code>, which it reports to the site editor in <code>describe</code>), so the site editor can edit the files on the developer's machine: the saved content in <code>.deco/blocks</code>, the schema in <code>.deco/schema.gen.json</code> (read-only) and the upload URLs for assets. It listens on the loopback address by default, prints a connect link at start (no token), answers CORS for any origin and answers Chrome's local-network permission preflight. Writes land in <code>.deco/blocks</code> and the app hot-reloads; the server regenerates the content module when a write adds or removes a file. It never commits. <code>deco schema --watch</code> and <code>deco content --watch</code> stay as they are.</p>", + "features": [ + "dev-studio-content-loop", + "bundler-config", + "codegen-pipeline", + "studio-schema-generation" + ], + "docs": [ + "schema", + "cli" + ] + }, + { + "id": "roadmap-cli--store-uploads-in-the-repository-with-deco-serve", + "group": "cli", + "title": "Store uploads in the repository with <code>deco serve</code>", + "plan": "<p>Without the hosted Deco CMS, files uploaded in the site editor are written into the repository: <code>deco serve</code> accepts them at <code>PUT /assets/<name></code> beside <code>/rpc</code>, with the same origin and <code>Host</code> checks, writes them to <code>public/assets/</code> (or the folder <code>--assets <dir></code> names), never overwrites an existing file, and answers with the path the site editor stores in the field, always <code>/assets/<name></code>, such as <code>/assets/summer-banner.jpg</code>. The folder is relative to the folder that contains <code>.deco</code>. <code>describe</code> reports the folder and the largest file accepted in its <code>assets</code> field, which is <code>null</code> with <code>--read-only</code>, and uploads are then refused. Vite, TanStack Start and Next already serve <code>public/assets</code> at <code>/assets/</code> with no setup; a site that picks another folder serves it there itself.</p>", + "features": [ + "dev-studio-content-loop", + "editor-widgets-and-image-fields", + "image-optimization" + ], + "docs": [ + "cli", + "studio-compatibility", + "schema" + ], + "pinned": false + }, + { + "id": "roadmap-cli--ship-the-deco-command", + "group": "cli", + "title": "Ship the <code>deco</code> command", + "plan": "<p>These docs run <code>npx @decocms/blocks schema</code>, but <code>@decocms/blocks</code> doesn't declare a <code>deco</code> bin yet. Ship the CLI inside <code>@decocms/blocks</code>, so <a href=\"#quickstart\">Quickstart</a> installs one package and the CLI always matches the runtime version. Declare <code>deco</code> as the package's single bin, so <code>npx @decocms/blocks <command></code> runs it with nothing installed and <code>package.json</code> scripts call <code>deco <command></code>; the bin is a small JavaScript file that loads the TypeScript sources, so it runs under plain Node as well as Bun. Export the commands from the <code>@decocms/blocks/cli</code> subpath, make <code>typescript</code> a required peer dependency (so npm and Bun install it) that the CLI loads only when a command runs, and keep the runtime from ever importing the <code>cli</code> subpath, so app bundles never include the CLI. It has four commands, <code>deco schema [--root <dir>] [--watch]</code>, <code>deco check [--root <dir>]</code>, <code>deco content [--root <dir>] [--watch]</code> and <code>deco serve [--root <dir>] [--port <n>] [--host <addr>] [--app-url <url>] [--assets <dir>] [--read-only]</code>. Every command works on one folder, <code>.deco/</code>, in the app root (the folder with the app's <code>package.json</code>). <code>--root</code> is the folder that contains it; by default, every command walks up from the current folder to the first folder with a <code>.deco/</code>. There are no input or output paths: <code>deco schema</code> reads <code>.deco/index.ts</code> (or <code>.deco/index.tsx</code>) and writes <code>.deco/schema.gen.json</code>; <code>deco content</code> reads <code>.deco/blocks</code> and writes <code>.deco/blocks.gen.ts</code>, and never reads the block map. The <code>.gen.</code> infix marks the two generated files. The flags of each command are in the <a href=\"/next/cli#deco-schema-and-deco-content\">CLI reference</a>.</p>", + "features": [ + "codegen-pipeline", + "studio-schema-generation", + "dev-tooling-and-repo-hygiene" + ], + "docs": [ + "quickstart", + "schema", + "cli", + "internals" + ], + "pinned": false + }, + { + "id": "roadmap-platform--build-studio-s-github-content-backend", + "group": "studio", + "title": "Build the site editor's GitHub content backend", + "plan": "<p>A <code>ContentStorage</code> inside the site editor's API that reads and writes <code>.deco/blocks</code> through the GitHub API, in the folder the project's \"app root\" setting names (a monorepo subfolder included). Every write is a commit, and all files of one <code>blocks.apply</code> go in one tree and one commit, with file contents inlined in the tree request. Clients poll on window focus and about every 30 seconds with one batched conditional request. Publishing, the draft pointer and <code>?__draft=</code> links stay site editor routes outside the protocol. Drop the requirement for a deployed preview URL, so a project with no deployment can still be edited.</p><p>Reuse the existing Fast Preview Git-provider and autosave machinery behind this adapter. Bind the editor endpoint to an automatically managed draft, normalize set precedence, preserve conditional guards and durable request receipts, and coordinate saves with synchronization and publication.</p><p>Use the <a href=\"#studio-implementation\">Studio implementation handoff</a> for module ownership, durable state and APIs, operation sequencing, proposed defaults and acceptance scenarios; completion includes the documented failure and concurrency cases.</p>", + "features": [ + "admin-protocol-endpoints", + "content-storage-and-delivery", + "studio-schema-generation", + "site-bootstrap", + "workspace-packages" + ], + "docs": [ + "content-protocol", + "studio-compatibility", + "hosted-site-editor", + "draft-synchronization", + "studio-implementation" + ] + }, + { + "id": "roadmap-platform--move-studio-s-editor-onto-the-protocol-client", + "group": "studio", + "title": "Move the site editor onto the protocol client", + "plan": "<p>Projects with a committed <code>.deco/schema.gen.json</code> or <code>.deco/meta.gen.json</code> under their folder use the protocol client, so v7 projects that commit <code>meta.gen.json</code> move to it automatically, with no per-project switch. Fresh and Deno sites, whose schema exists only at runtime, stay on the legacy path. Features that need the site's code degrade: block previews become name cards or open the real page (the local dev app, or the deployed site with a <code>?__draft=</code> link), dynamic pickers fall back to the schema's options, else free text, and Run and installing apps from the store are hidden. <code>Secret</code> fields keep working: the site editor encrypts them itself with the site's committed public key (see {{item:roadmap-platform--encrypt-secret-fields-in-the-site-editor}}). Autosave stays last-writer-wins. Add the connect flow for a local <code>deco serve</code> endpoint, with a one-line explainer before Chrome's local-network prompt.</p>", + "features": [ + "section-catalog", + "global-layout-sections", + "theming-and-styling", + "invoke-and-actions", + "env-vars-and-secrets", + "app-installation-and-store-config", + "dev-studio-content-loop", + "icon-by-name-enums", + "studio-preview" + ], + "docs": [ + "content-protocol", + "studio-compatibility" + ] + }, + { + "id": "roadmap-platform--encrypt-secret-fields-in-the-site-editor", + "group": "studio", + "title": "Encrypt <code>Secret</code> fields in the site editor and manage keys in Deco CMS", + "plan": "<p>The site editor shows a <code>Secret</code> field as a write-only password input: it encrypts the value in the browser with the public key committed in <code>.deco/</code> and saves only the ciphertext as a <code>secret</code> block, never showing a saved value again. It needs no action from the site, so it works on protocol projects and with <code>deco serve</code>. The hosted Deco CMS adds key management: creating the key pair, storing the private key for the site's deployments and rotating it (re-encrypting every saved secret in one commit). Without the hosted Deco CMS, developers create the key pair by hand.</p>", + "features": [ + "env-vars-and-secrets", + "app-installation-and-store-config", + "invoke-and-actions" + ], + "docs": [ + "built-in-blocks", + "site-editor" + ], + "pinned": false + }, + { + "id": "roadmap-platform--add-a-play-workspace-for-local-editing", + "group": "studio", + "title": "Add a play workspace for local editing", + "plan": "<p>Let anyone edit the files on their machine in the site editor without a Deco account: a connect link from <code>deco serve</code> opens a play workspace in the site editor that talks only to that local server, with no site, team or sign-in. Hosted features (the site editor's GitHub backend, releases, drafts on your site, hosted asset storage and telemetry) stay with a connected site.</p>", + "features": [ + "dev-studio-content-loop" + ], + "docs": [ + "how-it-works", + "cli" + ], + "pinned": false + }, + { + "id": "roadmap-platform--store-uploads-in-deco-s-asset-storage", + "group": "studio", + "title": "Store uploads in Deco's asset storage", + "plan": "<p>With the hosted Deco CMS, the site editor's GitHub backend sends uploads to Deco's asset storage (backed by Amazon S3) instead of the repository, and saves each file's CDN address in the field, so images are served from a CDN and the repository doesn't grow. A field can hold either a CDN address or a path to a file in the repository, so sites that started with local uploads keep working.</p>", + "features": [ + "editor-widgets-and-image-fields", + "image-optimization" + ], + "docs": [ + "hosted-site-editor", + "hosted", + "content-protocol" + ], + "pinned": false + }, + { + "id": "roadmap-platform--fix-the-add-section-gate", + "group": "studio", + "title": "Fix the Add Section gate", + "plan": "<p>The site editor shows Add Section only when a preview server is configured. Gate it on having the schema and the blocks instead; otherwise adding blocks to a page disappears on protocol projects.</p>", + "features": [ + "section-catalog", + "per-page-section-whitelists" + ], + "docs": [ + "how-it-works" + ] + }, + { + "id": "roadmap-platform--read-manifest-groups-for-pages-redirects-content-and-apps", + "group": "studio", + "title": "Read manifest groups for pages, redirects, content and apps", + "plan": "<p>Support several page types and a content collection screen. Read flat <code>redirect</code> entries alongside the nested legacy shape, mapping temporary to status 307 and keeping <code>discardQueryParameters</code>.</p>", + "features": [ + "cms-page-routing", + "redirects", + "per-page-section-whitelists", + "page-template-targeting", + "session-regionalization", + "delivery-promise", + "cart-and-minicart", + "vtex-io-app-settings", + "site-config-block" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-platform--use-manifest-lookup-instead-of-path-heuristics", + "group": "studio", + "title": "Use manifest lookup instead of path heuristics", + "plan": "<p>Replace the file-extension module-or-entry check and the site editor's legacy <code>\"/sections/\"</code> path heuristics, and refuse to save an entry whose name is a manifest key.</p>", + "features": [ + "block-type-discriminator", + "section-registration-conventions", + "faststore-cli-overlay", + "native-section-overrides", + "codegen-pipeline", + "favicon-generation", + "section-catalog", + "orphan-and-dangling-blocks" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-platform--build-the-deco-api-release-service", + "group": "studio", + "title": "Build the Deco API release service", + "plan": "<p>Separate GitHub ingestion and publication from a stable storage/CDN data plane: production reads, including misses and cold reads, never call GitHub. Materialize immutable, canonically hashed JSON snapshots before promoting small per-environment channel manifests. Keep authorization in a minimal independently deployed gateway when needed. Reconcile missed or out-of-order webhooks in the control plane, bound materialization memory and retain last-good releases. Publishing doesn't validate content: the runtime's route tie-break and CI's <code>deco check</code> are the safety net.</p><p>Use a durable per-channel promotion coordinator and monotonic generations. Fast rollback selects a retained compatible snapshot without builds, deploys or GitHub reads, records an audit event and pauses automatic promotion until explicitly resumed. Prevent stale jobs from undoing it, define asset retention and garbage collection, notify rendered-page caches, and document actual propagation delays. Site token lifecycle and CI code/content compatibility remain required.</p><p>Use the <a href=\"#studio-implementation\">Studio implementation handoff</a> for module ownership, durable state and APIs, operation sequencing, proposed defaults and acceptance scenarios; completion includes the documented failure and concurrency cases.</p><p>Production assets stay self-contained; draft previews use versioned overlays over local production, without a base-revision fetch. See <a href=\"#content-delivery--exact-draft-previews\">Draft overlays</a>.</p>", + "features": [ + "content-storage-and-delivery", + "cms-page-routing", + "orphan-and-dangling-blocks", + "codegen-pipeline", + "env-vars-and-secrets", + "hosting-and-deploy-config", + "fast-deploy-kv" + ], + "docs": [ + "hosted", + "hosted-publishing", + "hosted-drafts", + "content-delivery", + "hosted-releases-internals", + "studio-implementation" + ], + "pinned": false + }, + { + "id": "roadmap-platform--synchronize-editor-drafts", + "group": "studio", + "title": "Keep editor drafts current automatically", + "plan": "<p>Manage internal draft branches automatically; editors never create branches or rebase. Extend GitHub push intake and existing Studio event delivery: synchronize open drafts in the background, inactive drafts on reopen, and reconcile before saves and publishing. Coalesce updates and retain a slow reconciliation fallback. Use file-level draft wins: compare the draft with its incorporated production tree by blob identity; untouched files adopt production, edited or deleted files keep the entire draft version. Do not merge JSON properties or download conflict bodies. Reuse existing blobs for all file sizes.</p><p>Serialize operations per draft, retry external head races without unconditional force updates, retain append-only history and recoverable incorporated-base metadata, and preserve persistence guards. Bound tree-metadata work and concurrency; schema checks remain in CI. Expose synchronization status and audited resolution summaries without overwriting unsaved form state. Publishing produces only the saved-content diff against current production.</p><p>Use the <a href=\"#studio-implementation\">Studio implementation handoff</a> for module ownership, durable state and APIs, operation sequencing, proposed defaults and acceptance scenarios; completion includes the documented failure and concurrency cases.</p>", + "features": [ + "content-storage-and-delivery", + "dev-studio-content-loop", + "workspace-packages" + ], + "docs": [ + "draft-synchronization", + "hosted-site-editor", + "content-protocol", + "studio-implementation" + ] + }, + { + "id": "roadmap-platform--materialize-exact-draft-previews", + "group": "studio", + "title": "Serve draft overlays over local production", + "plan": "<p>Materialize saved draft changes as private immutable overlay manifests referencing changed-block blobs, with explicit deletion tombstones. Each manifest represents the cumulative draft overrides, not an incremental replay chain. The preview client captures whatever production snapshot it already has; no baseRevision or matching-production download. Fetch only missing changed blobs, share unchanged objects, and key composed views by local production revision plus overlay version. Old pointers fix old overrides but intentionally inherit current local content on later requests.</p><p>Use saved write bodies and cached tree/blob identities; read only changed files during recovery, and handle truncated Git comparisons. Preserve exact overlay authorization, bounded memory, preview readiness and garbage collection of referenced blobs. Missing overlays fail the draft client. Production releases remain complete immutable snapshots.</p><p>Use the <a href=\"#studio-implementation\">Studio implementation handoff</a> for module ownership, sequencing and acceptance scenarios.</p>", + "features": [ + "studio-preview", + "content-storage-and-delivery", + "env-vars-and-secrets" + ], + "docs": [ + "content-delivery", + "hosted-drafts", + "content-protocol", + "studio-implementation" + ] + }, + { + "id": "roadmap-platform--align-the-preview-protocol", + "group": "studio", + "title": "Align the preview protocol", + "plan": "<p>Keep <code>?__draft=off</code> as the one documented way to end a draft preview, and force variants in the draft content. On protocol projects the canvas opens the real page with the draft pointer, and gallery thumbnails and in-place renders are replaced by name cards, since the protocol never runs site code.</p>", + "features": [ + "studio-preview", + "device-detection-and-targeting", + "matchers-and-variants", + "section-catalog", + "theming-and-styling", + "global-layout-sections" + ], + "docs": [ + "hosted-drafts" + ], + "pinned": false + }, + { + "id": "roadmap-platform--drop-legacy-names-from-the-blog-tab-and-experiments", + "group": "studio", + "title": "Drop legacy names from the Blog tab and experiments", + "plan": "<p>Detect the blog from the manifest, and key experiments on their explicit experiment id instead of the random matcher's entry name.</p>", + "features": [ + "studio-workspace-spaces", + "key-prefix-content-collections", + "analytics-trackers" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-platform--label-matchers-from-their-schema-and-keep-enum-titles", + "group": "studio", + "title": "Label matchers from their schema and keep enum titles", + "plan": "<p>Label short-key matchers from their schema, dedupe aliased matchers, and keep oneOf const titles.</p>", + "features": [ + "device-detection-and-targeting", + "matchers-and-variants", + "icon-by-name-enums" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-platform--add-a-per-page-type-catalog-and-a-page-settings-form", + "group": "studio", + "title": "Add a per-page-type catalog and a page settings form", + "plan": "<p>Use the anyOf on a page type's <code>sections</code> list as the site editor's catalog, and render the remaining page props as a form.</p>", + "features": [ + "per-page-section-whitelists", + "page-level-settings-fields" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-platform--bring-studio-s-seo-merge-to-parity-with-the-kit", + "group": "studio", + "title": "Bring the site editor's SEO merge to parity with the kit", + "plan": "<p>Run the site editor's <code>mergeSeo</code> (seo-editor.tsx) against the kit's <code>mergeSiteSeo</code> test suite. The site-wide template applies to titles only: descriptions are used as written, so <code>descriptionTemplate</code> never wraps them.</p>", + "features": [ + "site-seo-defaults", + "page-seo-blocks" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-platform--add-a-content-live-signal", + "group": "studio", + "title": "Add a content-live signal", + "plan": "<p>Every response carries the served revision in <code>x-deco-revision</code>, and the CMS reports each revision it swaps in to the Deco API with the site token. The site editor polls the API and shows a publish as pending until a report for its revision arrives, then live.</p>", + "features": [ + "fast-deploy-kv", + "edge-html-cache-profiles" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-platform--build-the-telemetry-collector", + "group": "studio", + "title": "Build the telemetry collector", + "plan": "<p>An endpoint that accepts aggregated metrics and sampled error logs, authenticated by site ID and site token, for sites that set <code>telemetry: { site, token }</code>. Separately, it receives page views in the One Dollar Stats format from <code>analytics</code> blocks left on the default <code>collector</code>, telling sites apart by hostname and counting only sites connected to the hosted Deco CMS. Switching a site's telemetry off needs no special channel: it's a commit to the site's <code>Telemetry</code> saved block, from the site editor or by hand, like any content edit.</p>", + "features": [ + "otel-observability", + "upstream-fetch-instrumentation", + "analytics-trackers" + ], + "docs": [ + "hosted-telemetry" + ] + }, + { + "id": "roadmap-platform--update-the-injected-agent-rules", + "group": "studio", + "title": "Update the injected agent rules", + "plan": "<p>Replace the two wrong rules for next-major sites. Content edits go only in <code>.deco/blocks/<encodeURIComponent(key)>.json</code>. <code>.deco/index.ts</code> and <code>.deco/blocks</code> are the site's; two files are generated and never edited by hand: <code>.deco/schema.gen.json</code>, which <code>deco schema</code> rewrites after any change to a block's types, and <code>.deco/blocks.gen.ts</code>, which <code>deco content</code> writes and git ignores. A new type needs a block-map entry. <code>remoteLoader</code> picks up prepared releases on its next background check, not on save, and with DECO_SITE and DECO_SITE_TOKEN set the dev server serves the API release, not local edits.</p>", + "features": [ + "ai-agent-instructions" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-docs--resolve-contradictions-in-these-docs", + "group": "docs", + "title": "Resolve contradictions in these docs", + "plan": "<p>Make Page.seo optional (the site's SEO defaults fill in), drop array elements that resolve to <code>undefined</code> everywhere (the site editor's hidden array items), say that a hidden block resolves to <code>undefined</code> and what to render is the app's job (the core has no fallback rule), put the <code>always()</code> variant last, and fix the credential sentence in <a href=\"#hosted-drafts--who-may-preview\">Drafts on your site</a>.</p>", + "features": [ + "cms-page-routing", + "codegen-pipeline", + "key-prefix-content-collections", + "otel-observability", + "cms-navigation-menus", + "denormalized-collection-references", + "product-card", + "matchers-and-variants", + "app-installation-and-store-config", + "block-type-discriminator" + ], + "docs": [ + "api-reference", + "routing", + "schema", + "hosted-releases-internals", + "matchers-and-variants", + "studio-compatibility", + "blocks", + "troubleshooting", + "releases-and-drafts" + ], + "pinned": true + }, + { + "id": "roadmap-docs--correct-studio-compatibility", + "group": "docs", + "title": "Correct Site editor compatibility", + "plan": "<p><a href=\"#studio-compatibility\">Site editor compatibility</a> now describes the content protocol, with the old site endpoints (<code>/live/_meta</code>, <code>/.decofile</code>, <code>/live/previews</code>, <code>/deco/invoke</code>, <code>/live/invoke</code>) as the legacy path. Keep it in step with the protocol as the site editor's implementation lands, and state that sites allow framing through frame-ancestors, never X-Frame-Options, as the entry wrappers do.</p>", + "features": [ + "admin-protocol-endpoints", + "invoke-and-actions", + "csp-security-headers", + "studio-schema-generation" + ], + "docs": [ + "studio-compatibility" + ], + "pinned": true + }, + { + "id": "roadmap-docs--fix-the-tanstack-guides", + "group": "docs", + "title": "Fix the TanStack guides", + "plan": "<p>Mount <code>withDeco()</code> instead of hand-wiring the draft cookie. Add validateSearch/loaderDeps and staleTime, and state that <code>getRequest()</code> is the RPC request on SPA navigation, so the page URL comes from the request scope.</p>", + "features": [ + "worker-server-entry", + "env-vars-and-secrets", + "otel-observability", + "edge-html-cache-profiles", + "home-route-override", + "section-loaders", + "request-context-cookies", + "router-and-client-navigation", + "plp-filters-sort-pagination", + "studio-preview" + ], + "docs": [ + "tanstack-start-descriptors", + "tanstack-start-rsc", + "blocks" + ], + "pinned": false + }, + { + "id": "roadmap-docs--write-the-recipes-that-gate-cutover", + "group": "docs", + "title": "Write the recipes that gate cutover", + "plan": "<p>Write these before the first site migrates: site-wide content and layout (header, footer, theme), site settings, 404/500, route params in blocks, per-shopper reads and actions as server functions (invoke → server fn), and regionalization cache keys.</p>", + "features": [ + "site-config-block", + "global-layout-sections", + "theming-and-styling", + "not-found-and-error-pages", + "route-params-request-to-param", + "site-local-loaders", + "newsletter-subscription", + "client-commerce-state-hooks", + "session-regionalization" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-docs--write-the-post-cutover-recipes", + "group": "docs", + "title": "Write the post-cutover recipes", + "plan": "<p>Page-level data shared by a page's blocks, client data fetching, server-only upstream proxies, overriding an app's block, tabs over pre-resolved data, head tags from blocks, locale, and robots/static SEO files.</p>", + "features": [ + "implicit-page-data-context", + "product-comparison", + "bff-custom-endpoints", + "app-loader-overrides", + "tabbed-shelf-partial", + "head-tags-from-sections", + "i18n-and-currency", + "robots-and-static-seo-files" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-docs--write-a-faststore-to-blocks-guide", + "group": "docs", + "title": "Write a FastStore-to-Blocks guide", + "plan": "<p>Decision: port @faststore/core's UI components (FastStore calls them sections) into the site as vendored code, with no separate storefront UI kit; the commerce hooks they need come from apps-vtex. The guide covers the choice of App Router or TanStack Start (there's no Pages Router path), a core-internals → apps-vtex map, a fragment → loader map and the override → composition recipe.</p>", + "features": [ + "faststore-cli-overlay", + "design-system-library", + "graphql-fragments-codegen", + "native-section-overrides", + "native-platform-sections", + "framework-import-surface", + "workspace-packages", + "site-config-block" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-docs--fix-the-next-guide", + "group": "docs", + "title": "Fix the Next guide", + "plan": "<p>Mount <code>decoProxy()</code> instead of wiring the draft cookie by hand. Wrap <code>client()</code> in <code>cache()</code>, map the full PageSeo and degrade SEO errors instead of throwing, make release routes ISR/static, and cover transpilePackages and outputFileTracingIncludes.</p>", + "features": [ + "global-layout-sections", + "theming-and-styling", + "page-seo-blocks", + "hosting-and-deploy-config", + "bundler-config", + "faststore-cli-overlay", + "edge-html-cache-profiles" + ], + "docs": [ + "nextjs" + ], + "pinned": false + }, + { + "id": "roadmap-docs--write-an-operations-page", + "group": "docs", + "title": "Finish the operations page", + "plan": "<p><a href=\"#telemetry\">Telemetry</a> now covers what's sent and sampling, and <a href=\"#hosted\">The hosted Deco CMS</a> covers connecting a site. Still to add: (1) the DECO_SITE and DECO_SITE_TOKEN lifecycle (issue, rotate, scope) and a one-time warning when createCMS runs in production without them; (2) a per-host table of how the SDK schedules its once-a-minute check on Workers, Node and Vercel; (3) default bounds for every in-isolate cache and how to change them; (4) the 7.x → next env and binding table. Plus a CI job that resolves one block per app package in a Workers production build, to catch dynamic-import failures.</p>", + "features": [ + "env-vars-and-secrets", + "hosting-and-deploy-config", + "in-isolate-caches", + "upstream-fetch-instrumentation", + "otel-observability", + "legacy-runtime-leftovers", + "shopify-autoconfig-workaround" + ], + "docs": [ + "telemetry", + "hosted", + "hosted-telemetry" + ], + "pinned": false + }, + { + "id": "roadmap-docs--add-notes-to-the-rendering-section", + "group": "docs", + "title": "Add notes to the Rendering page", + "plan": "<p>Add four statements to <a href=\"#rendering\">Rendering</a>. Inline <code><script></code> in a streamed boundary runs on parse, before the boundary is revealed, so use effects. A legacy Lazy wrapper is unwrapped, so its block renders normally, with no client-side deferral. UI stores (drawers, minicart) are app code, created per request. Request-scope values are read during resolve and passed as props, because AsyncLocalStorage is gone by the time a streamed boundary renders.</p>", + "features": [ + "interactive-ui-widgets", + "core-patches", + "global-ui-state-store", + "lazy-deferred-sections", + "device-detection-and-targeting" + ], + "docs": [ + "rendering" + ], + "pinned": false + }, + { + "id": "roadmap-docs--fill-in-the-schema-reference-details", + "group": "docs", + "title": "Fill in the schema reference details", + "plan": "<p>Unions to anyOf with const discriminators, intersections and recursion, type resolution through packages, filename-to-entry encoding, and how a variant on a field of type T becomes one multivariate definition per T.</p>", + "features": [ + "conditional-schema-fields", + "cms-navigation-menus", + "app-domain-types", + "block-type-discriminator", + "matchers-and-variants" + ], + "docs": [ + "schema" + ], + "pinned": false + }, + { + "id": "roadmap-later--add-real-user-monitoring", + "group": "later", + "title": "Add real-user monitoring", + "plan": "<p>Real-user monitoring (RUM): what visitors experience in the browser, as a customer-facing alternative to Google Analytics, linked to A/B testing through variant exposure events. It's planned as a follow-up after the first release, not a release blocker, and it's being worked on. Its API isn't designed yet.</p>", + "features": [ + "analytics-trackers", + "ab-traffic-split", + "ecommerce-analytics-events" + ], + "docs": [ + "matchers-and-variants", + "analytics" + ] + }, + { + "id": "roadmap-later--detect-conflicting-edits-in-studio", + "group": "later", + "title": "Detect conflicting edits in the site editor", + "plan": "<p>The site editor's autosave is last-writer-wins in the first version of the protocol. Send each save with <code>ifMatch</code>, the version the form opened with, and show 'changed elsewhere: reload or overwrite' when it conflicts.</p>", + "features": [ + "dev-studio-content-loop", + "content-storage-and-delivery" + ], + "docs": [ + "studio-compatibility" + ] + }, + { + "id": "roadmap-later--serve-the-content-protocol-from-the-dev-server", + "group": "later", + "title": "Serve the content protocol from the dev server", + "plan": "<p>Add a few-line Vite plugin recipe to the templates that mounts the content protocol's handler in the app's own dev server, so editing the files on your machine from the site editor needs one process instead of two. <code>deco serve</code> stays for other setups.</p>", + "features": [ + "dev-studio-content-loop", + "bundler-config" + ], + "docs": [ + "content-protocol" + ] + }, + { + "id": "roadmap-later--add-long-polling-for-faster-local-updates", + "group": "later", + "title": "Add long polling for faster local updates", + "plan": "<p>An optional wait on the protocol's conditional reads, announced in <code>describe</code>, so a hand edit in the working tree shows in the site editor in a fraction of a second instead of on the next 2-second poll. One tab per endpoint would hold the request, so autosaves never queue behind it.</p>", + "features": [ + "dev-studio-content-loop" + ], + "docs": [ + "studio-compatibility" + ] + } + ], + "sites": [ + { + "id": "storefront-tanstack", + "name": "TanStack storefront", + "short": "storefront", + "section": "roadmap-storefront", + "description": "A storefront on TanStack Start and Cloudflare Workers, on the current <code>@decocms</code> 7.x packages.", + "headline": "Closest to the next major. Before it can move, the proposed API needs successors for the pieces it relies on today: the worker entry (the Workers request handler that wraps the app), the edge cache, the page-URL plumbing and the endpoints the site editor calls.", + "steps": [ + { + "id": "roadmap-storefront--fix-dark-metrics-and-deco-invoke-404s", + "kind": "now", + "title": "Fix dark metrics and <code>/deco/invoke</code> 404s", + "text": "<code>createInstrumentedFetch(\"shopify\")</code> has no onComplete, so upstream metrics are dark. Site loaders called through <code>/deco/invoke</code> 404.", + "features": [ + "upstream-fetch-instrumentation", + "site-local-loaders" + ], + "no_action": false + }, + { + "id": "roadmap-storefront--upgrade-7-20-7-to-the-latest-7-x", + "kind": "pre", + "title": "Upgrade 7.20.7 to the latest 7.x", + "text": "The lockfile pins 7.20.7, which still sends <code>X-Frame-Options: SAMEORIGIN</code> and has no <code>?__draft</code> binding. Moving past #390 (7.20.10) and #442 (7.34.0) lets the site editor frame and preview the site today, and leaves one 7 → next step.", + "features": [ + "csp-security-headers", + "worker-server-entry", + "studio-preview" + ], + "no_action": false + }, + { + "id": "roadmap-storefront--keep-studio-s-forms-previews-and-options-working-after-the-move", + "kind": "blocker", + "title": "Keep the site editor's forms, previews and <code>@options</code> working after the move", + "text": "Next-major serves none of <code>/live/_meta</code>, <code>/live/previews</code> or <code>/deco/invoke</code>, and its schema file isn't what the site editor reads. Forms, previews, @options fields and the Loaders tab all stop.", + "features": [ + "admin-protocol-endpoints", + "studio-schema-generation", + "codegen-pipeline" + ], + "no_action": false + }, + { + "id": "roadmap-storefront--drop-apps-shopify-s-decocms-tanstack-dependency-and-document-its-sdk-imports", + "kind": "blocker", + "title": "Drop <code>apps-shopify</code>'s <code>@decocms/tanstack</code> dependency and document its SDK imports", + "text": "It imports <code>@decocms/blocks/sdk/{fetchTimeout,instrumentedFetch}</code> and depends on <code>@decocms/tanstack</code>.", + "features": [ + "framework-import-surface", + "commerce-platform-binding" + ], + "no_action": false + }, + { + "id": "roadmap-storefront--keep-the-edge-cache-and-a-b-split-without-createdecoworkerentry", + "kind": "blocker", + "title": "Keep the edge cache and A/B split without <code>createDecoWorkerEntry</code>", + "text": "Workers Cache is on, with segment keys and degraded-page protection. The SITES_KV traffic split has no home.", + "features": [ + "edge-html-cache-profiles", + "ab-traffic-split" + ], + "no_action": false + }, + { + "id": "roadmap-storefront--keep-the-page-url-on-spa-navigation-plp-search-pdp", + "kind": "blocker", + "title": "Keep the page URL on SPA navigation (PLP, search, PDP)", + "text": "Today's <code>x-deco-page-url</code> and <code>derivePageUrl</code> workarounds have no successor.", + "features": [ + "section-loaders", + "plp-filters-sort-pagination" + ], + "no_action": false + }, + { + "id": "roadmap-storefront--do-the-mechanical-port", + "kind": "work", + "title": "Do the mechanical port", + "text": "Hand-write the block map (27 UI blocks, 12 loaders) with the path-shaped keys the site editor needs, add the ~8-line requestToParam shim (app code), and replace Image/Picture (~20 files). The content fixes below register types into this map.", + "features": [ + "site-bootstrap", + "section-registration-conventions", + "image-optimization", + "route-params-request-to-param" + ], + "no_action": false + }, + { + "id": "roadmap-storefront--fix-the-content-before-cutover", + "kind": "content", + "title": "Fix the content before cutover", + "text": "Rewrite the Category page path <code>/*</code> to <code>/:collection</code>, guard the PDP's <code>seo: null</code> and hoist SEO blocks out of <code>sections[]</code>. Register in the block map, or remove, ~20 unknown types, including the <code>resolved</code> built-in that Header.json's searchbar uses. Fix the Newsletter and Header drift, and hold MIGRATION_NEXT_STEPS' <code>*Config</code> Props regrouping until a content validator exists.", + "features": [ + "cms-page-routing", + "commerce-seo-sections-in-sections", + "orphan-and-dangling-blocks", + "content-schema-drift" + ], + "no_action": false + }, + { + "id": "roadmap-storefront--unwrap-lazy-wrappers-and-stop-duplicate-loads", + "kind": "content", + "title": "Unwrap Lazy wrappers and stop duplicate loads", + "text": "The alias bridge unwraps its 43 Lazy wrappers (21 with inline loaders): each wrapped block renders normally, with no client-side deferral. 'PLP Loader' is referenced twice per page and needs the per-client memo.", + "features": [ + "lazy-deferred-sections", + "inline-loader-props", + "saved-loader-blocks" + ], + "no_action": false + } + ] + }, + { + "id": "blog-tanstack", + "name": "TanStack blog", + "short": "blog", + "section": "roadmap-blog", + "description": "A blog on TanStack Start and Cloudflare Workers, on an older single-package version of the framework.", + "headline": "Simple content and a clean route tree, but it's on an older package: it upgrades to 7.x first (until then, the site editor can't open it in its preview frame), and its site editor blog workflow depends on old loader names and <code>apps-blog</code>.", + "steps": [ + { + "id": "roadmap-blog--upgrade-6-12-to-the-latest-7-x-first", + "kind": "pre", + "title": "Upgrade 6.12 to the latest 7.x first", + "text": "Fix <code>upgrade-6-to-7</code> for the blog's specifiers (~45 symbols across 13 subpaths), then run it. That also drops <code>X-Frame-Options: SAMEORIGIN</code> and adds <code>?__draft</code>, so the site editor can frame the site. The move to next is then the same 7 → next step as the storefront's.", + "features": [ + "framework-import-surface", + "codegen-pipeline", + "csp-security-headers", + "worker-server-entry" + ], + "no_action": false + }, + { + "id": "roadmap-blog--untie-the-blog-tab-from-legacy-names-and-apps-blog", + "kind": "blocker", + "title": "Untie the Blog tab from legacy names and <code>apps-blog</code>", + "text": "It keys off <code>blog/loaders/*.ts</code> and <code>@decocms/apps-blog</code> ≥ 7.53.0, but the site is on <code>@decocms/apps</code> 5.2.0. Its meta sits at <code>src/server/admin/meta.gen.json</code> with Windows-path keys, and <code>setInvokeLoaders</code> exists only for the site editor.", + "features": [ + "key-prefix-content-collections", + "studio-schema-generation", + "admin-protocol-endpoints", + "app-domain-types" + ], + "no_action": false + }, + { + "id": "roadmap-blog--stop-blog-loaders-reading-the-global-decofile", + "kind": "blocker", + "title": "Stop blog loaders reading the global decofile", + "text": "apps-blog's <code>getRecordsByPath</code> uses <code>loadBlocks()</code>, so under a pure CMS drafts read the wrong revision. It needs the request scope's <code>client</code>, or the lookups move to route code.", + "features": [ + "key-prefix-content-collections", + "inline-loader-props", + "page-seo-blocks" + ], + "no_action": false + }, + { + "id": "roadmap-blog--keep-the-live-publish-path", + "kind": "blocker", + "title": "Keep the live publish path", + "text": "<code>regen-blocks.yml</code> (site editor publish, then a regen commit, then a Workers Build) is how content ships today. Keep it until the release service and <code>remoteLoader</code> exist.", + "features": [ + "ci-and-release-workflows", + "content-storage-and-delivery" + ], + "no_action": false + }, + { + "id": "roadmap-blog--return-real-404s-for-unknown-urls-and-stop-routing-404-publicly", + "kind": "content", + "title": "Return real 404s for unknown URLs and stop routing <code>/404</code> publicly", + "text": "The 7 pages form a valid trie, but unknown URLs are soft 200s via <code>/:slug</code> and <code>/404</code> is publicly routable.", + "features": [ + "not-found-and-error-pages", + "cms-page-routing" + ], + "no_action": false + }, + { + "id": "roadmap-blog--alias-legacy-types-and-render-body-blocks", + "kind": "content", + "title": "Alias legacy types and render body blocks", + "text": "25 legacy types (210 occurrences) need aliases, and body blocks become a data-only block with a renderer.", + "features": [ + "block-type-discriminator", + "post-body-block-renderer" + ], + "no_action": false + }, + { + "id": "roadmap-blog--handle-images-the-edge-profile-and-app-config", + "kind": "work", + "title": "Handle images, the edge profile and app config", + "text": "The blog uses plain <code><img></code>, no edge profile fits posts, and app config goes through apps-blog.", + "features": [ + "image-optimization", + "edge-html-cache-profiles", + "app-installation-and-store-config" + ], + "no_action": false + }, + { + "id": "roadmap-blog--two-current-bugs-go-away-with-the-migration", + "kind": "fix", + "title": "Two current bugs go away with the migration", + "text": "One is in the layout cache, the other in the build-time site-globals snapshot, which never reaches draft preview. The migration replaces both.", + "features": [ + "in-isolate-caches", + "build-time-site-globals-snapshot" + ], + "no_action": true + } + ] + }, + { + "id": "faststore", + "name": "Next.js storefront", + "short": "Next.js store", + "section": "roadmap-faststore", + "description": "A Next.js storefront on another CMS. Not on Deco today, so this is a re-platform, not an upgrade.", + "headline": "A re-platform, not an upgrade. The target framework, the content, FastStore's native sections and the session model all come before the missing pieces in the proposed API even come into play.", + "steps": [ + { + "id": "roadmap-faststore--pick-the-target-framework-and-move-to-it", + "kind": "blocker", + "title": "Pick the target framework and move to it", + "text": "Pick the target first: the App Router (the only guide; the Pages Router has none) or TanStack Start. It decides how FastStore's native sections get rebuilt. Its code imports <code>@faststore/core</code> internals, and most of its hook files, and the files that use its translation hook, lack <code>\"use client\"</code>.", + "features": [ + "faststore-cli-overlay", + "framework-import-surface", + "design-system-library", + "i18n-and-currency" + ], + "no_action": false + }, + { + "id": "roadmap-faststore--bring-the-content-in-from-vtex-headless-cms", + "kind": "blocker", + "title": "Bring the content in from VTEX Headless CMS", + "text": "The content lives in VTEX Headless CMS. The Headless CMS-to-<code>.deco/blocks</code> importer is handled outside this roadmap, the JSONC schemas must become TS types, and per-page-type whitelists of allowed blocks and singletons have no TS or site editor expression.", + "features": [ + "faststore-cli-overlay", + "content-storage-and-delivery", + "block-type-discriminator", + "per-page-section-whitelists", + "page-template-targeting" + ], + "no_action": false + }, + { + "id": "roadmap-faststore--rebuild-what-faststore-core-renders", + "kind": "blocker", + "title": "Rebuild what <code>@faststore/core</code> renders", + "text": "Its native sections (FastStore's UI components), including the stateful cart, search and session ones, the global ones (header, footer) and the 404/500 pages. No UI kit exists.", + "features": [ + "native-platform-sections", + "native-section-overrides", + "global-layout-sections" + ], + "no_action": false + }, + { + "id": "roadmap-faststore--give-apps-vtex-session-region-and-delivery-promise", + "kind": "blocker", + "title": "Give <code>apps-vtex</code> session, region and delivery promise", + "text": "Without a populated request scope, regionalized Intelligent Search queries also silently fall back to the default region.", + "features": [ + "session-regionalization", + "delivery-promise", + "commerce-platform-binding" + ], + "no_action": false + }, + { + "id": "roadmap-faststore--rewrite-the-data-shapes-to-schema-org", + "kind": "content", + "title": "Rewrite the data shapes to schema.org", + "text": "FastStore GraphQL moves to apps-commerce schema.org: the PDP view model, the product card, the BFF fields, tax prices and installments.", + "features": [ + "code-composed-pdp", + "product-card", + "graphql-schema-extensions", + "bff-custom-endpoints" + ], + "no_action": false + }, + { + "id": "roadmap-faststore--port-client-state-and-the-core-patches", + "kind": "work", + "title": "Port client state and the core patches", + "text": "The useSearch state machine and the client fetches become server functions. The behaviours the core patches add are re-created as app code.", + "features": [ + "plp-filters-sort-pagination", + "graphql-fragments-codegen", + "core-patches" + ], + "no_action": false + }, + { + "id": "roadmap-faststore--rename-componentkey-values-to-path-shaped-keys-and-avoid-type-name-collisions", + "kind": "fix", + "title": "Rename <code>$componentKey</code> values to path-shaped keys and avoid type-name collisions", + "text": "There's no legacy Deco content, so no legacy-type aliases. The content import (handled outside this roadmap) must still rename <code>$componentKey</code> values (Navbar, Footer, Alert…) to path-shaped keys until the site editor does manifest lookups, and avoid entry names that collide with type names. The content protocol and schema fixes ({{item:roadmap-api--publish-the-content-protocol-and-its-package}}, {{item:roadmap-cli--write-a-studio-compatible-schema-file}}) gate editing.", + "features": [ + "studio-schema-generation", + "studio-preview", + "block-type-discriminator" + ], + "no_action": false + } + ] + } + ], + "categories": [ + { + "id": "routing", + "title": "Routing", + "description": "URL routing, page matching, route params, not-found handling, non-CMS routes, redirects and client navigation." + }, + { + "id": "content-model", + "title": "Content model", + "description": "How content is shaped: block types, saved entries (named or global), collections, props, rich text, editor widgets, drift and targeting rules." + }, + { + "id": "sections", + "title": "UI blocks", + "description": "Registering blocks that render UI, the site editor's block catalog, overrides of platform-native components, and lazy, deferred or viewport-gated rendering." + }, + { + "id": "data", + "title": "Data", + "description": "Loaders, query props, legacy section loaders (server-side prop enrichment), invoke/actions/BFF endpoints and search, plus site overrides of app loaders." + }, + { + "id": "commerce", + "title": "Commerce", + "description": "Commerce platform bindings (Shopify, VTEX), cart, session, regionalization, product merchandising and commerce-specific GraphQL." + }, + { + "id": "seo", + "title": "SEO", + "description": "Page and site SEO, head tags, JSON-LD, sitemap and robots." + }, + { + "id": "interactivity", + "title": "Interactivity", + "description": "Client-side UI behaviour and client state: widgets, tabs, PLP navigation, client commerce hooks and UI stores." + }, + { + "id": "caching", + "title": "Caching", + "description": "Edge/HTML cache profiles, in-isolate and client caches, and request context (cookies, headers, device)." + }, + { + "id": "studio", + "title": "Site editor", + "description": "Admin/site editor protocol, preview, schema generation or upload, and the local dev-to-site-editor content loop." + }, + { + "id": "config", + "title": "Config", + "description": "Site bootstrap, site and app config blocks, app autoconfig, environment variables and secrets." + }, + { + "id": "runtime-deploy", + "title": "Runtime & deploy", + "description": "Worker/server entry, security headers, traffic splitting, hosting, content storage and delivery, and CI/release." + }, + { + "id": "build", + "title": "Build", + "description": "Codegen, bundler config, framework import surface, patches, dev tooling, tests and repo hygiene." + }, + { + "id": "design", + "title": "Design", + "description": "Theming, fonts, icons, images, design-system libraries and i18n." + }, + { + "id": "observability-analytics", + "title": "Observability & analytics", + "description": "OTel and logging, upstream fetch instrumentation, analytics trackers and e-commerce events." + } + ], + "features": { + "cms-page-routing": { + "name": "CMS pages matched by URL pattern", + "category": "routing", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Paths take only literal segments and single-segment params, so the storefront's `/*` Category page never matches. One site editor commit can make `matchRoute` throw on every request, and `Page.seo` is required while stored content holds `seo: null`.", + "docs": [ + "routing", + "router-internals", + "renames-and-migrations", + "api-reference", + "nextjs", + "tanstack-start-descriptors", + "studio-compatibility", + "releases-and-deployment" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "home-route-override": { + "name": "Home route override (search params, page-URL header, client preload)", + "category": "routing", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack" + ], + "summary": "The guide's `/$` route has no validateSearch or loaderDeps, and on `<Link>` navigation `getRequest()` returns the `/_serverFn/...` RPC URL, so search params and the page URL never reach blocks.", + "docs": [ + "tanstack-start-descriptors", + "router-internals", + "blocks", + "nextjs" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "route-params-request-to-param": { + "name": "Route params bound via requestToParam", + "category": "routing", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "The CMS never injects `match.params`, so every migrated site repeats the same ~8-line AsyncLocalStorage shim, and every `resolve()` must start inside it. The guides don't show it.", + "docs": [ + "routing", + "blocks", + "design-decisions", + "renames-and-migrations" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "not-found-and-error-pages": { + "name": "Not-found and error pages", + "category": "routing", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Only URL-level not-found works as documented. Unknown single-segment URLs match a template and stream as cacheable soft 200s, and there's no story for CMS-authored 404 and 500 pages.", + "docs": [ + "routing", + "nextjs", + "tanstack-start-descriptors", + "rendering" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "non-cms-routes-and-account-pages": { + "name": "Code-only routes, account/login pages and external checkout", + "category": "routing", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack", + "faststore" + ], + "summary": "Code routes stay in the framework's router, so account and login pages are app code. After FastStore, `/pvt/account` falls into the CMS catch-all and 404s unless the account link points at the VTEX-hosted account.", + "docs": [ + "design-decisions", + "routing" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "router-and-client-navigation": { + "name": "Router setup, Link and client navigation", + "category": "routing", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "`createDecoRouter` carries three behaviours these docs never mention: URLSearchParams search serializers, the CSP nonce, and scroll and intent defaults. The TanStack guide never appends the draft cookie, so preview is lost on the first in-frame `<Link>` click.", + "docs": [ + "tanstack-start-descriptors", + "nextjs", + "releases-and-drafts", + "renames-and-migrations" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "pwa-offline-static-assets": { + "name": "Offline page, service worker and public static assets", + "category": "routing", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack" + ], + "summary": "Static assets are served before the CMS catch-all and `/offline` is an ordinary Route entry; registering `sw.js` and the web manifest takes about 3 lines. Any CSP must allow the Workbox import, and `/offline` must never be a draft render.", + "docs": [ + "routing" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "redirects": { + "name": "Redirects and route handlers", + "category": "routing", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack", + "faststore" + ], + "summary": "`/old/*` globs are impossible with single-segment params, the site editor's 307 has no counterpart (only 301 or 302), and query, case and duplicate-`from` handling are unspecified. A naive legacy alias doesn't match the nested shape the site editor stores.", + "docs": [ + "routing", + "router-internals", + "nextjs", + "tanstack-start-descriptors", + "studio-compatibility", + "renames-and-migrations" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "page-template-targeting": { + "name": "PDP/PLP templates targeted by product slug or category path", + "category": "routing", + "status": "to-finish", + "effort": "L", + "sites": [ + "faststore" + ], + "summary": "The entries live in VTEX Headless CMS, so converting them to `.deco/blocks` comes first. There's no multi-segment catch-all, so category trees need one entry per depth, and [Routing](#routing) and [Router internals](#router-internals) disagree on when two templates are ambiguous.", + "docs": [ + "routing", + "router-internals", + "api-reference" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "block-type-discriminator": { + "name": "Block type naming (__resolveType / $componentKey)", + "category": "content-model", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Aliases are documented, but the filename-to-entry-name rule isn't (the site editor writes every key through `encodeURIComponent`), the site editor still classifies keys by their path shape, and an unregistered legacy type now fails its parent block with UNKNOWN_BLOCK.", + "docs": [ + "quickstart", + "blocks", + "renames-and-migrations", + "tanstack-start-descriptors", + "studio-compatibility", + "design-decisions" + ], + "confidence": "high", + "first_rated": "done", + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "global-layout-sections": { + "name": "Global/shared layout blocks (header, footer)", + "category": "content-model", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Saved entries with references cover the two TanStack sites, but FastStore's global singletons have no equivalent. A layout and its page can get two clients on different revisions, and Lazy-wrapped headers and footers are unwrapped and render with no client-side deferral.", + "docs": [ + "blocks", + "caching", + "nextjs", + "releases-and-deployment", + "rendering", + "design-decisions" + ], + "confidence": "medium", + "first_rated": "done", + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "saved-loader-blocks": { + "name": "Saved loader blocks and commerce extension wrappers", + "category": "content-model", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack" + ], + "summary": "Per-request dedupe is only implied: no cache key, and nothing on shared in-flight promises. Pages that reference 'PLP Loader' or 'PDP Loader' twice would double their Shopify calls, and a failing loader now fails its whole block.", + "docs": [ + "blocks", + "routing", + "api-reference", + "troubleshooting", + "studio-compatibility" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "orphan-and-dangling-blocks": { + "name": "Unreferenced blocks and dangling references", + "category": "content-model", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "A dangling reference used to return null; now it fails the parent block, and one in `seo` throws. There's no validation tool, and one reading of the release rule would skip every storefront release because of ~20 unregistered types.", + "docs": [ + "blocks", + "api-reference", + "renames-and-migrations", + "releases-and-deployment", + "rendering" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "content-schema-drift": { + "name": "Content drift from current props / schemas", + "category": "content-model", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Stored inputs pass through unvalidated, and no documented tool does the 'validate saved content' step. Drift is already live: the storefront's Newsletter content stores a flat shape its Props no longer read.", + "docs": [ + "schema", + "api-reference", + "renames-and-migrations", + "releases-and-deployment" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "rich-text-html-props": { + "name": "Rich text / raw HTML props", + "category": "content-model", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Only `@format rich-text` is documented; `html`, `rich-text-inline`, `textarea`, `markdown`, alias-name detection and sanitization aren't, and FastStore's Lexical fields need converting.", + "docs": [ + "routing", + "schema", + "rendering" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": "done", + "unconfirmed_sub_claim": false + }, + "nested-section-props": { + "name": "Block-typed props (nested blocks)", + "category": "content-model", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack" + ], + "summary": "The rule is documented: a field typed as the resolved value (`product: Product`, `sections: ReactNode[]`) takes any block whose function returns that type (`T` or `Promise<T>`). The CLI doesn't emit block pickers from return types yet; today's schema uses the site editor's legacy `__SECTION_REF__`. Nested blocks also resolve eagerly, with no Suspense boundary of their own.", + "docs": [ + "blocks", + "rendering", + "api-reference" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "key-prefix-content-collections": { + "name": "Content collections read by key-prefix scan", + "category": "content-model", + "status": "to-finish", + "effort": "M", + "sites": [ + "blog-tanstack" + ], + "summary": "Block functions get no access to the content they're resolved against, so apps-blog's loaders, which read the global `loadBlocks()`, must be rewritten around a client the app provides.", + "docs": [ + "api-reference", + "schema", + "blocks", + "routing" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "denormalized-collection-references": { + "name": "Author/category collections with denormalized copies", + "category": "content-model", + "status": "site-code", + "effort": "S", + "sites": [ + "blog-tanstack" + ], + "summary": "The in-memory join is trivial for 4 authors and 5 categories, but it depends on the same unsolved client access as key-prefix collections.", + "docs": [ + "api-reference", + "blocks", + "schema" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "post-body-block-renderer": { + "name": "Nested post-body blocks rendered by a site map", + "category": "content-model", + "status": "site-code", + "effort": "S", + "sites": [ + "blog-tanstack" + ], + "summary": "Listing posts with `run: true`, or resolving one, turns every body block into UNKNOWN_BLOCK and fails the post; registering the types as functions loses the TOC's raw input. Rendering the stored blocks from a site-owned map works.", + "docs": [ + "blocks", + "api-reference" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "per-page-section-whitelists": { + "name": "Per-page-type block whitelists", + "category": "content-model", + "status": "to-build", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "A page's `sections: ReactNode[]` takes every block function that returns JSX; limiting a page type to some of them isn't designed yet. The site editor's add-section catalog (its legacy name) is the union of all UI blocks anyway.", + "docs": [ + "routing", + "api-reference", + "schema", + "studio-compatibility" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "page-level-settings-fields": { + "name": "Page-level settings outside the block list", + "category": "content-model", + "status": "to-finish", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "The site editor edits only a page's name, path, SEO and block list, so typed page settings get no form. Only one page type can carry the `website/pages/Page.tsx` alias.", + "docs": [ + "routing", + "schema", + "studio-compatibility" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "cms-navigation-menus": { + "name": "CMS-authored navigation and menus", + "category": "content-model", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Navigation is a data-only block, so a block can reference a saved menu entry. Nothing says whether `deco schema` handles the intersections and recursion in `SiteNavigationElement`.", + "docs": [ + "blocks", + "routing", + "schema" + ], + "confidence": "medium", + "first_rated": "done", + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "conditional-schema-fields": { + "name": "Discriminated-union fields (dependencies + oneOf)", + "category": "content-model", + "status": "to-finish", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "Union handling isn't documented, so this relies on today's generator carrying over (literals to const, object unions to anyOf). JSON Schema `dependencies` has no TS form, and the array constraint tags aren't listed.", + "docs": [ + "schema", + "quickstart" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "editor-widgets-and-image-fields": { + "name": "Editor widgets and image/media fields", + "category": "content-model", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Image and HTML fields depend on type-alias names these docs never mention. If `deco schema` drops alias detection, all 22 image pickers and the HTML editors become plain text inputs, and the widget vocabulary isn't listed.", + "docs": [ + "quickstart", + "routing", + "schema" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "icon-by-name-enums": { + "name": "Icon-by-name enums and code-embedded logo", + "category": "design", + "status": "to-finish", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "Union-to-enum, `@format` passthrough and widget tags aren't documented, so icon and image fields may degrade to text, and enum display labels can't be expressed. The content must first be exported from VTEX.", + "docs": [ + "quickstart", + "routing", + "blocks", + "studio-compatibility", + "renames-and-migrations" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "matchers-and-variants": { + "name": "Matchers, variants and personalization", + "category": "content-model", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Every variant resolves, so a block the site editor hides with a never rule still runs its loaders, and its `undefined` renders 'temporarily unavailable'. Device and random matchers no longer ship, and which site editor matcher paths get aliases is unstated.", + "docs": [ + "matchers-and-variants", + "studio-compatibility", + "tanstack-start-descriptors" + ], + "confidence": "medium", + "first_rated": "site-code", + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "per-path-section-visibility": { + "name": "Per-path block visibility", + "category": "content-model", + "status": "site-code", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "This is component logic. A server-side check in a Next layout won't re-evaluate on client navigation, and `router.asPath` includes the query string, so compare pathnames.", + "docs": [ + "blocks", + "matchers-and-variants", + "rendering" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "device-detection-and-targeting": { + "name": "Device detection and device targeting", + "category": "caching", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Detection is app code, but device as a cache-key dimension, hydration of request-derived values and the site editor's device and variant preview are unaddressed. The site editor's variant tabs can't force a variant through the built-in multivariate.", + "docs": [ + "matchers-and-variants", + "design-decisions", + "blocks", + "tanstack-start-descriptors", + "studio-compatibility", + "renames-and-migrations" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "section-registration-conventions": { + "name": "UI block registration and file conventions", + "category": "sections", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Registration works, but none of the 8 legacy convention flags or section loaders has a home, so each site rebuilds the ~737-line DecoPageRenderer. And `getRequest()` gives URL-dependent loaders the wrong URL on SPA navigation.", + "docs": [ + "blocks", + "tanstack-start-descriptors", + "rendering", + "renames-and-migrations", + "caching", + "api-reference", + "studio-compatibility", + "design-decisions" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "section-catalog": { + "name": "Block catalog exposed to editors (the site editor's section catalog)", + "category": "sections", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "The catalog works for legacy path-keyed (site/sections/…) blocks, but thumbnails and in-place render go through `/live/previews`, which these docs don't define. The alias bridge skips Lazy and the Seo* types, and a JSX type can't be kept out of the catalog.", + "docs": [ + "studio-compatibility", + "schema", + "routing", + "api-reference", + "renames-and-migrations", + "design-decisions" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "native-section-overrides": { + "name": "Overrides of platform-native components", + "category": "sections", + "status": "to-finish", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "Overrides collapse into plain composition, but the core components, contexts and SDK hooks they rely on must be rebuilt. There's no page-level data context, and keys like 'Footer' can collide with saved-entry names.", + "docs": [ + "blocks", + "rendering", + "renames-and-migrations", + "troubleshooting", + "design-decisions" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "native-platform-sections": { + "name": "Native platform components used via schema only", + "category": "sections", + "status": "to-build", + "effort": "L", + "sites": [ + "faststore" + ], + "summary": "Nothing in the next major renders UI without site code, and UI stays out of `@decocms/apps`. Replacing the core components, the stateful ones included, is real engineering, and FastStore's global-section (its term) and 404/500 content types have no layout pattern.", + "docs": [ + "design-decisions", + "api-reference", + "studio-compatibility", + "routing" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "code-composed-pdp": { + "name": "Code-composed monolithic PDP block", + "category": "sections", + "status": "to-finish", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "The block-function part works, but a PDP block has no documented way to get route params, and it can't signal a 404 once a template route matches. Its view model also parses FastStore GraphQL shapes.", + "docs": [ + "rendering", + "blocks", + "routing", + "api-reference", + "nextjs", + "troubleshooting" + ], + "confidence": "medium", + "first_rated": "done", + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "product-card": { + "name": "Product card organism and CMS card config", + "category": "commerce", + "status": "to-finish", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "Server-side product inputs and typed card config work as documented, but apps-vtex has no tax-inclusive prices, full installment ladders or delivery badges", + "docs": [ + "blocks", + "schema", + "rendering", + "routing", + "studio-compatibility" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "lazy-deferred-sections": { + "name": "Lazy / deferred / viewport-gated blocks", + "category": "sections", + "status": "to-finish", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "A legacy Lazy wrapper's `section` input resolves fully before Lazy runs, so Lazy defers nothing (43 wrappers on the storefront). The alias bridge unwraps legacy Lazy/SingleDeferred/Deferred wrappers, so the wrapped block renders normally, with no client-side deferral; today's per-prop deferral has no counterpart, and none is planned. Viewport-gated second hops need a revision the client doesn't expose.", + "docs": [ + "blocks", + "rendering", + "tanstack-start-descriptors", + "matchers-and-variants", + "releases-and-deployment", + "api-reference", + "design-decisions" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "section-loaders": { + "name": "Prop-enrichment loaders (legacy section loaders)", + "category": "data", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "The documented request sources don't give blocks the page URL: on TanStack SPA navigation `getRequest()` returns the `/_serverFn` URL, Next's `headers()` has none, and `match.params` has no path to blocks or app packages.", + "docs": [ + "blocks", + "rendering", + "tanstack-start-descriptors", + "nextjs", + "routing", + "caching", + "studio-compatibility", + "renames-and-migrations" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": true + }, + "tabbed-shelf-partial": { + "name": "Tabbed shelf / server partial re-render", + "category": "interactivity", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack" + ], + "summary": "Client-side tabs over pre-resolved data are one `useState`. Fetching only the active tab would need a server partial re-render and a re-resolve at a pinned revision, which the proposed API lacks.", + "docs": [ + "blocks", + "matchers-and-variants", + "rendering", + "tanstack-start-descriptors", + "nextjs", + "renames-and-migrations", + "api-reference", + "studio-compatibility" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": true + }, + "inline-loader-props": { + "name": "Loaders and query descriptors in block props", + "category": "data", + "status": "to-finish", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "There's no page-URL or search-param injection, blocks get no client (so apps-blog reads the wrong revision in preview), and both the rule for which loaders a field accepts and the memo key are unspecified. 21 of the storefront's inline loaders sit inside Lazy wrappers.", + "docs": [ + "blocks", + "studio-compatibility", + "schema", + "api-reference", + "troubleshooting", + "renames-and-migrations", + "rendering" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": true + }, + "app-loader-overrides": { + "name": "Site overrides of app/platform loaders", + "category": "data", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Overriding a key is plain JS, but none of the 3 real overrides ports on the proposed API alone. There's no notion of app packages, and no rule says whose schema wins when a site overrides an app's key.", + "docs": [ + "blocks", + "renames-and-migrations", + "schema" + ], + "confidence": "high", + "first_rated": "done", + "rated_before_review": null, + "unconfirmed_sub_claim": true + }, + "site-local-loaders": { + "name": "Site-local loaders called from the client", + "category": "data", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack" + ], + "summary": "No framework feature is needed, and the migration fixes a live bug: `/deco/invoke` never finds the site's user and wishlist loaders, so those calls 404 and throw. They become server functions.", + "docs": [ + "blocks", + "tanstack-start-descriptors", + "caching", + "routing" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "invoke-and-actions": { + "name": "Actions, invoke endpoint and client invoke", + "category": "data", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "There's no invoke endpoint, and the plan drops it: the site editor calls `/deco/invoke` for @options fields, Run and the product picker, and `/live/invoke` to encrypt secrets. On protocol projects pickers fall back to schema options and Run is hidden.", + "docs": [ + "studio-compatibility", + "internals", + "caching", + "api-reference", + "tanstack-start-descriptors", + "upstream-clients", + "content-protocol" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "bff-custom-endpoints": { + "name": "BFF: custom GraphQL fields proxying platform REST", + "category": "data", + "status": "site-code", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "Nothing framework-level is needed; API routes belong to the framework. Each handler sets Cache-Control itself once `@cacheControl` goes.", + "docs": [ + "routing", + "blocks", + "caching", + "nextjs" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "implicit-page-data-context": { + "name": "Route page data via page context", + "category": "data", + "status": "site-code", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "Routing and looking entries up by name is allowed, but the provider data moves from FastStore fragments to schema.org types. Server block functions can't read client React context, so sharing one fetch needs React cache or an app-owned AsyncLocalStorage.", + "docs": [ + "routing", + "nextjs" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "plp-filters-sort-pagination": { + "name": "PLP/search filters, sort and pagination", + "category": "interactivity", + "status": "to-finish", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "On SPA navigation blocks see the RPC URL, `/*` never matches, and without loaderDeps the loader doesn't re-run. Show-more needs `/deco/invoke` and a revision, the Lax draft cookie is rejected in the site editor's iframe, and FastStore's search state machine must be rebuilt.", + "docs": [ + "blocks", + "tanstack-start-descriptors", + "nextjs", + "router-internals", + "releases-and-deployment", + "api-reference", + "matchers-and-variants", + "troubleshooting" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "site-search-and-autocomplete": { + "name": "Site search and autocomplete", + "category": "data", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Nothing matches `Resolved<T>`: Suggestions keeps its loader unresolved and invokes it with `query`, which an eagerly resolved inline block can't do. `resolved` isn't in the alias bridge, and apps-shopify has no suggestions loader.", + "docs": [ + "blocks", + "matchers-and-variants", + "api-reference", + "tanstack-start-descriptors" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "graphql-fragments-codegen": { + "name": "FastStore GraphQL fragment overrides and codegen", + "category": "commerce", + "status": "goes-away", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "FastStore tooling with no counterpart once @faststore/api and core are gone. Persisted-query hashes acted as an allowlist, so each replacement server function needs explicit input validation.", + "docs": [ + "blocks", + "api-reference", + "rendering", + "tanstack-start-descriptors" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "graphql-schema-extensions": { + "name": "GraphQL schema extensions on product/offer", + "category": "commerce", + "status": "to-finish", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "A wrapper block can compute the fields, but apps-vtex's lean offers drop the full installment list and interest rates that custom price fields may need. The BFF's per-field cache policy must be restated per server function.", + "docs": [ + "rendering", + "blocks", + "caching" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": true + }, + "vtex-io-app-settings": { + "name": "Merchandising data from VTEX IO app settings", + "category": "commerce", + "status": "site-code", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "A server endpoint plus a client hook, but apps-vtex has no persisted-query client for `/_v/public/graphql/v1`, so the site's IO client still needs porting, and it's unclear what the app should cache with.", + "docs": [ + "blocks", + "caching", + "matchers-and-variants", + "routing", + "api-reference" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "commerce-platform-binding": { + "name": "Commerce platform binding", + "category": "commerce", + "status": "to-finish", + "effort": "L", + "sites": [ + "storefront-tanstack", + "faststore" + ], + "summary": "Loaders as block functions and legacy aliases are documented, but not what apps need from the runtime: apps-* import `@decocms/blocks/sdk/*` and depend on `@decocms/tanstack`, and no guide populates `RequestContext`, so apps-vtex's region and cookies silently turn off.", + "docs": [ + "blocks", + "routing", + "router-internals", + "renames-and-migrations", + "design-decisions", + "tanstack-start-descriptors", + "studio-compatibility", + "api-reference", + "nextjs" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "shopify-autoconfig-workaround": { + "name": "Shopify app autoconfig static-module workaround", + "category": "config", + "status": "goes-away", + "effort": "S", + "sites": [ + "storefront-tanstack" + ], + "summary": "There's no registry left to patch. The package bug is still open and its root cause was never established, so in-body dynamic imports need checking in a production Workers build.", + "docs": [ + "design-decisions", + "blocks", + "api-reference", + "troubleshooting" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "cart-customer-server-functions": { + "name": "Cart and customer via server functions", + "category": "commerce", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack" + ], + "summary": "The four cart server functions touch neither the CMS nor `/deco/invoke`. They break at import time, though, if apps-shopify's `@decocms/blocks/sdk` imports and its `@decocms/tanstack` dependency don't survive.", + "docs": [ + "caching", + "tanstack-start-descriptors", + "blocks" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "client-commerce-state-hooks": { + "name": "Client commerce state hooks and SSR prefetch", + "category": "interactivity", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack" + ], + "summary": "The hooks and their SSR prefetch stay app code.", + "docs": [ + "caching", + "blocks", + "rendering", + "tanstack-start-descriptors" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "cart-and-minicart": { + "name": "Add to cart, minicart and checkout handoff", + "category": "commerce", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "faststore" + ], + "summary": "The proposed API has no cart; the Next guide's CartButton is a counter demo. The invoke-based cart is TanStack-only, the VTEX checkout proxy lives in `createDecoWorkerEntry`, and FastStore's cart rules must be rebuilt by hand.", + "docs": [ + "rendering", + "caching", + "routing" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "session-regionalization": { + "name": "Session and regionalization", + "category": "commerce", + "status": "to-build", + "effort": "L", + "sites": [ + "faststore" + ], + "summary": "There's nothing on sessions or regions beyond cache-key advice. Without a populated `RequestContext`, apps-vtex's regionalized Intelligent Search queries silently fall back to the default region.", + "docs": [ + "blocks", + "matchers-and-variants", + "caching", + "routing" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "delivery-promise": { + "name": "Delivery-promise facets and region slider", + "category": "commerce", + "status": "to-build", + "effort": "L", + "sites": [ + "faststore" + ], + "summary": "Neither these docs nor apps-vtex have it: the Intelligent Search loaders carry no delivery-promise parameters, and the FastStore hooks and region slider it uses don't survive. It also depends on session regionalization.", + "docs": [ + "routing", + "blocks" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "global-ui-state-store": { + "name": "Global UI state store", + "category": "interactivity", + "status": "site-code", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "Shared open/close state is plain React context. A module-level store must never be written during SSR, and the cart sidebar and region slider that lived in @faststore/core become site code.", + "docs": [ + "api-reference", + "design-decisions", + "rendering", + "caching" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "newsletter-subscription": { + "name": "Newsletter subscription", + "category": "interactivity", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack", + "faststore" + ], + "summary": "A form plus a server function or Server Action is a few lines. The content drift (flat fields where Props read nested `notices` and `form`) means editors' copy is ignored today, and the public POST needs rate limiting.", + "docs": [ + "caching", + "matchers-and-variants", + "blocks", + "tanstack-start-descriptors", + "renames-and-migrations" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "product-comparison": { + "name": "Product comparison", + "category": "interactivity", + "status": "site-code", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "One server function plus useQuery; the selection context moves unchanged. The cost is re-platforming the comparison sidebar to schema.org products, and it can't ship before ProductGallery's search state is rebuilt.", + "docs": [ + "rendering", + "blocks", + "caching", + "schema" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "shipping-simulation": { + "name": "PDP shipping simulation", + "category": "commerce", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack", + "faststore" + ], + "summary": "It becomes a server function around an existing apps-vtex call. Every `invoke.*` call site needs rewriting, and without `RequestContext` no segment cookie is forwarded, so regional SLAs can differ.", + "docs": [ + "caching", + "tanstack-start-descriptors", + "blocks" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "wishlist-notify-reviews": { + "name": "Wishlist, notify-me, reviews (stubbed)", + "category": "commerce", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack", + "faststore" + ], + "summary": "These are placeholders; each becomes a server function in a few lines. The site's demo wishlist code uses `RequestContext`.", + "docs": [ + "caching", + "tanstack-start-descriptors", + "blocks" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "interactive-ui-widgets": { + "name": "Client UI widgets (sliders, drawers, modals, scripts)", + "category": "interactivity", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Plain-React widgets run unchanged, but the storefront's widgets import `@decocms/blocks/sdk/useDevice` and the Image and Picture hooks, the CSP nonce comes from `createDecoRouter`, and the blog's inline scripts misbehave in streamed boundaries.", + "docs": [ + "rendering", + "nextjs", + "tanstack-start-descriptors", + "blocks", + "troubleshooting" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": "done", + "unconfirmed_sub_claim": false + }, + "design-system-library": { + "name": "Site-owned atomic UI library", + "category": "design", + "status": "site-code", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "The app owns this entirely. Hook-using files without `\"use client\"` throw under Server Components, and the FastStore shell's contexts must be re-mounted.", + "docs": [ + "how-it-works", + "blocks", + "rendering", + "nextjs" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": true + }, + "page-seo-blocks": { + "name": "Page-level SEO blocks", + "category": "seo", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "`page.seo` resolves, but the guides map only title and description, so canonical, robots, OG and page JSON-LD have no owner. The blog's SEO blocks need cross-entry lookups, and `throw seoError` turns an upstream failure into a full-page error.", + "docs": [ + "routing", + "rendering", + "nextjs", + "tanstack-start-descriptors", + "renames-and-migrations", + "studio-compatibility", + "blocks", + "design-decisions" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "site-seo-defaults": { + "name": "Site-wide SEO defaults and title templates", + "category": "seo", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "There's no site-level SEO concept, so inheritance is app code and must match the site editor's `mergeSeo` exactly. The site editor's preview applies titleTemplate but not descriptionTemplate, so production and preview already differ (descriptions are used as written, see the SEO parity item).", + "docs": [ + "blocks", + "schema", + "api-reference", + "renames-and-migrations", + "design-decisions" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "commerce-seo-sections-in-sections": { + "name": "Commerce SEO blocks inside a page's block list", + "category": "seo", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack" + ], + "summary": "The target, `page.seo`, is documented; what's missing is the SEO-hoist codemod and the per-client memo-key rule. The problem is live today: storefront PDP and PLP heads carry only the site title.", + "docs": [ + "blocks", + "rendering", + "routing", + "caching", + "renames-and-migrations", + "router-internals" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": "to-build", + "unconfirmed_sub_claim": false + }, + "head-tags-from-sections": { + "name": "Head tags injected from blocks", + "category": "seo", + "status": "site-code", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "React 19 hoisting lets a block emit head tags, but the per-block Suspense pattern defeats in-block LCP preloads, and nothing says so. Preload from the page component instead.", + "docs": [ + "rendering", + "how-it-works", + "blocks", + "nextjs" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "json-ld-structured-data": { + "name": "JSON-LD structured data", + "category": "seo", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "The JSON-LD helpers live in `@decocms/blocks/hooks`, which has no documented home in a React-free SDK, and the proposed API has no `<JsonLd>` renderer.", + "docs": [ + "blocks", + "rendering", + "api-reference", + "internals" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "sitemap": { + "name": "Sitemap", + "category": "seo", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Only literal pages and Route-typed content are handled: templated pages have no enumeration story, and there's no XML or sitemap-index helper. Next's `app/sitemap.ts` is static by default, which defeats remote releases.", + "docs": [ + "routing", + "api-reference", + "router-internals", + "releases-and-deployment" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "robots-and-static-seo-files": { + "name": "robots.txt, llms.txt and favicons", + "category": "seo", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Static files sit outside the CMS, so these are content fixes: the storefront's robots.txt has no Sitemap line, the blog's Sitemap URL is relative, and FastStore must recreate its robots.txt and favicon.", + "docs": [ + "routing", + "how-it-works" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "edge-html-cache-profiles": { + "name": "Edge/HTTP cache profiles", + "category": "caching", + "status": "to-build", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "There's no HTTP or edge cache story, and the Next pattern disables static and ISR rendering. Degraded pages get cached, and there's no revision to key by. The planned once-a-minute check is eventually consistent; manifest and page-cache lifetimes also affect propagation.", + "docs": [ + "caching", + "releases-and-deployment", + "api-reference", + "hosted-releases-internals", + "nextjs", + "tanstack-start-descriptors", + "rendering", + "releases-and-drafts", + "routing" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "in-isolate-caches": { + "name": "In-process caches (layout, loaders, globals)", + "category": "caching", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Cross-request caching is 'your policy', and upstream caching moves to template recipes (a caching fetch passed to a client), not yet in the templates. Draft caches are unbounded, input-keyed caches miss request state, and the site editor's bypass flag is ignored.", + "docs": [ + "caching", + "api-reference", + "troubleshooting", + "blocks" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "request-context-cookies": { + "name": "Request context, cookies and headers", + "category": "caching", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Headers and cookies are available, but the page URL and params, Set-Cookie from resolution and the request port apps-* use are not. [Blocks](#blocks) says to read route params through `getRequest()`, which is wrong even on SSR.", + "docs": [ + "blocks", + "routing", + "tanstack-start-descriptors", + "nextjs", + "rendering", + "caching" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "admin-protocol-endpoints": { + "name": "Admin/site editor protocol endpoints", + "category": "studio", + "status": "to-build", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "Nothing serves the endpoints the site editor calls on a site today (`/live/_meta`, `/.decofile`, `/live/previews`, `/deco/invoke`). The plan replaces them with the content protocol, which needs only the committed schema and files, so features that run site code degrade. Today the blog's product picker also calls invoke in production; on protocol projects it falls back to free-text product IDs.", + "docs": [ + "studio-compatibility", + "internals", + "schema", + "blocks", + "caching", + "content-protocol" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "studio-preview": { + "name": "Site editor preview rendering", + "category": "studio", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Draft rendering maps directly, but the TanStack guides never append the draft cookie, the SameSite=Lax cookie is rejected in the site editor's iframe, and there's no no-store rule for drafts. Recovering `data-manifest-key` takes more than a wrapper.", + "docs": [ + "releases-and-drafts", + "api-reference", + "nextjs", + "tanstack-start-descriptors", + "matchers-and-variants", + "rendering" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "studio-schema-generation": { + "name": "Editor schema generation / catalog", + "category": "studio", + "status": "to-finish", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "The CLI's documented output isn't what the site editor reads: the file name, btoa keys, `schema.root` unions and `schema.definitions` all differ. There are no `apps` or `actions` groups, and the promised JSDoc tag table doesn't exist.", + "docs": [ + "schema", + "studio-compatibility", + "renames-and-migrations", + "blocks", + "quickstart", + "routing", + "caching" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "cms-schema-upload-pipeline": { + "name": "CMS schema generate and upload (VTEX CP)", + "category": "studio", + "status": "goes-away", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "The VTEX upload has no counterpart, because publishing is committing. The replacement inherits the schema-file problems.", + "docs": [ + "schema", + "releases-and-deployment" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "studio-workspace-spaces": { + "name": "Site editor workspace layout (spaces.json)", + "category": "studio", + "status": "goes-away", + "effort": "S", + "sites": [ + "blog-tanstack" + ], + "summary": "Nothing in the site editor reads `spaces.json`. What matters instead: the site editor's Blog tab keys off legacy blog loader names and gates scheduling on `@decocms/apps-blog` 7.53.0 or later, while the blog pins `@decocms/apps` 5.2.0.", + "docs": [ + "studio-compatibility", + "blocks" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "dev-studio-content-loop": { + "name": "Local dev daemon and site editor content loop", + "category": "studio", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "With a site and token in dev, `remoteLoader` swaps in the production release over local edits, so site editor sandbox saves vanish from the dev preview. Nothing regenerates the schema or serves the Local-mode endpoints.", + "docs": [ + "api-reference", + "tanstack-start-descriptors", + "releases-and-drafts", + "schema", + "hosted-releases-internals", + "quickstart", + "content-protocol" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "site-bootstrap": { + "name": "Site bootstrap (createSiteSetup + admin setup)", + "category": "config", + "status": "to-finish", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "`createCMS` plus a block map replace registration, but nothing generates the map (27 UI blocks and 12 loaders by hand on the storefront), the revision of generated content must match the API hash (planned: <code>deco content</code> computes it the same way), and there's no lenient mode or site-editor-shaped schema.", + "docs": [ + "quickstart", + "blocks", + "api-reference", + "tanstack-start-descriptors", + "hosted-releases-internals", + "schema", + "releases-and-drafts", + "studio-compatibility", + "renames-and-migrations" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "site-config-block": { + "name": "Site config block / global settings", + "category": "config", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Today the binding resolves the site's theme, globals and page blocks into every page. In the next major, SEO-default merging and global-block injection move to app code, there's no singleton concept, and `site/apps/site.ts` isn't in the alias bridge.", + "docs": [ + "routing", + "schema", + "blocks", + "api-reference", + "renames-and-migrations", + "studio-compatibility" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "app-installation-and-store-config": { + "name": "App installation blocks, autoconfig and store config", + "category": "config", + "status": "to-build", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "There's no concept of an app: no config for app loaders, no autoconfig, no secret resolution, no `apps` manifest group and no encrypt endpoint, so the site editor can't save secret fields.", + "docs": [ + "blocks", + "design-decisions", + "studio-compatibility", + "schema", + "releases-and-drafts", + "api-reference", + "tanstack-start-descriptors" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "env-vars-and-secrets": { + "name": "Environment variables and secrets", + "category": "config", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "DECO_SITE_TOKEN's lifecycle is undocumented, there's no channel option, and a production deploy that forgets the token silently serves bundled content. There's no secret story either: the decryptor for secrets stored in content is off the documented surface.", + "docs": [ + "api-reference", + "tanstack-start-descriptors", + "nextjs", + "releases-and-drafts", + "releases-and-deployment", + "caching" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "build-time-site-globals-snapshot": { + "name": "Build-time site-globals snapshot workaround", + "category": "config", + "status": "goes-away", + "effort": "S", + "sites": [ + "blog-tanstack" + ], + "summary": "A workaround for a current-framework bug, with nothing to port. Half its call sites are server-side, so the site needs a small AsyncLocalStorage hand-off, and the snapshot's defect (edits never reach draft preview) goes away.", + "docs": [ + "blocks", + "routing", + "tanstack-start-descriptors", + "releases-and-deployment", + "releases-and-drafts" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "app-domain-types": { + "name": "App domain types driving the site editor's schema", + "category": "config", + "status": "to-finish", + "effort": "M", + "sites": [ + "blog-tanstack" + ], + "summary": "The mechanism is documented but not the conventions apps-blog relies on: type-alias formats like `ImageWidget`, and `@titleBy`, `@hide` and `@widget`. Without them the blog's forms degrade to plain text inputs.", + "docs": [ + "schema", + "quickstart", + "routing", + "studio-compatibility" + ], + "confidence": "medium", + "first_rated": "done", + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "worker-server-entry": { + "name": "Worker/server entry composition", + "category": "runtime-deploy", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Only the CMS and draft slice of the entry is documented. Site editor routes, the edge cache, purge, security headers and OTel have no home, the draft cookie is SameSite=Lax, and `?__draft=off` never exits preview.", + "docs": [ + "tanstack-start-descriptors", + "api-reference", + "releases-and-drafts", + "nextjs", + "caching", + "studio-compatibility", + "internals" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "csp-security-headers": { + "name": "CSP and security headers", + "category": "runtime-deploy", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "Headers are a few lines of app code, but after migration nothing sets HSTS, nosniff, Referrer-Policy or a framing policy, and the origins the site editor needs in frame-ancestors are undocumented.", + "docs": [ + "how-it-works", + "tanstack-start-descriptors", + "nextjs", + "studio-compatibility" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "ab-traffic-split": { + "name": "Migration A/B traffic split", + "category": "runtime-deploy", + "status": "to-build", + "effort": "S", + "sites": [ + "storefront-tanstack" + ], + "summary": "Nothing gives `withABTesting` a home, and it isn't few-line app code. It also needs to bypass draft requests by default.", + "docs": [ + "api-reference", + "internals", + "quickstart" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "hosting-and-deploy-config": { + "name": "Hosting and deploy configuration", + "category": "runtime-deploy", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Only the single-instance happy path is documented: a cron or webhook `update()` refreshes one instance, serverless Node gives no staleness bound, and per-PR preview deploys swap in the production release.", + "docs": [ + "tanstack-start-descriptors", + "tanstack-start-rsc", + "nextjs", + "api-reference", + "releases-and-deployment" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "content-storage-and-delivery": { + "name": "Content storage, snapshot and delivery", + "category": "runtime-deploy", + "status": "to-finish", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "The file model matches, but filename encoding, bundling, the release service and skew protection are unspecified. Content is meant to be validated against the code in CI by `deco check`, which isn't built yet.", + "docs": [ + "quickstart", + "blocks", + "api-reference", + "tanstack-start-descriptors", + "releases-and-deployment", + "hosted-releases-internals", + "studio-compatibility" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "fast-deploy-kv": { + "name": "Fast Deploy (KV-first content)", + "category": "runtime-deploy", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "`remoteLoader` will check once a minute (configurable with `interval`), cold isolates serve the deployed content module until their first check, a webhook reaches one isolate, and the KV loader is a template recipe, not yet in the templates. Impact on these sites is low: none serves content from KV today.", + "docs": [ + "api-reference", + "releases-and-deployment", + "hosted-releases-internals" + ], + "confidence": "high", + "first_rated": "done", + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "ci-and-release-workflows": { + "name": "CI, content regen and release workflows", + "category": "runtime-deploy", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "The CI job is a few lines, but the CLI doesn't emit what the site editor reads and has no `deco check` yet. The blog's `regen-blocks.yml` is its live content-delivery path today; deleting it early freezes its content.", + "docs": [ + "schema", + "releases-and-deployment", + "router-internals", + "studio-compatibility", + "renames-and-migrations" + ], + "confidence": "high", + "first_rated": "site-code", + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "root-document-layout": { + "name": "Root document and layout", + "category": "runtime-deploy", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "The document shell is app code, but the site editor bridge and the `section[data-manifest-key]` marker it needs are neither shipped nor documented. Dropping the DECO.events bootstrap also silently breaks OneDollarStats.", + "docs": [ + "tanstack-start-descriptors", + "nextjs", + "rendering", + "studio-compatibility" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "codegen-pipeline": { + "name": "Codegen and build pipeline", + "category": "build", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "`deco schema` and a hand-written block map replace the registries, `deco content` (planned) generates the content module for every runtime, but there's no `deco check` to validate content against the code yet", + "docs": [ + "schema", + "blocks", + "tanstack-start-descriptors", + "releases-and-deployment", + "hosted-releases-internals", + "renames-and-migrations", + "studio-compatibility" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "bundler-config": { + "name": "Vite config", + "category": "build", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "The guide configs build, but nothing replaces decoVitePlugin's dev and site editor duties: schema regeneration, the site editor tunnel, the build hash and block preloads. Next also needs transpilePackages and file-tracing notes.", + "docs": [ + "how-it-works", + "tanstack-start-descriptors", + "tanstack-start-rsc", + "nextjs", + "internals" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": true + }, + "faststore-cli-overlay": { + "name": "FastStore CLI .faststore overlay", + "category": "build", + "status": "to-build", + "effort": "L", + "sites": [ + "faststore" + ], + "summary": "The overlay goes away by design, but the move off it has no guide and no serverless deployment note (the content importer is handled outside this roadmap). Everything the overlay supplied becomes app code, and its imports of core internals, `@faststore/core` and `@generated` have to go.", + "docs": [ + "nextjs", + "how-it-works", + "schema", + "blocks", + "api-reference" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": "goes-away", + "unconfirmed_sub_claim": false + }, + "framework-import-surface": { + "name": "Framework import surface and compat shims", + "category": "build", + "status": "to-build", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "These docs cover about 12 CMS-core symbols and say nothing about the 7.x sdk, hooks, admin or binding tiers that apps-* and the telemetry depend on. Widget aliases are missing, and there's no 7.x to next codemod.", + "docs": [ + "api-reference", + "matchers-and-variants", + "routing", + "blocks", + "schema", + "internals", + "upstream-clients" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "core-patches": { + "name": "patch-package patches to core", + "category": "build", + "status": "site-code", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "The patches go away, but the behaviours they add must be re-created in site code.", + "docs": [ + "nextjs", + "rendering", + "blocks" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": "goes-away", + "unconfirmed_sub_claim": false + }, + "dev-tooling-and-repo-hygiene": { + "name": "Dev tooling, quality gates and repo hygiene", + "category": "build", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Ordinary app work, and the CI drift check is one line, but there's no `deco check` content validator yet. Sites' own lint and quality scripts carry over as app scripts.", + "docs": [ + "schema", + "router-internals", + "renames-and-migrations" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "legacy-runtime-leftovers": { + "name": "Legacy Deno/Fresh leftovers", + "category": "build", + "status": "goes-away", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "Dead Deno and Fresh files, with nothing to port.", + "docs": [ + "tanstack-start-descriptors", + "releases-and-drafts", + "api-reference", + "caching" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "favicon-generation": { + "name": "Build-time favicon generation", + "category": "build", + "status": "site-code", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "`metadata.icons` does it in one line.", + "docs": [ + "rendering", + "nextjs" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "e2e-lighthouse-config": { + "name": "E2E and Lighthouse config", + "category": "build", + "status": "site-code", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "Tests are the app's job. A 'resolve every page' smoke test needs a fake request context, and there's no framework harness, so every site writes the same test.", + "docs": [ + "how-it-works", + "api-reference", + "renames-and-migrations", + "blocks" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "ai-agent-instructions": { + "name": "AI-agent instructions", + "category": "build", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack", + "faststore" + ], + "summary": "Agent docs are repo content, but no canonical guidance ships, and the site editor's injected rules drift from the next major.", + "docs": [ + "how-it-works", + "schema", + "quickstart" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "workspace-packages": { + "name": "Sites and block types in workspace packages", + "category": "build", + "status": "to-finish", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "Undocumented: whether `deco schema` follows block types into a workspace or npm package, and how the Deco API and the site editor address a site that lives in a package inside a larger repository.", + "docs": [ + "schema", + "releases-and-drafts", + "nextjs", + "api-reference", + "releases-and-deployment" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": true + }, + "theming-and-styling": { + "name": "Theming and styling", + "category": "design", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "There's no pattern for site-wide content resolved outside the catch-all, so the theme streams late under Suspense and shifts the layout, and the theme and the page can come from different revisions.", + "docs": [ + "blocks", + "renames-and-migrations", + "schema", + "rendering", + "releases-and-deployment", + "nextjs", + "studio-compatibility" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "web-fonts": { + "name": "Web fonts", + "category": "design", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "`googleFonts` has no home in the next-major packages and imports `sdk/fetchTimeout`. Making fonts editable also needs the CLI to link `font: Font` to loaders by return type, a rule these docs name but never define.", + "docs": [ + "blocks", + "renames-and-migrations", + "caching", + "studio-compatibility", + "internals" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "image-optimization": { + "name": "Image optimization", + "category": "design", + "status": "to-build", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Images aren't mentioned. Without a shipped URL builder, migrated sites serve full-size originals and ignore the site editor's quality choice, and without `format: image-uri` the site editor shows a text box instead of the image picker.", + "docs": [ + "api-reference", + "internals", + "design-decisions", + "routing", + "schema" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "i18n-and-currency": { + "name": "i18n and currency", + "category": "design", + "status": "site-code", + "effort": "S", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Locale is fixed per deployment, so the only framework need is cache keys. In the FastStore storefront, code that calls its own translation hook without `\"use client\"` throws as a Server Component until it's rewritten.", + "docs": [ + "blocks", + "matchers-and-variants", + "caching", + "nextjs" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "otel-observability": { + "name": "OTel and logging", + "category": "observability-analytics", + "status": "to-finish", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Sites trace inbound requests and page caches with their framework's own OpenTelemetry setup; the SDK's resolution, upstream and error measurements go wherever the `telemetry` option of `createCMS` points (your own OTLP collector or the hosted Deco CMS). Still open: `remoteLoader` fails silently, and spans around JSX blocks miss the upstream fetches.", + "docs": [ + "telemetry", + "api-reference", + "hosted-releases-internals", + "releases-and-drafts", + "releases-and-deployment", + "troubleshooting", + "blocks", + "tanstack-start-descriptors", + "internals" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "upstream-fetch-instrumentation": { + "name": "Upstream fetch instrumentation", + "category": "observability-analytics", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "faststore" + ], + "summary": "The storefront calls `createInstrumentedFetch(\"shopify\")` with no onComplete, so upstream metrics are dark today. The chokepoint rule is never stated, and there's no meter on Next or Node.", + "docs": [ + "telemetry", + "blocks", + "rendering", + "internals", + "upstream-clients" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "analytics-trackers": { + "name": "Analytics trackers", + "category": "observability-analytics", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "Trackers carry over, but the DECO.events bootstrap is private and copied three times, and experiment results break: the site editor keys them on the random matcher's saved-entry name, which a pure block function never sees.", + "docs": [ + "analytics", + "nextjs", + "matchers-and-variants", + "design-decisions", + "studio-compatibility", + "renames-and-migrations", + "blocks", + "quickstart", + "rendering" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "ecommerce-analytics-events": { + "name": "E-commerce events", + "category": "observability-analytics", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack", + "faststore" + ], + "summary": "Emitters are app code, but the receiving pipeline is mounted only by the bindings' DecoRootLayout, which the guides don't use, so every commerce event would drop silently.", + "docs": [ + "rendering", + "nextjs", + "internals", + "analytics" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + } + } +} diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000..3b4c175c --- /dev/null +++ b/docs/package.json @@ -0,0 +1,45 @@ +{ + "name": "deco-blocks-docs", + "private": true, + "type": "module", + "packageManager": "bun@1.3.5", + "engines": { + "node": ">=22.12" + }, + "scripts": { + "dev": "vite dev --port 3000", + "build": "bun scripts/check-roadmap.ts && vite build && bun scripts/postbuild.ts", + "preview": "bun scripts/preview.ts", + "check": "tsc --noEmit && bun scripts/check-content.ts && bun scripts/check-roadmap.ts" + }, + "dependencies": { + "@tanstack/react-router": "^1.170.41", + "@tanstack/react-start": "^1.168.60", + "react": "^19.3.0", + "react-dom": "^19.3.0" + }, + "devDependencies": { + "@mdx-js/rollup": "^3.1.1", + "@tailwindcss/vite": "^4.3.3", + "@types/bun": "^1.4.2", + "@types/hast": "^3.0.5", + "@types/mdx": "^2.0.14", + "@types/node": "^26.6.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "estree-util-value-to-estree": "^3.5.0", + "hast-util-to-html": "^9.0.5", + "hast-util-to-string": "^3.0.1", + "pagefind": "^1.5.2", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.1", + "remark-mdx-frontmatter": "^6.0.0", + "shiki": "^4.5.0", + "tailwindcss": "^4.3.3", + "typescript": "^5.9.3", + "unist-util-visit": "^5.1.0", + "vite": "^8.3.2", + "yaml": "^2.9.1" + } +} diff --git a/docs/promo/.gitignore b/docs/promo/.gitignore new file mode 100644 index 00000000..922e9729 --- /dev/null +++ b/docs/promo/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +output/ +__pycache__/ +.venv/ diff --git a/docs/promo/README.md b/docs/promo/README.md new file mode 100644 index 00000000..0f90ab50 --- /dev/null +++ b/docs/promo/README.md @@ -0,0 +1,49 @@ +# Next-major promotional video + +A 26-second, 1080×1920 vertical MP4 with English captions, a synthesized instrumental +soundtrack, and click effects synchronized to the recorded mobile landing-page demo. +The video identifies the product as a next-major preview. The Studio/Git interaction is +the landing page's illustrative demo; this capture does not operate a real Studio or +publish a repository change. + +## Recreate + +Build and serve the docs from `docs/` with `bun run build` and `bun run preview`. +In another terminal, from this directory: + +```sh +npm install +npx playwright install chromium +python3 -m venv .venv +.venv/bin/pip install -r requirements.txt +node capture.mjs +.venv/bin/python render.py +``` + +`PROMO_URL` overrides `http://localhost:4173/next/`. Capture a built preview matching +the branch's current sources. `PROMO_FONT` can specify a TrueType font on platforms +without Arial or DejaVu Sans. Python 3.10+ and Node 22+ are sufficient. + +The result is `output/deco-next-vertical.mp4`. `output/timeline.json` stores the real +caption and click timestamps; `studio.png` and `final-page.png` are inspection stills. +The output folder and local dependencies are ignored by Git. Share the MP4 separately +from the source change. + +## Sequence + +| Approximate time | Visual | Caption | +| --- | --- | --- | +| 0–2s | Forest-green title card | Headless. Editable. AI-native. | +| 2–5s | Next home and scroll into the demo | One model. Every maker. | +| 5–7s | TypeScript pane | Write a function. | +| 7–11s | Edit pane, slider interaction | Edit it in Studio. | +| 11–15s | Save, then commit pane | Every change is a commit. | +| 15–18s | Resolve pane | Resolve it. Make it live. | +| 18–23s | Shared-content section | Working on the same content. | +| 23–26s | Closing title card | Build with Deco. | + +The soundtrack is original procedural synthesis: pads, arpeggio, bass, percussion, and +clicks. It contains no downloaded music or third-party audio samples. Adjust the copy +and interactions in `capture.mjs`; edit the cards, colors, arrangement, and mix in +`render.py`. The capture adds a visible pointer and click rings only inside its own +browser session. diff --git a/docs/promo/capture.mjs b/docs/promo/capture.mjs new file mode 100644 index 00000000..22e356a2 --- /dev/null +++ b/docs/promo/capture.mjs @@ -0,0 +1,102 @@ +import { chromium } from 'playwright'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const output = path.join(path.dirname(fileURLToPath(import.meta.url)), 'output'); +await mkdir(output, { recursive: true }); +const browser = await chromium.launch(); +const context = await browser.newContext({ + viewport: { width: 540, height: 760 }, deviceScaleFactor: 2, + isMobile: true, hasTouch: true, colorScheme: 'light', + recordVideo: { dir: output, size: { width: 540, height: 760 } }, +}); +const page = await context.newPage(); +const recordingStart = performance.now(); +const errors = []; +page.on('pageerror', error => errors.push(error.message)); +await page.goto(process.env.PROMO_URL ?? 'http://localhost:4173/next/', { waitUntil: 'networkidle' }); +await page.evaluate(() => document.fonts.ready); +await page.waitForTimeout(1000); +await page.evaluate(() => { + const style = document.createElement('style'); + style.textContent = `#promo-cursor{position:fixed;width:22px;height:22px;border:2px solid #fff;border-radius:50%;background:#d0ff5f99;box-shadow:0 0 0 2px #124723;z-index:2147483647;pointer-events:none;transform:translate(-50%,-50%);left:-100px;top:-100px}.promo-ripple{position:fixed;width:36px;height:36px;border:3px solid #bbff53;border-radius:50%;z-index:2147483646;pointer-events:none;animation:promo-click .5s ease-out forwards}@keyframes promo-click{from{transform:translate(-50%,-50%) scale(.5);opacity:1}to{transform:translate(-50%,-50%) scale(2);opacity:0}}`; + document.head.append(style); + const cursor = document.createElement('div'); cursor.id = 'promo-cursor'; document.body.append(cursor); +}); +const start = performance.now(); +const clicks = [], captions = []; +const elapsed = () => (performance.now() - start) / 1000; +const until = async seconds => page.waitForTimeout(Math.max(0, seconds * 1000 - (performance.now() - start))); +const caption = (label, title) => captions.push({ at: elapsed(), label, title }); +let pointer = { x: 480, y: 640 }; +async function point(x, y) { + const from = { ...pointer }; + for (let i = 1; i <= 18; i++) { + const t = i / 18, ease = t * t * (3 - 2 * t); + pointer = { x: from.x + (x - from.x) * ease, y: from.y + (y - from.y) * ease }; + await page.mouse.move(pointer.x, pointer.y); + await page.evaluate(({ x, y }) => { const c = document.querySelector('#promo-cursor'); c.style.left = `${x}px`; c.style.top = `${y}px`; }, pointer); + await page.waitForTimeout(16); + } +} +async function click(locator, fraction = .5) { + const box = await locator.boundingBox(); + if (!box) throw new Error('Missing click target'); + const x = box.x + box.width * fraction, y = box.y + box.height / 2; + await point(x, y); + clicks.push(elapsed()); + await page.evaluate(({ x, y }) => { + const r = document.createElement('div'); r.className = 'promo-ripple'; + r.style.left = `${x}px`; r.style.top = `${y}px`; document.body.append(r); + setTimeout(() => r.remove(), 600); + }, { x, y }); + await page.mouse.click(x, y); +} +async function scrollTo(locator, top = 95) { + const y = await locator.evaluate((el, top) => window.scrollY + el.getBoundingClientRect().top - top, top); + await page.evaluate(async y => { + const from = window.scrollY, start = performance.now(); + await new Promise(resolve => { + function frame(now) { + const t = Math.min(1, (now - start) / 750), e = t * t * (3 - 2 * t); + window.scrollTo(0, from + (y - from) * e); + if (t < 1) requestAnimationFrame(frame); else resolve(); + } + requestAnimationFrame(frame); + }); + }, y); +} + +caption('DECO / NEXT', 'One model. Every maker.'); +await until(2.6); +await scrollTo(page.getByRole('group', { name: 'From a TypeScript type to a resolved value', exact: true }).locator('..'), 90); +caption('01 / DEVELOPERS', 'Write a function.'); +await until(5.0); +await click(page.locator('[data-jp="1"]')); +caption('02 / HUMANS', 'Edit it in Studio.'); +await page.waitForTimeout(650); +await click(page.locator('#exp-newCheckout'), .7); +await page.waitForTimeout(900); +await page.screenshot({ path: path.join(output, 'studio.png') }); +await until(9.0); +await click(page.locator('#studio-save')); +await page.waitForTimeout(600); +await click(page.locator('[data-jp="2"]')); +caption('03 / GIT', 'Every change is a commit.'); +await until(13.0); +await click(page.locator('[data-jp="3"]')); +caption('04 / RUNTIME', 'Resolve it. Make it live.'); +await until(16.4); +await scrollTo(page.getByRole('heading', { name: 'Every change is a commit, whoever makes it.' }).locator('../..'), 95); +caption('DEVELOPERS + HUMANS + AI', 'Working on the same content.'); +await until(21.0); +await page.screenshot({ path: path.join(output, 'final-page.png') }); +const manifest = { trimStart: (start - recordingStart) / 1000, duration: elapsed(), clicks, captions, errors }; +const video = page.video(); +await context.close(); +await video.saveAs(path.join(output, 'capture.webm')); +await browser.close(); +await writeFile(path.join(output, 'timeline.json'), JSON.stringify(manifest, null, 2)); +console.log(JSON.stringify(manifest, null, 2)); +if (errors.length) process.exitCode = 1; diff --git a/docs/promo/node_modules b/docs/promo/node_modules new file mode 120000 index 00000000..7809c8be --- /dev/null +++ b/docs/promo/node_modules @@ -0,0 +1 @@ +/tmp/deco-promo-tools/node_modules \ No newline at end of file diff --git a/docs/promo/package.json b/docs/promo/package.json new file mode 100644 index 00000000..a06260d3 --- /dev/null +++ b/docs/promo/package.json @@ -0,0 +1,7 @@ +{ + "name": "deco-next-promo", + "private": true, + "type": "module", + "scripts": { "capture": "node capture.mjs" }, + "devDependencies": { "playwright": "1.63.0" } +} diff --git a/docs/promo/render.py b/docs/promo/render.py new file mode 100644 index 00000000..235e3421 --- /dev/null +++ b/docs/promo/render.py @@ -0,0 +1,155 @@ +"""Compose the mobile capture, title cards, captions, and an original soundtrack.""" +from pathlib import Path +import json +import math +import os +import subprocess +import wave + +import imageio_ffmpeg +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +ROOT = Path(__file__).resolve().parent +OUT = ROOT / 'output' +timeline = json.loads((OUT / 'timeline.json').read_text()) +W, H = 1080, 1920 +INTRO, OUTRO = 2.0, 3.0 +duration = INTRO + timeline['duration'] + OUTRO +FOREST, LIME, WHITE = '#092e1a', '#d0ff5f', '#f6f5ef' + +def font(size, bold=False): + candidates = [os.environ.get('PROMO_FONT', ''), '/System/Library/Fonts/Supplemental/Arial Bold.ttf' if bold else '/System/Library/Fonts/Supplemental/Arial.ttf', '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf' if bold else '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'] + for candidate in candidates: + if candidate and Path(candidate).exists(): + return ImageFont.truetype(candidate, size) + raise RuntimeError('Set PROMO_FONT to a TrueType font path.') + +def background(): + image = Image.new('RGB', (W, H), FOREST) + draw = ImageDraw.Draw(image) + for y in range(-100, H, 180): + for x in range(-100, W, 180): + draw.arc((x, y, x + 150, y + 150), 0, 270, fill='#12482a', width=2) + return image + +def card(filename, label, lines, footer): + image = background() + draw = ImageDraw.Draw(image) + draw.text((84, 160), 'DECO', font=font(56, True), fill=LIME) + draw.text((84, 280), label, font=font(26), fill='#b6cdbb') + y = 640 + for i, line in enumerate(lines): + draw.text((80, y), line, font=font(112, True), fill=LIME if i == len(lines) - 1 else WHITE) + y += 138 + draw.line((84, 1380, 996, 1380), fill='#386048', width=2) + for i, line in enumerate(footer): + draw.text((84, 1440 + i * 64), line, font=font(34), fill=WHITE) + draw.text((84, 1750), 'NEXT-MAJOR PREVIEW', font=font(25), fill='#b6cdbb') + image.save(OUT / filename) + +card('intro.png', 'THE NEXT CHAPTER', ['Headless.', 'Editable.', 'AI-native.'], ['One content model.', 'For developers, humans, and AI.']) +card('outro.png', 'START BUILDING', ['Build with', 'Deco.'], ['Explore the next-major docs', '/next/', 'npm install @decocms/blocks']) + +base = background() +draw = ImageDraw.Draw(base) +draw.text((60, 58), 'DECO', font=font(34, True), fill=LIME) +draw.text((740, 63), 'NEXT / PREVIEW', font=font(22), fill='#b6cdbb') +draw.text((60, 1866), 'ONE MODEL. EVERY MAKER.', font=font(22), fill='#b6cdbb') +base.save(OUT / 'background.png') +for i, caption in enumerate(timeline['captions']): + image = Image.new('RGBA', (W, 210), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + draw.text((60, 18), caption['label'], font=font(24), fill=LIME) + words = caption['title'].split() + lines, line = [], '' + for word in words: + trial = (line + ' ' + word).strip() + if draw.textlength(trial, font=font(49, True)) > 960: + lines.append(line) + line = word + else: + line = trial + lines.append(line) + for j, line in enumerate(lines): + draw.text((60, 60 + 58 * j), line, font=font(49, True), fill=WHITE) + image.save(OUT / f'caption-{i}.png') + +# Original 108 BPM instrumental: soft pads, a plucked arpeggio, bass, and drums. +# Every sample is synthesized here; no third-party recordings are used. +SR, BPM = 48000, 108 +beat = 60 / BPM +audio = np.zeros((math.ceil(duration * SR), 2), dtype=np.float64) +rng = np.random.default_rng(17) +def add(at, samples, gain=1, pan=0): + start = round(at * SR) + stop = min(len(audio), start + len(samples)) + if stop <= start: + return + samples = samples[:stop-start] * gain + audio[start:stop, 0] += samples * math.sqrt((1-pan)/2) + audio[start:stop, 1] += samples * math.sqrt((1+pan)/2) + +def tone(midi, seconds, kind): + t = np.arange(round(seconds * SR)) / SR + frequency = 440 * 2 ** ((midi - 69) / 12) + if kind == 'pad': + envelope = np.minimum(t / .3, 1) * np.minimum((seconds - t) / .5, 1) + return envelope * (np.sin(2*np.pi*frequency*t) + .24*np.sin(2*np.pi*frequency*1.003*t)) + return np.exp(-t * (6 if kind == 'pluck' else 3)) * np.minimum(t/.008, 1) * (np.sin(2*np.pi*frequency*t) + .12*np.sin(4*np.pi*frequency*t)) + +chords = [[60,64,67,71], [57,60,64,67], [53,57,60,64], [55,59,62,64]] +for bar in range(math.ceil(duration / (4 * beat))): + notes = chords[bar % 4] + at = bar * 4 * beat + for i, note in enumerate(notes): + add(at, tone(note, 4*beat+.2, 'pad'), .028, -.6 + i*.4) + for step in range(8): + add(at + step*beat/2, tone(notes[step % 4]+12, .65, 'pluck'), .045, -.4 if step%2 else .4) + for step in range(4): + add(at + step*beat, tone(notes[0]-24, .5, 'bass'), .13) + t = np.arange(round(.22*SR)) / SR + kick = np.sin(2*np.pi*(48*t + 55*.025*(1-np.exp(-t/.025)))) * np.exp(-t*22) + add(at+step*beat, kick, .17) + if step % 2: + t = np.arange(round(.12*SR)) / SR + noise = rng.normal(size=len(t)); noise = np.diff(noise, prepend=0) + add(at+step*beat, noise*np.exp(-t*45), .024, .15) + for step in range(8): + t = np.arange(round(.055*SR)) / SR + noise = rng.normal(size=len(t)); noise = np.diff(noise, prepend=0) + add(at+step*beat/2, noise*np.exp(-t*90), .01, -.3) + +for click in timeline['clicks']: + t = np.arange(round(.055*SR)) / SR + sound = (.7*np.sin(2*np.pi*1600*t) + .3*rng.normal(size=len(t))) * np.exp(-t*125) + add(INTRO + click, sound, .32) + +t = np.arange(len(audio)) / SR +envelope = np.minimum(t/.45, 1) * np.minimum((duration-t)/1.1, 1) +audio *= envelope[:, None] +audio = np.tanh(audio * 1.5) +audio *= min(10 ** (-18/20) / np.sqrt(np.mean(audio**2)), .79 / np.max(np.abs(audio))) +with wave.open(str(OUT / 'soundtrack.wav'), 'wb') as wav: + wav.setnchannels(2); wav.setsampwidth(2); wav.setframerate(SR) + wav.writeframes((audio * 32767).astype('<i2').tobytes()) + +ffmpeg = imageio_ffmpeg.get_ffmpeg_exe() +inputs = ['-loop', '1', '-i', str(OUT/'background.png'), '-i', str(OUT/'capture.webm')] +for filename in ['intro.png', 'outro.png'] + [f'caption-{i}.png' for i in range(len(timeline['captions']))]: + inputs += ['-loop', '1', '-i', str(OUT/filename)] +audio_index = 4 + len(timeline['captions']) +inputs += ['-i', str(OUT/'soundtrack.wav')] +filters = [f'[0:v]fps=30,format=yuv420p,trim=duration={timeline["duration"]},setpts=PTS-STARTPTS[bg]', + f'[1:v]trim=start={timeline["trimStart"]}:duration={timeline["duration"]},setpts=PTS-STARTPTS,scale=1080:1520,fps=30[screen]', + '[bg][screen]overlay=0:320:shortest=1[shot0]'] +for i, caption in enumerate(timeline['captions']): + end = timeline['captions'][i+1]['at'] if i+1 < len(timeline['captions']) else timeline['duration'] + filters += [f'[{4+i}:v]format=rgba,fade=t=in:st={caption["at"]:.3f}:d=0.18:alpha=1[cap{i}]', + f'[shot{i}][cap{i}]overlay=0:110:shortest=1:enable=\'between(t,{caption["at"]:.3f},{end:.3f})\'[shot{i+1}]'] +filters += [f'[2:v]fps=30,format=yuv420p,trim=duration={INTRO},setpts=PTS-STARTPTS[intro]', + f'[3:v]fps=30,format=yuv420p,trim=duration={OUTRO},setpts=PTS-STARTPTS[outro]', + f'[intro][shot{len(timeline["captions"])}][outro]concat=n=3:v=1:a=0[video]'] +command = [ffmpeg, '-y', *inputs, '-filter_complex', ';'.join(filters), '-map', '[video]', '-map', f'{audio_index}:a', '-r', '30', '-c:v', 'libx264', '-preset', 'fast', '-crf', '19', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', '-t', str(duration), str(OUT/'deco-next-vertical.mp4')] +subprocess.run(command, check=True) +print(f'Rendered {OUT / "deco-next-vertical.mp4"} ({duration:.1f}s, 1080×1920)') diff --git a/docs/promo/requirements.txt b/docs/promo/requirements.txt new file mode 100644 index 00000000..681031f2 --- /dev/null +++ b/docs/promo/requirements.txt @@ -0,0 +1,3 @@ +Pillow>=11,<13 +numpy>=2,<3 +imageio-ffmpeg==0.6.0 diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg new file mode 100644 index 00000000..99d2d099 --- /dev/null +++ b/docs/public/favicon.svg @@ -0,0 +1,5 @@ +<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="200" height="200" rx="40" fill="#D0EC1A"/> +<path d="M79.8643 161.651C64.1138 161.651 51.606 155.629 44.194 144.974C36.3187 133.856 35.3922 118.106 41.4145 101.429C49.753 79.6558 69.6728 66.2216 93.7618 66.2216H94.2251C94.2251 65.7583 94.2251 65.2951 94.2251 64.3685C93.7618 56.4933 98.8576 49.5445 106.27 47.2283L128.042 38.8898C130.359 37.9633 132.675 37.5 134.991 37.5C141.94 37.5 148.425 41.206 151.668 47.6915L160.47 66.2216C163.249 71.7806 163.249 78.7293 160.007 83.8251C156.764 88.9208 151.668 91.7003 146.109 92.1636C144.719 94.9431 143.793 97.7226 142.403 100.039C139.624 106.524 136.844 113.01 133.601 119.959C121.557 144.974 108.123 161.651 79.8643 161.651Z" fill="#07401A"/> +<path d="M79.8641 145.438C97.4676 145.438 107.196 137.562 118.777 113.01C125.263 99.5758 130.358 86.1415 136.381 73.1705L143.793 75.4867C145.646 75.95 147.035 75.0235 146.109 73.1705L136.844 55.1037C136.381 53.714 134.528 53.714 133.601 54.1772L111.365 62.5157C109.512 62.979 109.512 64.832 111.365 65.2952L117.851 67.6115C112.292 79.656 105.806 98.186 100.247 109.767C94.2249 122.738 91.4454 131.54 80.7906 131.54C70.1359 131.54 68.7461 123.665 73.3786 112.084C78.4744 98.6493 86.8129 94.9433 96.0779 97.7228C98.8574 94.0168 100.71 88.4578 101.637 83.362C98.8574 82.4355 95.6146 82.4355 92.8351 82.4355C77.5479 82.4355 62.2606 90.3108 55.7751 106.988C48.8263 128.761 56.2383 145.438 79.8641 145.438Z" fill="#D0EC1A"/> +</svg> diff --git a/docs/scripts/check-content.ts b/docs/scripts/check-content.ts new file mode 100644 index 00000000..b245deff --- /dev/null +++ b/docs/scripts/check-content.ts @@ -0,0 +1,71 @@ +/** + * Content checks that don't need a build (`bun run check` runs them after tsc): + * + * - every content/<version>/*.mdx has valid frontmatter (build/manifest.ts throws otherwise); + * - each page has exactly one `# ` h1, and its text equals the frontmatter `title`; + * - links to other doc pages (`](/next/x#y)`, `href="/next/x#y"` or `<Hosted to="/next/x#y">`) point at a page that exists, + * and the #fragment at one of its headings (Roadmap links are checked by scripts/check-roadmap.ts, and every fragment after the build, + * by scripts/postbuild.ts, which checks every link in the rendered HTML); + * - nothing that must not be published: local filesystem paths. + */ +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { loadManifest, manifestPaths } from '../build/manifest.ts' +import { createSlugger } from '../build/slugify.ts' +import { ROADMAP_PATHS } from '../components/roadmap/sections.ts' +import { SITE_ROOT } from './site-files.ts' + +const manifest = loadManifest(SITE_ROOT) +const pages = Object.values(manifest.versions).flatMap((v) => v.pages) +const known = new Set(['/', '/roadmap', ...ROADMAP_PATHS, ...manifestPaths(manifest), ...Object.keys(manifest.versions).map((v) => `/${v}/`)]) +const problems: string[] = [] + +/** Heading text as the slugger sees it: Markdown inline syntax stripped. */ +const plain = (md: string) => + md + .replace(/`([^`]*)`/g, '$1') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[*_]{1,2}([^*_]+)[*_]{1,2}/g, '$1') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim() + +const idsByPath = new Map<string, Set<string>>() +const sources = new Map<string, string>() +for (const p of pages) { + const src = readFileSync(path.join(SITE_ROOT, p.file), 'utf8') + sources.set(p.file, src) + const body = src.replace(/^---[\s\S]*?---\r?\n/, '').replace(/```[\s\S]*?```/g, '') + const h1s = [...body.matchAll(/^# (.+)$/gm)].map((m) => plain(m[1])) + if (h1s.length !== 1) problems.push(`${p.file}: expected exactly one "# Title" line, found ${h1s.length}`) + else if (h1s[0] !== p.title) problems.push(`${p.file}: h1 "${h1s[0]}" differs from frontmatter title "${p.title}"`) + const slug = createSlugger() + const ids = new Set<string>() + for (const m of body.matchAll(/<h[1-3][^>]*\sid="([^"]+)"/g)) ids.add(slug('', m[1])) + for (const m of body.matchAll(/^(#{1,3}) (.+)$/gm)) ids.add(slug(plain(m[2]))) + idsByPath.set(p.path, ids) +} + +for (const p of pages) { + const src = sources.get(p.file)! + // Links inside fenced code are example code (an href in a JSX snippet), not doc links. + const prose = src.replace(/```[\s\S]*?```/g, '') + const links = [...prose.matchAll(/\]\((\/[^)\s]*)\)/g), ...prose.matchAll(/href="(\/[^"]*)"/g), ...prose.matchAll(/<Hosted\b[^>]*?\bto="(\/[^"]*)"/g)].map((m) => m[1]) + for (const href of links) { + const [route, frag] = href.split('#') + if (!known.has(route)) { + problems.push(`${p.file}: link to ${href}: no such page`) + continue + } + if (frag && !route.startsWith('/roadmap') && route !== '/' && !idsByPath.get(route)?.has(frag)) + problems.push(`${p.file}: link to ${href}: no heading with id "${frag}" on ${route}`) + } + if (/(?:\/Users\/|\/home\/[a-z]|[A-Z]:\\Users\\)/.test(src)) problems.push(`${p.file}: contains a local filesystem path`) +} + +const unique = [...new Set(problems)] +if (unique.length) { + console.error(`${unique.length} content problem${unique.length === 1 ? '' : 's'}:\n${unique.map((p) => ` - ${p}`).join('\n')}`) + process.exit(1) +} +console.log(`content: ok (${pages.length} pages in ${Object.keys(manifest.versions).length} versions)`) diff --git a/docs/scripts/check-roadmap.ts b/docs/scripts/check-roadmap.ts new file mode 100644 index 00000000..3d542df4 --- /dev/null +++ b/docs/scripts/check-roadmap.ts @@ -0,0 +1,195 @@ +/** + * The Roadmap's self-checks (a port of the former Python Roadmap generator's). Run by `bun run check` and at the + * start of `bun run build`; fails loudly when the data doesn't hold together. + * + * 1. Data (components/roadmap/model.ts throws): statuses, sections, unknown ids, broken references, + * the counts and links the copy depends on. + * 2. The rendered pages: every section is rendered with React (as the app renders it) and the + * markup checked: wording, labels, ids (unique, and each on the page its URL says), the to-do, + * note, detail-list, row and tag counts, section counts, and every internal link. + * 3. Links from the docs into the Roadmap (`/roadmap#id`, `/roadmap/<section>#id` in content/): + * the id exists, on the page the link resolves to. + * + * Links into docs pages that don't exist (content/next/<id>.mdx) fail too, unless DOCS_LINKS=warn + * (as for the post-build link check). Prints a summary. + */ +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { renderToStaticMarkup } from 'react-dom/server' +import data from '../data/roadmap.json' +import { loadManifest, manifestPaths } from '../build/manifest.ts' +import { createRoadmap, docLabelsFromManifest, plain, RoadmapDataError, unescapeHtml, VC, type Roadmap, type RoadmapData } from '../components/roadmap/model.ts' +import { sectionView } from '../components/roadmap/SectionViews.tsx' +import { roadmapTarget, SECTION_IDS, SECTION_NAV, sectionForSlug, sectionOfId, sectionPath, type SectionId } from '../components/roadmap/sections.ts' +import { SITE_ROOT } from './site-files.ts' + +const problems: string[] = [] +const check = (cond: unknown, msg: string) => { + if (!cond) problems.push(msg) +} +const fail = () => { + if (!problems.length) return + console.error(`${problems.length} roadmap problem${problems.length === 1 ? '' : 's'}:\n${problems.map((p) => ` - ${p}`).join('\n')}`) + process.exit(1) +} + +// ------------------------------------------------------------------------------ 1. data +const manifest = loadManifest(SITE_ROOT) +let rm: Roadmap +try { + rm = createRoadmap(data as unknown as RoadmapData, { docs: docLabelsFromManifest(manifest), base: '/' }) +} catch (e) { + if (e instanceof RoadmapDataError) { + console.error(e.message) + process.exit(1) + } + throw e +} + +// sections.ts keeps a copy of the sidebar labels (for document titles without the data). +for (const id of SECTION_IDS) + check(SECTION_NAV[id] === rm.SEC[id].nav, `sections.ts SECTION_NAV['${id}'] is ${JSON.stringify(SECTION_NAV[id])}, data/roadmap.json says ${JSON.stringify(rm.SEC[id].nav)}`) + +// ------------------------------------------------------------------------------ 2. rendered pages +const pages = new Map<SectionId, string>(SECTION_IDS.map((id) => [id, renderToStaticMarkup(sectionView(rm, id).article)])) +const out = [...pages.values()].join('\n') +const text = unescapeHtml(out.replace(/<[^>]+>/g, ' ')) +const near = (re: RegExp) => [...text.matchAll(new RegExp(`.{0,40}(?:${re.source}).{0,40}`, `g${re.flags.replace('g', '')}`))].slice(0, 5).map((m) => m[0]) + +// Wording: these docs are the spec here, anchors are links not "#ids", and no undefined abbreviations. +for (const [re, what] of [ + [/\bspec\b/i, "'spec'"], + [/(?<![\w&])#[a-z]/, 'a bare #anchor'], + [/\b(?:IS|hCMS|ALS)\b|N\/A/, 'an undefined abbreviation'], +] as const) + check(!re.test(text), `${what} in the page text: ${JSON.stringify(near(re))}`) +check(!out.includes('{{') && !out.includes('}}'), 'an unexpanded {{placeholder}}') + +// No "gap" wording as a label: headings, eyebrows, nav names, detail labels, pills, buttons, tags. +const labels = [ + ...[...out.matchAll(/<(h[123])\b[^>]*>(.*?)<\/\1>/g)].map((m) => m[2]), + ...[...out.matchAll(/<(dt|button|b class="gx-vp[^"]*"|span class="gx-kind[^"]*"|i class="gx-tg[^"]*"|p class="eyebrow[^"]*")[^>]*>(.*?)<\//g)].map((m) => m[2]), + ...SECTION_IDS.map((id) => rm.SEC[id].nav), +] +const badLab = labels.filter((x) => /\bgaps?\b/i.test(plain(x))) +check(!badLab.length, `'gap' in a label: ${JSON.stringify(badLab)}`) + +// Ids: unique across the Roadmap, and each on the page its URL names (sections.ts derives the +// page from the id, so links to /roadmap#id can be resolved without the data). +const idsOn = new Map<SectionId, Set<string>>() +const seen = new Map<string, SectionId>() +for (const [sec, html] of pages) { + const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((m) => unescapeHtml(m[1])) + idsOn.set(sec, new Set(ids)) + for (const id of ids) { + check(!seen.has(id), `duplicate id ${id} (${seen.get(id)} and ${sec})`) + seen.set(id, sec) + check(sectionOfId(id) === sec, `id ${id} is on ${sectionPath(sec)}, but its URL would be ${roadmapTarget(id)?.to ?? '(none)'}`) + } + check(html.startsWith(`<article id="${sec}"`), `${sec}: the page's article must carry the section id`) +} + +// Every to-do on the pages. The page never shows this sum: the lists overlap (blockers and Studio +// items are delivered by work items; site steps lean on the same work), so each section shows only its own count. +const nNotes = rm.nNotes +const nTodo = rm.TOP.length + rm.STUDIO_NEW.length + rm.STUDIO_LEGACY.length + rm.CHANGES.length + rm.nSteps - nNotes +// A to-do is an <li> whose first child is the screen-reader "To do" and whose second is its heading. +const todos = [...out.matchAll(/<li class="(?!rm-note)[^"]*"><span class="sr-only" data-pagefind-ignore="">To do<\/span><h2 id="([^"]+)"/g)].map((m) => m[1]) +check(todos.length === nTodo && out.split('>To do</span><h2 ').length - 1 === nTodo, `to-dos: ${todos.length} rendered, ${nTodo} in the data`) +check([...out.matchAll(/<li class="rm-note [^"]*"><span class="sr-only" data-pagefind-ignore="">Note<\/span><h2 /g)].length === nNotes, 'notes') +// No row reads as work that was done and undone, and no grand total appears. +check(!new RegExp(`\\b(?:${nTodo}|${nTodo + nNotes})\\b`).test(text), `the page shows a to-do total (${nTodo})`) +check(!/\bWas “/.test(text), "a 'Was “...”' tag") +check(out.split('<dl class="gx-wf ').length - 1 === rm.TOP.length + rm.STUDIO_NEW.length + rm.STUDIO_LEGACY.length + rm.CHANGES.length, 'detail lists') +const rows = [...out.matchAll(/<li id="roadmap-f-([^"]+)"/g)].map((m) => m[1]) +check(rows.length === rm.FIDS.length && new Set(rows).size === rm.FIDS.length && rm.FIDS.every((f) => rows.includes(f)), 'feature rows') +const rowV: Record<string, number> = {} +for (const m of out.matchAll(/<li id="roadmap-f-[^"]+" data-v="([^"]+)"/g)) rowV[m[1]] = (rowV[m[1]] ?? 0) + 1 +const wantV = Object.fromEntries(Object.entries(rm.TOTALS).filter(([, n]) => n).map(([k, n]) => [VC[k], n])) +check(JSON.stringify(Object.entries(rowV).sort()) === JSON.stringify(Object.entries(wantV).sort()), `row statuses: ${JSON.stringify(rowV)}`) + +// Chips: every list has at least one chip, and each names a feature row. +const chipLists = [...out.matchAll(/<div class="gx-chips [^"]*"[^>]*>(.*?)<\/div>/g)].map((m) => m[1]) +check(chipLists.length === out.split('class="gx-chips ').length - 1 && chipLists.every((c) => c.includes('<a ')), 'empty chip lists') +const chipTargets = chipLists.flatMap((c) => [...c.matchAll(/href="\/roadmap\/features#roadmap-f-([^"]+)"/g)].map((m) => m[1])) +check(chipTargets.length && chipTargets.every((t) => t in rm.FEATS), 'chip targets') + +const nTags: Record<string, number> = {} +for (const m of out.matchAll(/<i class="gx-tg [^"]*"[^>]*>([A-Z][a-z]+)/g)) nTags[m[1]] = (nTags[m[1]] ?? 0) + 1 +const F = Object.values(rm.FEATS) +check( + (nTags.Medium ?? 0) === F.filter((f) => f.confidence !== 'high').length && + (nTags.Rated ?? 0) === F.filter((f) => f.rated_before_review).length && + (nTags.Unconfirmed ?? 0) === F.filter((f) => f.unconfirmed_sub_claim).length && + (nTags.First ?? 0) === F.filter((f) => f.first_rated).length, + `row tags: ${JSON.stringify(nTags)}`, +) +const countsBySec: Record<string, string> = {} +for (const [sec, html] of pages) { + const m = /^<article[^>]*><p class="eyebrow [^"]*">[^<]*<span class="rm-count [^"]*"[^>]*>([^<]+)<\/span>/.exec(html) + if (m) countsBySec[sec] = m[1] +} +check(JSON.stringify(Object.keys(countsBySec)) === JSON.stringify(SECTION_IDS.slice(1)), 'every section but the overview shows its own count') +// Every "Part of blocker" link is a blocker whose "Delivered by" names that work item, and back. +check([...rm.CLEARS.values()].reduce((n, v) => n + v.length, 0) === rm.TOP.reduce((n, b) => n + b.delivered_by.length, 0), 'blocker back-links') + +// Every internal link resolves: Roadmap links to an id on the page they name, docs links to a page. +const docPaths = new Set(manifestPaths(manifest)) +let nLinks = 0 +const docLinks = new Set<string>() +for (const [sec, html] of pages) { + for (const m of html.matchAll(/<a\b[^>]*?\shref="([^"]*)"/g)) { + const href = unescapeHtml(m[1]) + nLinks++ + const [p, frag] = href.split('#') as [string, string | undefined] + if (p === '') { + check(frag && idsOn.get(sec)!.has(frag), `${sectionPath(sec)}: link to #${frag}: no such id on the page`) + } else if (p.startsWith('/roadmap/')) { + const target = sectionForSlug(p) + check(target, `${sectionPath(sec)}: link to ${href}: no such Roadmap page`) + if (target && frag) check(idsOn.get(target)!.has(frag), `${sectionPath(sec)}: link to ${href}: no #${frag} on that page`) + } else if (p.startsWith('/next/')) { + docLinks.add(p.slice('/next/'.length)) + if (!docPaths.has(p)) rm.docProblems.add(`${sectionPath(sec)}: link to ${href}: no such docs page`) + } else check(/^[a-z]+:/.test(href), `${sectionPath(sec)}: unexpected link ${href}`) + } +} + +// ------------------------------------------------------------------------------ 3. docs -> Roadmap +const contentFiles = (dir: string): string[] => + readdirSync(dir, { withFileTypes: true }).flatMap((e) => (e.isDirectory() ? contentFiles(path.join(dir, e.name)) : e.name.endsWith('.mdx') ? [path.join(dir, e.name)] : [])) +let nIn = 0 +for (const file of contentFiles(path.join(SITE_ROOT, 'content'))) { + const src = readFileSync(file, 'utf8') + const rel = path.relative(SITE_ROOT, file) + for (const m of [...src.matchAll(/\]\((\/roadmap[^)\s]*)\)/g), ...src.matchAll(/href="(\/roadmap[^"]*)"/g)]) { + nIn++ + const [p, frag] = m[1].split('#') as [string, string | undefined] + const page = p === '/roadmap' ? (frag ? sectionOfId(frag) : 'roadmap') : sectionForSlug(p) + if (!page) { + problems.push(`${rel}: link to ${m[1]}: no such Roadmap page or id`) + continue + } + if (frag && !idsOn.get(page)!.has(frag)) problems.push(`${rel}: link to ${m[1]}: no #${frag} on ${sectionPath(page)}`) + } +} + +// ------------------------------------------------------------------------------ result +if (rm.docProblems.size) { + const list = [...rm.docProblems].map((p) => `docs: ${p}`) + if (process.env.DOCS_LINKS === 'warn') console.warn(`${list.length} roadmap link${list.length === 1 ? '' : 's'} into missing docs pages:\n${list.map((p) => ` - ${p}`).join('\n')}`) + else problems.push(...list) +} +fail() + +const vlabel = (k: string) => rm.VLABEL(k) +console.log( + `roadmap: ok — ${SECTION_IDS.length} pages: ${SECTION_IDS.map((s) => `${sectionPath(s)}${countsBySec[s] ? ` [${countsBySec[s]}]` : ''}`).join(', ')}`, +) +console.log( + ` to-dos: ${todos.length} (blockers ${rm.TOP.length}, studio ${rm.STUDIO_NEW.length}+${rm.STUDIO_LEGACY.length}, work items ${rm.CHANGES.length}, site steps ${rm.REPOS.map((r) => rm.PLAN[r].length).join('+')}) rows: ${rows.length} chips: ${chipTargets.length} internal links: ${nLinks} links in from the docs: ${nIn}`, +) +console.log( + ` statuses: ${JSON.stringify(Object.fromEntries(Object.entries(rm.TOTALS).map(([k, v]) => [vlabel(k), v])))} row tags: ${JSON.stringify(nTags)}`, +) +console.log(` links into the docs: ${[...docLinks].sort().join(', ')}`) diff --git a/docs/scripts/postbuild.ts b/docs/scripts/postbuild.ts new file mode 100644 index 00000000..c3bf99b3 --- /dev/null +++ b/docs/scripts/postbuild.ts @@ -0,0 +1,163 @@ +/** + * Runs after `vite build` (which prerenders every page into dist/client): + * + * 0. 404.html: GitHub Pages serves it for any unknown path. It's rendered by the built server + * handler for a URL no route matches, so it is exactly the root's not-found state, which is + * what the browser hydrates it as (prerendering a /404 route would hydrate with a mismatch). + * 1. Link check: every internal link (href starting with the base path) must reach a page, and + * its #fragment, if any, an id on that page. All failures are listed; the build fails on any. + * DOCS_LINKS=warn reports them without failing (handy while pages are still being ported). + * 2. Size budget: the JS every page loads (the chunks all pages reference) stays under a gzip + * budget, and carries none of the Roadmap's data (which belongs in the Roadmap's route chunk); + * no page's prerendered HTML goes over its own gzip budget (utility classes live in it). + * 3. Search index: Pagefind indexes the prerendered HTML (only elements marked + * data-pagefind-body, i.e. doc articles and whatever else opts in) and writes + * dist/client/pagefind/. Result URLs are the real routes (/next/quickstart, not + * next/quickstart.html); the Pagefind client prepends the base path it's served under. + * 4. Redirects: a removed or merged page keeps its old URL working through a small page that + * sends the reader on (GitHub Pages has no server-side redirects). Written last, so the link + * check, size budget and search index never see them; the build fails if a target is missing. + */ +import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { gzipSync } from 'node:zlib' +import path from 'node:path' +import * as pagefind from 'pagefind' +import { BASE, OUT_DIR, SITE_ROOT, fileFor, htmlFiles, redirectFor, routeOf } from './site-files.ts' + +// ---------------------------------------------------------------- 0. 404.html +{ + const server = (await import(path.join(SITE_ROOT, 'dist', 'server', 'server.js'))) as { default: { fetch: (r: Request) => Promise<Response> } } + const res = await server.default.fetch(new Request(`http://localhost${BASE}__not-found__`)) + if (res.status !== 404) throw new Error(`404 page: expected status 404, got ${res.status}`) + writeFileSync(path.join(OUT_DIR, '404.html'), await res.text()) +} + +const decode = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>') + +const files = htmlFiles() +if (!files.length) throw new Error(`No HTML in ${OUT_DIR}; run vite build first`) + +// ---------------------------------------------------------------- 1. links +const idsByFile = new Map<string, Set<string>>() +const htmlByFile = new Map<string, string>() +for (const f of files) { + const html = readFileSync(f, 'utf8') + htmlByFile.set(f, html) + idsByFile.set(f, new Set([...html.matchAll(/\sid="([^"]+)"/g)].map((m) => decode(m[1])))) +} + +const broken: string[] = [] +for (const [file, html] of htmlByFile) { + const from = BASE.replace(/\/$/, '') + routeOf(file) + for (const m of html.matchAll(/<a\b[^>]*?\shref="([^"]*)"/g)) { + const href = decode(m[1]) + if (!href || /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) continue + let target = file + let frag = '' + if (href.startsWith('#')) frag = href.slice(1) + else if (href.startsWith('/')) { + if (!href.startsWith(BASE)) { + broken.push(`${from}: ${href} (outside the base path ${BASE})`) + continue + } + const [p, h = ''] = href.slice(BASE.length - 1).split('#') + // A directory without its slash: Pages redirects to the slash form, so follow it. + const redirect = redirectFor(p) + const f = fileFor(redirect ?? p) + if (!f || path.basename(f) === '404.html') { + broken.push(`${from}: ${href} (no such page)`) + continue + } + target = f + frag = h + } else continue // relative links aren't used by the app + if (frag && frag !== 'main' && !idsByFile.get(target)?.has(decodeURIComponent(frag))) broken.push(`${from}: ${href} (no #${frag} on that page)`) + } +} +const uniq = [...new Set(broken)] +if (uniq.length) { + const strict = process.env.DOCS_LINKS !== 'warn' + console[strict ? 'error' : 'warn'](`\n${uniq.length} broken link${uniq.length === 1 ? '' : 's'}:\n${uniq.map((b) => ` - ${b}`).join('\n')}\n`) + if (strict) process.exit(1) +} else console.log(`links: ok (${files.length} pages)`) + +// ---------------------------------------------------------------- 2. size budget +{ + const BUDGET_GZIP = 160 * 1024 + // The heaviest page (/roadmap/features) is ~40KB gzipped; repeated markup that needs a long + // class string belongs in a named class (src/styles/components/docs.css), not inline. + const HTML_BUDGET_GZIP = 48 * 1024 + // Strings only the Roadmap's data and views contain. + const ROADMAP_MARKERS = ['No feature matches both filters', 'Studio builds its forms'] + const scriptsOf = (html: string) => + new Set([...html.matchAll(/<(?:script|link)\b[^>]*?\s(?:src|href)="([^"]+\.js)"/g)].map((m) => decode(m[1]))) + const pages = [...htmlByFile].filter(([f]) => path.basename(f) !== '404.html').map(([, html]) => scriptsOf(html)) + const shared = [...pages[0]].filter((src) => pages.every((p) => p.has(src))) + let gz = 0 + const problems: string[] = [] + for (const src of shared) { + const f = path.join(OUT_DIR, src.slice(BASE.length)) + if (!f.startsWith(OUT_DIR) || !statSync(f, { throwIfNoEntry: false })?.isFile()) continue + const code = readFileSync(f) + gz += gzipSync(code).length + const text = code.toString('utf8') + for (const m of ROADMAP_MARKERS) if (text.includes(m)) problems.push(`${src} (loaded by every page) contains Roadmap data ("${m}")`) + } + if (!shared.length) problems.push('found no script every page loads (did the HTML change shape?)') + if (gz > BUDGET_GZIP) problems.push(`the JS every page loads is ${(gz / 1024).toFixed(0)}KB gzipped, over the ${BUDGET_GZIP / 1024}KB budget`) + let heaviest = { file: '', gz: 0 } + for (const [file, html] of htmlByFile) { + const size = gzipSync(html).length + if (size > heaviest.gz) heaviest = { file: path.relative(OUT_DIR, file), gz: size } + if (size > HTML_BUDGET_GZIP) + problems.push(`${path.relative(OUT_DIR, file)} is ${(size / 1024).toFixed(0)}KB gzipped, over the ${HTML_BUDGET_GZIP / 1024}KB page budget`) + } + if (problems.length) { + console.error(`\nsize: ${problems.join('\n ')}\n`) + process.exit(1) + } + console.log( + `size: ok (${shared.length} shared scripts, ${(gz / 1024).toFixed(0)}KB gzipped; heaviest page ${heaviest.file}, ${(heaviest.gz / 1024).toFixed(0)}KB gzipped)`, + ) +} + +// ---------------------------------------------------------------- 3. search index +// Index what the reader sees in the content, not the chrome around it: code-panel headers and copy +// buttons, heading permalinks, the inline "On this page" outline (it repeats the headings), and +// the Roadmap's metadata/chips/counts (the old app.js search left out the same things). The Roadmap +// marks those with data-pagefind-ignore in its markup (components/roadmap/SectionViews.tsx). +const EXCLUDE = ['.toc-inline', '.code-head', '.copy-button', '.code-lang', '.heading-anchor'] +const { index, errors } = await pagefind.createIndex({ forceLanguage: 'en', excludeSelectors: EXCLUDE }) +if (!index) throw new Error(`pagefind: ${errors.join(', ')}`) +let indexed = 0 +for (const [file, content] of htmlByFile) { + if (path.basename(file) === '404.html') continue + // Without the base path: Pagefind's client prepends its own location's parent ("/blocks/"). + const url = routeOf(file) + const res = await index.addHTMLFile({ url, content }) + if (res.errors.length) throw new Error(`pagefind ${url}: ${res.errors.join(', ')}`) + indexed++ +} +const written = await index.writeFiles({ outputPath: path.join(OUT_DIR, 'pagefind') }) +if (written.errors.length) throw new Error(`pagefind: ${written.errors.join(', ')}`) +await pagefind.close() +console.log(`search: indexed ${indexed} pages -> ${path.relative(process.cwd(), path.join(OUT_DIR, 'pagefind'))}/`) + +// ---------------------------------------------------------------- 4. redirects +// Old path -> new path, both without the base path, in the form pages are served at +// (`/next/x`, written as next/x.html; a path ending in `/` is written as <path>/index.html). +const REDIRECTS: Record<string, string> = { + '/next/caching-and-observability': '/next/caching', +} +for (const [from, to] of Object.entries(REDIRECTS)) { + if (!fileFor(to)) throw new Error(`redirect ${from} -> ${to}: no page at ${to}`) + if (fileFor(from)) throw new Error(`redirect ${from} -> ${to}: a page still exists at ${from}`) + const href = BASE.replace(/\/$/, '') + to + const out = from.endsWith('/') ? path.join(OUT_DIR, from, 'index.html') : path.join(OUT_DIR, `${from}.html`) + mkdirSync(path.dirname(out), { recursive: true }) + writeFileSync( + out, + `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Moved

This page moved to ${href}.

`, + ) +} +console.log(`redirects: ${Object.keys(REDIRECTS).length} written`) diff --git a/docs/scripts/preview.ts b/docs/scripts/preview.ts new file mode 100644 index 00000000..7ec04462 --- /dev/null +++ b/docs/scripts/preview.ts @@ -0,0 +1,30 @@ +/** + * Serves dist/client the way GitHub Pages does: under the base path, `/x` -> x or x.html, `/x/` -> + * x/index.html, `/x` -> 301 `/x/` for a directory, and 404.html (status 404) for anything else. `bun run preview`, then open the URL. + * PORT=… to change the port (default 4173). + */ +import { existsSync } from 'node:fs' +import { BASE, OUT_DIR, fileFor, redirectFor } from './site-files.ts' + +if (!existsSync(OUT_DIR)) { + console.error(`${OUT_DIR} doesn't exist; run \`bun run build\` first`) + process.exit(1) +} + +const port = Number(process.env.PORT ?? 4173) +const server = Bun.serve({ + port, + fetch(req) { + const url = new URL(req.url) + if (BASE !== '/' && (url.pathname === BASE.slice(0, -1))) return Response.redirect(`${BASE}${url.search}`, 301) + if (!url.pathname.startsWith(BASE)) return new Response('Not found (outside the base path)', { status: 404 }) + const sitePath = url.pathname.slice(BASE.length - 1) + const file = fileFor(sitePath) + if (file) return new Response(Bun.file(file)) + const redirect = redirectFor(sitePath) + if (redirect) return Response.redirect(`${BASE.slice(0, -1)}${redirect}${url.search}`, 301) + const notFound = fileFor('/404.html') + return notFound ? new Response(Bun.file(notFound), { status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } }) : new Response('Not found', { status: 404 }) + }, +}) +console.log(`Serving ${OUT_DIR} at http://localhost:${server.port}${BASE}`) diff --git a/docs/scripts/site-files.ts b/docs/scripts/site-files.ts new file mode 100644 index 00000000..bcba7156 --- /dev/null +++ b/docs/scripts/site-files.ts @@ -0,0 +1,64 @@ +/** + * Shared helpers for the post-build scripts: where the static site is, how a URL maps to a file + * (the same lookup and trailing-slash redirect GitHub Pages does), and the base path. + */ +import { existsSync, readdirSync, statSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +export const SITE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +/** What `vite build` writes (TanStack Start's client output). Deploy this folder. */ +export const OUT_DIR = path.join(SITE_ROOT, 'dist', 'client') + +/** "/blocks/" or "/", the same normalisation as vite.config.ts. */ +export const BASE = `/${(process.env.BASE_PATH ?? '/').replace(/^\/+|\/+$/g, '')}/`.replace(/^\/\/$/, '/') + +export function htmlFiles(dir = OUT_DIR): string[] { + const out: string[] = [] + for (const name of readdirSync(dir)) { + const p = path.join(dir, name) + if (statSync(p).isDirectory()) { + if (name !== 'assets' && name !== 'pagefind') out.push(...htmlFiles(p)) + } else if (name.endsWith('.html')) out.push(p) + } + return out +} + +/** dist/client/next/quickstart.html -> "/next/quickstart"; v7/index.html -> "/v7/"; index.html -> "/" (no base). */ +export function routeOf(file: string): string { + const rel = path.relative(OUT_DIR, file).split(path.sep).join('/') + if (rel === 'index.html') return '/' + if (rel.endsWith('/index.html')) return `/${rel.slice(0, -'index.html'.length)}` + return `/${rel.slice(0, -'.html'.length)}` +} + +const isFile = (rel: string) => { + const p = path.join(OUT_DIR, rel) + return p.startsWith(OUT_DIR) && existsSync(p) && statSync(p).isFile() ? p : undefined +} +const isDir = (rel: string) => { + const p = path.join(OUT_DIR, rel) + return p.startsWith(OUT_DIR) && existsSync(p) && statSync(p).isDirectory() +} +const cleanPath = (sitePath: string) => decodeURIComponent(sitePath.split(/[?#]/)[0]).replace(/^\/+/, '') + +/** + * A site path (without base) -> the file that serves it, the way GitHub Pages looks it up: + * `/x/` -> x/index.html only; `/x` -> x, then x.html. Undefined if Pages would 404 (or redirect: + * see `redirectFor`). + */ +export function fileFor(sitePath: string): string | undefined { + const clean = cleanPath(sitePath) + if (clean === '' || clean.endsWith('/')) return isFile(`${clean}index.html`) + return isFile(clean) ?? isFile(`${clean}.html`) +} + +/** + * The trailing-slash redirect GitHub Pages does (301): `/x` with no x or x.html, but a directory x + * holding index.html, -> "/x/" (without base). Undefined otherwise. + */ +export function redirectFor(sitePath: string): string | undefined { + const clean = cleanPath(sitePath) + if (clean === '' || clean.endsWith('/') || fileFor(sitePath)) return undefined + return isDir(clean) && isFile(`${clean}/index.html`) ? `/${clean}/` : undefined +} diff --git a/docs/src/env.d.ts b/docs/src/env.d.ts new file mode 100644 index 00000000..ed266f4f --- /dev/null +++ b/docs/src/env.d.ts @@ -0,0 +1,18 @@ +/// + +declare module 'virtual:content-manifest' { + import type { Manifest } from '~/build/manifest' + const manifest: Manifest + export default manifest +} + +declare module '*.mdx' { + import type { ComponentType } from 'react' + import type { MDXComponents } from 'mdx/types' + import type { DocHeading } from '~/build/rehype-docs' + import type { PageFrontmatter } from '~/build/manifest' + export const frontmatter: PageFrontmatter + export const headings: DocHeading[] + const MDXContent: ComponentType<{ components?: MDXComponents }> + export default MDXContent +} diff --git a/docs/src/layout/DocPage.tsx b/docs/src/layout/DocPage.tsx new file mode 100644 index 00000000..d30ed012 --- /dev/null +++ b/docs/src/layout/DocPage.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react' +import type { ManifestPage } from '~/build/manifest' +import { Eyebrow, mdxComponents } from '~/components/mdx' +import { getLoadedPage } from '~/src/lib/content' +import { breadcrumbFor, pagerFor, railFor, sidebarFor } from '~/src/lib/nav' +import { DocsShell } from './DocsShell' + +/** + * One MDX page in the docs shell: sidebar of its kind, breadcrumb, article, pager, rail. + * `indexable: false` keeps a duplicate (a version index showing its first page) out of search. + */ +export function DocPage({ page, indexable = true }: { page: ManifestPage; indexable?: boolean }) { + const mod = getLoadedPage(page) + const Content = mod.default + // A deep link to a heading inside a closed
opens it, so the target can be seen. + useEffect(() => { + const id = decodeURIComponent(location.hash.slice(1)) + const target = id ? document.getElementById(id) : null + for (let d = target?.closest('details'); d; d = d.parentElement?.closest('details')) d.open = true + }, [page.file]) + return ( + +
+ {page.eyebrow} + +
+
+ ) +} diff --git a/docs/src/layout/DocsShell.tsx b/docs/src/layout/DocsShell.tsx new file mode 100644 index 00000000..e5d26b2d --- /dev/null +++ b/docs/src/layout/DocsShell.tsx @@ -0,0 +1,165 @@ +import type { ReactNode } from 'react' +import { Link } from '@tanstack/react-router' +import { Icon } from '~/components/ui/Icon' +import type { Crumb, NavGroup, PagerLink, RailItem } from '~/src/lib/nav' +import { cx, prefersReducedMotion } from '~/src/lib/ui' +import { Sidebar } from './Sidebar' +import { Rail, RailContext } from './Rail' +import { PageTools } from './PageTools' +import { PlainLink } from './PlainLink' + +export interface DocsShellProps { + /** Sidebar groups (the current page's item has `active: true`). */ + nav: NavGroup[] + /** Breadcrumb trail; the last crumb is the current page. */ + crumbs: Crumb[] + /** Previous/next cards under the article. */ + pager?: { prev?: PagerLink; next?: PagerLink } + /** "On this page" entries (first = Overview, the top of the article). Also feeds . */ + rail: RailItem[] + /** The article, normally
…
. */ + children: ReactNode +} + +/** + * The three-column docs layout: sidebar · main (breadcrumb, page tools, article, pager, footer) + * · "On this page" rail. Used by the doc pages, the Roadmap and the 404. The page's route chrome + * should be `layout: 'docs'` (the header and drawer follow it). + * + * Width: the sidebar sits at the shell's left edge and the rail at its right edge; the middle + * column takes the rest. The shell fills the window up to 1920px (max-w-shell), then centres. + * Everything in main shares one reading column (text, code, tables, callouts, pager): + * max-w-article (720px), max-w-article-wide (800px) from 1600px, centred in the middle column. + */ +export function DocsShell({ nav, crumbs, pager, rail, children }: DocsShellProps) { + return ( + +
+ +
+
+
+ + +
+
+ {children} + +
+ Deco CMS + +
+
+ +
+
+ ) +} + +/** + * The landing layout: no grid, no rail; the sidebar exists only as the mobile drawer (it shows + * `nav`, normally the Docs groups). `footer` renders after the shell (the site footer). + */ +export function LandingShell({ nav, children, footer }: { nav: NavGroup[]; children: ReactNode; footer?: ReactNode }) { + return ( + <> +
+ +
+ {children} +
+
+ {footer} + + ) +} + +export function Breadcrumb({ crumbs }: { crumbs: Crumb[] }) { + return ( + + ) +} + +const pagerCard = + 'flex min-w-0 flex-col gap-1.5 rounded-2xl border border-border bg-surface px-6 pt-5 pb-[22px] text-fg no-underline [transition:border-color_.4s_var(--ease-out-quart),box-shadow_.5s_var(--ease-out-quart),translate_.5s_var(--ease-out-quart)] hover:-translate-y-[3px] hover:border-border-strong hover:shadow-lift' +const pagerDir = 'inline-flex items-center gap-1.5 leading-4 eyebrow-label' +const pagerTitle = 'text-21 leading-7 font-normal tracking-heading' +const pagerSub = 'text-13 leading-5 text-muted-fg' + +export function Pager({ prev, next }: { prev?: PagerLink; next?: PagerLink }) { + const nav = 'mt-18 grid grid-cols-2 gap-4 border-t border-hairline pt-8 max-md:grid-cols-1 print:hidden' + if (!prev && !next) return