From d616ef6878fbe8aca62be10220a7c5437226b78f Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 30 Sep 2026 09:50:01 -0300 Subject: [PATCH 001/132] docs(site): add the Deco Blocks docs site (Home, Docs, Under the hood, Roadmap) Adds the source of the Deco Blocks documentation site under site/ and a workflow that builds it on pull requests (downloadable artifact) and, once GitHub Pages is enabled, deploys it from main. The site is one self-contained page built with the Python standard library (python3 site/build.py -> site/dist/index.html). The Roadmap is rendered from site/data/roadmap.json by site/gen_roadmap.py, which also checks ids, counts and cross-links. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .github/workflows/pages.yml | 78 + site/README.md | 99 + site/build.py | 257 ++ site/data/roadmap.json | 4359 ++++++++++++++++++++++++++ site/gen_roadmap.py | 714 +++++ site/src/app.js | 920 ++++++ site/src/brand/favicon.svg | 5 + site/src/brand/symbol-on-dark.svg | 4 + site/src/brand/symbol-on-light.svg | 4 + site/src/brand/wordmark-on-dark.svg | 16 + site/src/brand/wordmark-on-light.svg | 16 + site/src/cobogo-defs.svg | 1 + site/src/content.html | 1426 +++++++++ site/src/dark-tokens.css | 82 + site/src/landing.html | 358 +++ site/src/roadmap.css | 311 ++ site/src/shell.html | 144 + site/src/style.css | 1532 +++++++++ 18 files changed, 10326 insertions(+) create mode 100644 .github/workflows/pages.yml create mode 100644 site/README.md create mode 100755 site/build.py create mode 100644 site/data/roadmap.json create mode 100755 site/gen_roadmap.py create mode 100644 site/src/app.js create mode 100644 site/src/brand/favicon.svg create mode 100644 site/src/brand/symbol-on-dark.svg create mode 100644 site/src/brand/symbol-on-light.svg create mode 100644 site/src/brand/wordmark-on-dark.svg create mode 100644 site/src/brand/wordmark-on-light.svg create mode 100644 site/src/cobogo-defs.svg create mode 100644 site/src/content.html create mode 100644 site/src/dark-tokens.css create mode 100644 site/src/landing.html create mode 100644 site/src/roadmap.css create mode 100644 site/src/shell.html create mode 100644 site/src/style.css diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..097c2965 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,78 @@ +name: Docs site + +# Builds the Deco Blocks docs site (site/) into one self-contained page, +# site/dist/index.html (python3 site/build.py; standard library only). +# +# - Pull requests that touch site/: build, then upload site/dist as the +# workflow artifact "docs-site" so reviewers can download the page and +# open it locally. Nothing is deployed. +# - Pushes to main that touch site/, and manual runs on main: build, then +# deploy site/dist 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: + - "site/**" + - ".github/workflows/pages.yml" + push: + branches: [main] + paths: + - "site/**" + - ".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 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Build site/dist + run: python3 site/build.py + + - name: Upload the preview (pull requests) + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: docs-site + path: site/dist + 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: site/dist + + 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 page is one self-contained file, so nothing uses this step's + # outputs (base URL, origin); it checks Pages is set up before deploying. + - uses: actions/configure-pages@v6 + + - id: deployment + uses: actions/deploy-pages@v5 diff --git a/site/README.md b/site/README.md new file mode 100644 index 00000000..a7bb3960 --- /dev/null +++ b/site/README.md @@ -0,0 +1,99 @@ +# Deco Blocks docs site + +The source of the Deco Blocks documentation site: one self-contained HTML page with four +hash-routed parts — **Home**, **Docs**, **Under the hood** and **Roadmap** — plus ⌘K search, a +light/dark theme toggle and a printable layout. It documents the proposed API for the next major +version, and the Roadmap is the to-do list that gets that API to a release. + +## Build and preview + +```sh +python3 site/build.py && open site/dist/index.html +``` + +Python 3.9 or later, standard library only; no install step. The output is a single file, +`site/dist/index.html` (`dist/` is gitignored). On Linux, use `xdg-open` instead of `open`. + +CI (`.github/workflows/pages.yml`) builds the page on every pull request that touches `site/` +and uploads it as the `docs-site` workflow artifact: download it from the run's summary page and +open `index.html`. Pushes to `main` that touch `site/`, and manual runs of the workflow on `main`, +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 | +|---|---| +| `build.py` | Assembles `dist/index.html`: fills `src/shell.html`'s placeholders, inlines and compacts the CSS and JS, and renders the Roadmap. | +| `gen_roadmap.py` | Renders the Roadmap from `data/roadmap.json` and checks it (ids, links, counts). `python3 site/gen_roadmap.py` checks the data alone and prints a summary. | +| `data/roadmap.json` | Everything the Roadmap shows. See below. | +| `src/shell.html` | The page frame: header, sidebar, "on this page" rail, search dialog. | +| `src/landing.html` | The Home page. | +| `src/content.html` | The Docs and Under-the-hood pages, and the Prism syntax-highlighting bundle (MIT, license notice inside). | +| `src/style.css`, `src/dark-tokens.css`, `src/roadmap.css` | Styles. `dark-tokens.css` is spliced into `style.css`'s dark-theme blocks. | +| `src/app.js` | Routing, sidebar, search, theme, copy/print, and the Home page's interactive examples. | +| `src/brand/*.svg`, `src/cobogo-defs.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 page 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 page's content; `gen_roadmap.py` holds its structure and the +framing sentences around the data. Run `python3 site/gen_roadmap.py` after an edit: it fails with +a message naming the item when an id, link or count doesn't hold together. + +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: `
--` (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 ``; work-item `plan`s are block HTML (`

`, `

    `), + 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 into the docs use the section ids in `src/content.html`; the build fails on a link to an + id that doesn't exist. diff --git a/site/build.py b/site/build.py new file mode 100755 index 00000000..abbca6b9 --- /dev/null +++ b/site/build.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +"""Build the Deco Blocks docs site into one self-contained page: site/dist/index.html. + + python3 site/build.py && open site/dist/index.html + +Inputs, all under site/: + src/shell.html the page frame (header, sidebar, rail, search dialog) with {{placeholders}} + src/landing.html the Home page + src/content.html the Docs and Under-the-hood pages, and the Prism bundle (copied verbatim) + src/style.css, src/dark-tokens.css, src/roadmap.css, src/app.js, src/cobogo-defs.svg, src/brand/*.svg + data/roadmap.json the Roadmap page, rendered by gen_roadmap.py + +Python 3 standard library only. The page's only external requests are the Fontshare and Google +Fonts stylesheets in shell.html. +""" +import json +import pathlib +import re +import sys +import urllib.parse + +HERE = pathlib.Path(__file__).resolve().parent +SRC = HERE / "src" +DIST = HERE / "dist" +sys.path.insert(0, str(HERE)) +sys.dont_write_bytecode = True # no __pycache__ next to the sources +import gen_roadmap # noqa: E402 + +content = (SRC / "content.html").read_text(encoding="utf-8") + + +def balanced_sections(fragment): + """Top-level
    blocks in order, balanced on
    tags.""" + out, pos = [], 0 + while True: + m = re.compile(r']*\bid="([^"]+)"').search(fragment, pos) + if not m: + return out + depth = 0 + for t in re.finditer(r"<(/?)section\b", fragment[m.start():]): + depth += -1 if t.group(1) else 1 + if depth == 0: + end = m.start() + t.end() + end = fragment.index(">", end) + 1 + out.append((m.group(1), fragment[m.start():end])) + pos = end + break + + +def page_fragment(name): + start = content.index(f'
    ') + nxt = content.find('
    ') + return content[start:end] + + +docs = balanced_sections(page_fragment("docs")) +internals = balanced_sections(page_fragment("internals")) +assert [s for s, _ in docs][:2] == ["architecture", "quickstart"], docs[:2] +assert len(docs) == 18 and len(internals) == 6, (len(docs), len(internals)) + +prism = re.search(r'', content, re.S).group(0) +assert "MIT LICENSE" in prism, "keep the Prism license notice in the bundle" + +# ------------------------------------------------------------------ roadmap page +# The fourth page ("Roadmap", the next major's to-do list) is rendered by gen_roadmap.py from +# data/roadmap.json; its styles are src/roadmap.css. The sidebar groups in app.js list these ids, +# so all of them must be present. +ROADMAP_IDS = gen_roadmap.SEC_IDS +roadmap_html = gen_roadmap.render().strip() +roadmap_ids = [s for s, _ in balanced_sections(roadmap_html)] +assert roadmap_ids == ROADMAP_IDS, ("roadmap sections differ from the sidebar's", roadmap_ids) +clash = sorted(set(roadmap_ids) & {s for s, _ in docs + internals}) +assert not clash, ("roadmap section ids collide with doc ids", clash) +roadmap_css = (SRC / "roadmap.css").read_text(encoding="utf-8") + +# ------------------------------------------------------------------ icons +P = 'fill="none" stroke="currentColor" stroke-width="{w}" stroke-linecap="round" stroke-linejoin="round"' +ICON_PATHS = { + "search": '', + "sun": '', + "moon": '', + "menu": '', + "x": '', + "copy": '', + "check": '', + "chevron-right": '', + "chevron-down": '', + "arrow-right": '', + "arrow-left": '', + "arrow-up": '', + "file": '', + "link": '', + "printer": '', + "list": '', + "git-commit": '', + "git-branch": '', + "pencil": '', + "play": '', + "box": '', + "sliders": '', + "globe": '', + "book": '', + "layers": '', + "server": '', + "cpu": '', + "hash": '', + "code": '', + "terminal": '', + "sparkle": '', + "eye": '', + "lock": '', + "zap": '', + "corner": '', + "cloud": '', + "smartphone": '', + "plus": '', +} +GITHUB = ('') + + +# Filled monochrome marks for the stack strip, drawn like the site's (fill: currentColor, 18px). +# Next.js and the git diamond are the site's own paths; the others are simple filled glyphs. +MARKS = { + "nextjs": '', + "git": '', + "cloudflare": '', + "tanstack": '', + "react": '', + "node": '', +} + + +def svg_mark(name): + return (f'') + + +def svg_icon(name, width="1.75"): + if name == "github": + return GITHUB + return (f'') + + +# ------------------------------------------------------------------ brand +def brand(name, cls): + s = (SRC / "brand" / name).read_text(encoding="utf-8") + s = re.sub(r"\s+", " ", s).strip() + s = s.replace("> <", "><") + s = re.sub(r']*?viewBox="([^"]+)"[^>]*>', + lambda m: f'
     only (a newline still separates inline elements)."""
    +    parts = re.split(r"()", text)
    +    return "".join(p if p.startswith(".*?\n", landing, re.S)
    +assert fm, "site footer not found in landing"
    +site_footer = min_html(fm.group(0).strip())
    +landing = min_html(landing[: fm.start()] + "\n" + landing[fm.end():])
    +
    +shell = min_html((SRC / "shell.html").read_text(encoding="utf-8"))
    +out = (shell
    +       .replace("{{favicon}}", favicon)
    +       .replace("{{cobogo}}", (SRC / "cobogo-defs.svg").read_text(encoding="utf-8").strip())
    +       .replace("{{wordmark}}", wordmark)
    +       .replace("{{landing}}", landing.strip())
    +       .replace("{{site_footer}}", site_footer)
    +       .replace("{{docs}}", "\n\n".join(frag for _, frag in docs))
    +       .replace("{{internals}}", "\n\n".join(frag for _, frag in internals))
    +       .replace("{{roadmap}}", roadmap_html))
    +out = fill_icons(out)
    +# Frozen pieces and code go in last, so no placeholder expansion can touch them.
    +out = out.replace("{{css}}", css).replace("{{roadmap_css}}", roadmap_css).replace("{{prism}}", prism).replace("{{js}}", js)
    +
    +assert "" not in out[out.index("") + 7:], "stray  after main"
    +left = re.findall(r"\{\{(?:css|js|prism|landing|site_footer|docs|internals|roadmap|roadmap_css|favicon|wordmark|cobogo|dark_tokens|light_tokens|icons_json|icon:[\w-]+|mark:[\w-]+)\}\}", out)
    +assert not left, left[:5]
    +
    +DIST.mkdir(exist_ok=True)
    +(DIST / "index.html").write_text(out, encoding="utf-8")
    +print(f"wrote {DIST / 'index.html'}  {len(out.encode()) / 1024:.1f} KB  docs={len(docs)} internals={len(internals)} roadmap={len(roadmap_ids)}")
    diff --git a/site/data/roadmap.json b/site/data/roadmap.json
    new file mode 100644
    index 00000000..330565d7
    --- /dev/null
    +++ b/site/data/roadmap.json
    @@ -0,0 +1,4359 @@
    +{
    +  "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": "Studio support",
    +      "title": "Studio on a next-major site"
    +    },
    +    {
    +      "id": "roadmap-studio-legacy",
    +      "nav": "With legacy content",
    +      "eyebrow": "Studio support",
    +      "title": "Studio 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": "Studio and Deco API",
    +      "eyebrow": "Work items",
    +      "title": "Studio and Deco API"
    +    },
    +    {
    +      "id": "roadmap-docs",
    +      "nav": "Docs fixes",
    +      "eyebrow": "Work items",
    +      "title": "Docs fixes and additions"
    +    },
    +    {
    +      "id": "roadmap-storefront",
    +      "nav": "storefront-tanstack",
    +      "eyebrow": "Site migrations",
    +      "title": "Migrate storefront-tanstack"
    +    },
    +    {
    +      "id": "roadmap-blog",
    +      "nav": "blog-tanstack",
    +      "eyebrow": "Site migrations",
    +      "title": "Migrate blog-tanstack"
    +    },
    +    {
    +      "id": "roadmap-faststore",
    +      "nav": "FastStore storefront",
    +      "eyebrow": "Site migrations",
    +      "title": "Migrate the FastStore storefront"
    +    },
    +    {
    +      "id": "roadmap-features",
    +      "nav": "Every feature",
    +      "eyebrow": "Feature readiness",
    +      "title": "Every feature, and where it stands"
    +    }
    +  ],
    +  "overview": {
    +    "intro": "

    The API in these docs is proposed, not released. This page is the to-do list that gets it to a release. It comes from checking the proposed API, feature by feature, against the three sites that have to migrate to it: storefront-tanstack, blog-tanstack and the FastStore storefront.

    \n

    No site can migrate on the documented API alone, because each one first hits missing pieces in Studio, the request scope or the SDK surface. The ten release blockers gate the release; the status of every feature the three sites use is under Release readiness.

    ", + "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": "Fix the docs Some statements in these docs are wrong or contradict each other. Studio compatibility says Studio reads only the schema and the content files, but it also calls /live/previews, /deco/invoke and /live/invoke. API reference says 'Nothing in the SDK throws', while Routing says ambiguous templates make matchRoute throw. 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 Studio talk to a next-major site", + "today": "Nothing serves /live/_meta, /.decofile, /live/previews, /deco/invoke or the secrets encrypt action, and Studio's code contradicts the claim in Studio compatibility that it only reads the schema and files.", + "plan": "Ship createStudioHandler({ cms, schema, render }) in @decocms/blocks-admin, mounted in one line per binding, and correct Studio compatibility.", + "features": [ + "admin-protocol-endpoints", + "invoke-and-actions", + "section-catalog", + "global-layout-sections", + "dev-studio-content-loop", + "site-bootstrap" + ], + "delivered_by": [ + "roadmap-api--ship-the-studio-protocol-handler", + "roadmap-docs--correct-studio-compatibility" + ] + }, + { + "id": "roadmap-blockers--make-the-cli-write-a-schema-studio-can-read", + "title": "Make the CLI write a schema Studio can read", + "today": "The CLI writes .deco/schema.json with top-level definitions; Studio reads .deco/meta.gen.json in LiveMeta shape (btoa keys, schema.root). There are no apps or actions 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 Studio's exact file and shape, add apps/actions groups and one multivariate definition per VariantOf<T>, 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" + ] + }, + { + "id": "roadmap-blockers--give-blocks-the-page-url-and-route-params", + "title": "Give blocks the page URL and route params", + "today": "On SPA navigation getRequest() returns the /_serverFn URL, Next's headers() has no URL, app packages' loaders have no standard way to receive the URL or match.params, and apps-vtex's RequestContext is never populated.", + "plan": "Add one binding-owned request scope (url, params, request, client, response headers) that both bindings' openPage populate and apps can import.", + "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 Studio's iframe", + "today": "The SameSite=Lax cookie is rejected cross-site, the TanStack guide never sets it, and ?__draft=off is ignored. There's no no-store rule, and no StudioBridge or data-manifest-key, so click-to-select is lost.", + "plan": "Ship withDeco()/decoProxy(), which set the cookie as Secure; SameSite=None; Partitioned, treat off/empty as exit and mark drafts private/no-store. Add <StudioBridge/>, and have <Sections> emit section[data-manifest-key].", + "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", + "roadmap-api--add-a-section-renderer-and-a-deferral-api" + ] + }, + { + "id": "roadmap-blockers--define-the-non-cms-sdk-surface", + "title": "Define the non-CMS SDK surface", + "today": "apps-* and the ClickHouse telemetry depend on sdk/requestContext, instrumentedFetch, fetchCache and cachedLoader, and sites' widgets on sdk/useDevice and the Image and JsonLd hooks. These docs only list 'loaders, client, resolution, router'.", + "plan": "Publish a 7.x-to-next surface map, keep sdk/* as a documented utility tier, and ship @decocms/blocks/image.", + "features": [ + "framework-import-surface", + "in-isolate-caches", + "upstream-fetch-instrumentation", + "image-optimization", + "json-ld-structured-data", + "interactive-ui-widgets" + ], + "delivered_by": [ + "roadmap-api--keep-the-utility-tier-and-document-it", + "roadmap-api--add-an-image-module" + ], + "delivered_note": "No work item covers the 7.x-to-next surface map yet." + }, + { + "id": "roadmap-blockers--add-a-revision-handle-an-edge-cache-kit-and-an-isr-recipe", + "title": "Add a revision handle, an edge cache kit and an ISR recipe", + "today": "Clients expose no revision and there's no forRevision. Degraded pages get cached, and update() isn't passed to waitUntil. Content refreshes on a ~5 min poll with cold isolates serving bundled content, versus ~10 s today.", + "plan": "Add client.revision(), cms.forRevision(), onUpdate and markDegraded(), plus withEdgeCache() in @decocms/tanstack and an ISR recipe for Next.", + "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 Resolved<T> can't be expressed.", + "plan": "Add one deferral API: a Deferred<T> input type bound to the client's revision, which <Sections> renders lazily. A multivariate over Deferred values resolves only the winner.", + "features": [ + "lazy-deferred-sections", + "matchers-and-variants", + "site-search-and-autocomplete" + ], + "delivered_by": [ + "roadmap-api--add-a-section-renderer-and-a-deferral-api" + ] + }, + { + "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 Studio writes Lazy, SeoV2, site/apps/site.ts, redirect, secret and the multi matcher. UNKNOWN_BLOCK now fails the parent section, and list() can't see Content aliases.", + "plan": "Specify single-decode filenames, widen the alias bridge, ship deco check and deco bundle, 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 /* PLPs never match. matchRoute throws per request on an ambiguity a single Studio commit can introduce. Page.seo is required but content stores null, and with no 404 gate unknown URLs return soft 200s.", + "plan": "Add a trailing splat and make matchRoute non-throwing, with release-time validation. 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 apps manifest group, website/loaders/secret.ts can't decrypt, and without the site's encrypt action Studio refuses to save any Secret field.", + "plan": "Define an app contract (blocks, Config, configure()), plus @decocms/blocks/secrets (the secret block and the site-side encrypt action) and an apps group.", + "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-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 Studio a schema it can read, so the editor loads", + "today": "Studio reads .deco/meta.gen.json or /live/_meta in LiveMeta shape, so a next-major site shows 'live meta unavailable'.", + "features": [ + "studio-schema-generation", + "ci-and-release-workflows", + "codegen-pipeline" + ], + "delivered_by": [ + "roadmap-cli--write-a-studio-compatible-schema-file" + ] + }, + { + "id": "roadmap-studio-new--keep-gallery-theme-and-in-place-previews-working", + "title": "Keep gallery, theme and in-place previews working", + "today": "Section thumbnails, global and theme previews, and Fast Preview in-place render all go through /live/previews.", + "features": [ + "section-catalog", + "theming-and-styling", + "global-layout-sections" + ], + "delivered_by": [ + "roadmap-api--ship-the-studio-protocol-handler", + "roadmap-platform--align-the-preview-protocol" + ] + }, + { + "id": "roadmap-studio-new--require-a-draft-grant-for-in-place-preview-renders", + "title": "Require a draft grant for in-place preview renders", + "today": "The proposed Studio handler doesn't define who may request an in-place render.", + "features": [ + "admin-protocol-endpoints", + "studio-preview" + ], + "delivered_by": [ + "roadmap-api--ship-the-studio-protocol-handler", + "roadmap-platform--align-the-preview-protocol" + ] + }, + { + "id": "roadmap-studio-new--let-editors-save-secret-fields-on-next-major-sites", + "title": "Let editors save Secret fields on next-major sites", + "today": "Studio encrypts Secret fields through an encrypt action the site serves. On a next-major site without that action, editors can't save any Secret field.", + "features": [ + "env-vars-and-secrets", + "app-installation-and-store-config", + "invoke-and-actions" + ], + "delivered_by": [ + "roadmap-api--ship-decocms-blocks-secrets", + "roadmap-api--ship-the-studio-protocol-handler" + ] + }, + { + "id": "roadmap-studio-new--keep-options-run-and-pickers-working", + "title": "Keep @options, Run and pickers working", + "today": "These need /deco/invoke and /live/invoke. The blog ProductShelf picker even calls production's /deco/invoke with legacy VTEX keys.", + "features": [ + "invoke-and-actions", + "app-installation-and-store-config" + ], + "delivered_by": [ + "roadmap-api--ship-the-studio-protocol-handler" + ] + }, + { + "id": "roadmap-studio-new--restore-click-to-select", + "title": "Restore click-to-select", + "today": "There's no editor::inject listener, and the guides' renderers emit no section[data-manifest-key].", + "features": [ + "root-document-layout", + "interactive-ui-widgets" + ], + "delivered_by": [ + "roadmap-api--add-a-studio-bridge", + "roadmap-api--add-a-section-renderer-and-a-deferral-api" + ] + }, + { + "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. ?__draft=off 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": "Studio's field variant UI needs a single-option block-ref whose definition has variants, and takes the inner field from variants.items.properties.value, else a plain string (schema-form.tsx:76-87, 254-280). The proposed API's one generic multivariate<T> and VariantOf<T> give the CLI no concrete T. Legacy schemas had one flag per kind, e.g. website/flags/multivariate/image.ts.", + "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": "x-deco-matchers-override and ?deviceHint have no reader in an app-owned 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 Studio reading short type names as saved entries", + "today": "Studio classifies keys by file extension, so site sections must keep site/sections/X.tsx 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" + ] + }, + { + "id": "roadmap-studio-new--prevent-entry-names-from-colliding-with-types", + "title": "Prevent entry names from colliding with types", + "today": "Studio's only write guard rejects keys with a source-file extension (block-key.ts:48-50). An editor can save an entry named hero, seo or page; under the proposed API's { ...savedEntries, ...blocks } the function wins and the entry is silently ignored. Only deco schema 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 Studio see and edit flat redirect entries", + "today": "The Redirects collection lists only blocks typed exactly website/loaders/redirect.ts and writes { redirect: { from, to, type, discardQueryParameters } }, with temporary = 307 (redirect-data.ts:14,29-32,77). Flat redirect 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" + ] + }, + { + "id": "roadmap-studio-new--fix-local-and-tunnel-modes-show-local-edits-in-dev", + "title": "Fix local and tunnel modes; show local edits in dev", + "today": "The dev server serves neither endpoint. With a token in .dev.vars, remoteLoader swaps in the production release over local edits.", + "features": [ + "dev-studio-content-loop", + "bundler-config" + ], + "delivered_by": [ + "roadmap-cli--add-a-local-dev-loop", + "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 ~5 min poll replaces ~10 s, with no 'content live' signal. A failed forDraft 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" + ] + } + ], + "studio_legacy": [ + { + "id": "roadmap-studio-legacy--handle-the-legacy-types-studio-keeps-writing", + "title": "Handle the legacy types Studio keeps writing", + "today": "website/pages/Page.tsx, SeoV2 on every new page, Rendering/Lazy.tsx (the ⚡ toggle), flags/multivariate/section.ts (hide), nested website/loaders/redirect.ts, website/loaders/secret.ts, site/apps/site.ts, the multi matcher, Theme. The bridge only aliases pages, matchers and multivariate, so these Studio 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 sections from running", + "today": "'Every variant resolves': a section hidden with a never-rule still runs its loaders, and its undefined renders 'temporarily unavailable'.", + "features": [ + "matchers-and-variants" + ], + "delivered_by": [ + "roadmap-api--add-a-section-renderer-and-a-deferral-api", + "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": "Studio hides a banner or link by wrapping it in website/flags/multivariate.ts with a never rule (array-item-hidden.ts:35-40). Today's resolver drops undefined array items (resolve.ts:849-860). The proposed API's rule and resolve(page.sections) ('one result per section') keep them, so components receive undefined 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": "Fix the default-variant rule that makes other variants unreachable", + "today": "Studio seeds every new variant with an always rule (section-types.ts:26; a new field variant starts as two) and keeps the first always, 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 always last.", + "features": [ + "matchers-and-variants" + ], + "delivered_by": [ + "roadmap-docs--resolve-contradictions-in-these-docs" + ] + }, + { + "id": "roadmap-studio-legacy--decode-filenames-exactly-once", + "title": "Decode filenames exactly once", + "today": "Studio writes encodeURIComponent(key). Real stems include pages-Category%2520Page-* and collections%2Fblog%2F*.", + "features": [ + "block-type-discriminator", + "content-storage-and-delivery" + ], + "delivered_by": [ + "roadmap-cli--specify-the-filename-rule-and-ship-a-content-bundle" + ] + }, + { + "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": "page 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 blog/loaders/*.ts and @decocms/apps-blog ≥ 7.53.0. blog-tanstack 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 Studio can frame them", + "today": "Both TanStack sites send X-Frame-Options: SAMEORIGIN and lack ?__draft, 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": "Make content drift visible to editors", + "today": "Fields missing from the schema are hidden, and defaults apply only on save. The storefront's Header children are typed string[] today.", + "features": [ + "content-schema-drift", + "cms-navigation-menus" + ], + "delivered_by": [] + } + ], + "work_items": [ + { + "id": "roadmap-api--add-a-request-scope", + "group": "api", + "title": "Add a request scope", + "plan": "

    One binding-owned scope in @decocms/blocks, behind conditional exports: requestScope() returns { url, params, request, client, device, forcedRules }, plus appendResponseHeader(). Both bindings' openPage populate it with the real page href (port derivePageUrl), match.params and the per-request client. It replaces sdk/requestContext, the page-scope helpers and currentClient(), so apps import one framework-neutral port and content-reading loaders stay revision-pinned. device honors ?deviceHint and forcedRules exposes x-deco-matchers-override, both on drafts only.

    ", + "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--ship-the-studio-protocol-handler", + "group": "api", + "title": "Ship the Studio protocol handler", + "plan": "

    createStudioHandler({ cms, schema, render }) in blocks-admin serves /live/_meta, /.decofile, /deco/invoke/:key (allowlist, CORS), and POST /live/invoke/website/actions/secrets/encrypt.ts plus the $live/actions/secrets/encrypt.ts key ({ value } → { value: hex }, what Studio's Secret field calls). It also serves /live/previews (framework render callback, request-scoped CMS, preview resolver), the one preview contract for gallery, theme and in-place renders. In-place renders require a draft grant the content API verifies, or a configured preview host.

    ", + "features": [ + "admin-protocol-endpoints", + "invoke-and-actions", + "section-catalog", + "global-layout-sections", + "theming-and-styling", + "site-bootstrap", + "dev-studio-content-loop", + "worker-server-entry", + "app-loader-overrides", + "section-loaders", + "icon-by-name-enums", + "env-vars-and-secrets", + "studio-preview" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-a-section-renderer-and-a-deferral-api", + "group": "api", + "title": "Add a section renderer and a deferral API", + "plan": "

    <Sections> per binding: per-section Suspense, error boundary, clientOnly, sized fallbacks, eager above the fold, SEO routing, and a section[data-manifest-key] frame on every section for Studio. One deferral API: a Deferred<T> input type passes a ref bound to the client's revision instead of resolving it. <Sections> renders the ref lazily, and a multivariate whose values are Deferred<T> resolves only the winning variant.

    ", + "features": [ + "section-registration-conventions", + "lazy-deferred-sections", + "core-patches", + "theming-and-styling", + "orphan-and-dangling-blocks", + "site-search-and-autocomplete", + "matchers-and-variants", + "tabbed-shelf-partial", + "global-layout-sections", + "inline-loader-props", + "root-document-layout", + "interactive-ui-widgets", + "section-catalog" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--ship-the-withdeco-and-decoproxy-entry-wrappers", + "group": "api", + "title": "Ship the withDeco() and decoProxy() entry wrappers", + "plan": "

    withDeco(handler, { cms }) (TanStack) and decoProxy() (Next) own the per-request plumbing. They schedule cms.update() (ctx.waitUntil on Workers, after() on Next). They set the draft cookie as Secure; SameSite=None; Partitioned; HttpOnly and treat __draft=off or an empty value as exit. Drafted responses are private/no-store/noindex, and security headers carry frame-ancestors from an exported STUDIO_FRAME_ANCESTORS, never X-Frame-Options. Studio routes are optional. The CMS keys draft caches on the API ETag, bounded by an LRU.

    ", + "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": "

    client.revision(), cms.forRevision(rev) / forRelease({ revision }), CMS.onUpdate(cb) and update({ force }).

    ", + "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--keep-the-utility-tier-and-document-it", + "group": "api", + "title": "Keep the utility tier and document it", + "plan": "

    Document instrumentedFetch, fetchCache, cached() (cachedLoader), fetchTimeout and recordCommerceMetric as importable by apps, with preview bypass and clear().

    ", + "features": [ + "framework-import-surface", + "in-isolate-caches", + "upstream-fetch-instrumentation", + "commerce-platform-binding", + "cart-customer-server-functions", + "web-fonts", + "vtex-io-app-settings", + "bff-custom-endpoints" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-remoteloader-options", + "group": "api", + "title": "Add remoteLoader options", + "plan": "

    { interval, coldStartTimeoutMs, channel, onUpdate, onError }, local files preferred in dev, and a first-party kvLoader. Scheduling update() is the entry wrappers' job.

    ", + "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": [], + "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 apps-vtex", + "plan": "

    A session module (resolveRegion, setSegment, useVtexSession), delivery promise in the Intelligent Search loaders, tax-inclusive prices, full installments with InterestRate, an invoke-free cart hook, an Intelligent Search events subscriber and createVtexIoClient.

    ", + "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 Studio bridge", + "plan": "

    A client-safe <StudioBridge /> that accepts editor::inject only from an explicit list of Studio origins, configurable for self-hosted and native Studio. Also resolve(t, { withType: true }) to recover the block type.

    ", + "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 apps-website and apps-commerce", + "plan": "

    PageSeo, and SEO functions pre-aliased to SeoV2/Seo/SeoPDPV2/SeoPLPV2. mergeSiteSeo(siteSeo, pageSeo) is the one site-SEO merge, sharing a test suite with Studio. seoToNextMetadata / seoToTanStackHead, JSON-LD builders and a <JsonLd> renderer.

    ", + "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 an app contract", + "plan": "

    Each @decocms/apps-* exports blocks (legacy keys), type Config and configure() from env, with no @decocms/tanstack dependency and no dynamic module import.

    ", + "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 kit", + "plan": "

    withEdgeCache(entry, { segment, profiles }) in @decocms/tanstack, keyed by URL, build, revision and segment. It skips drafts, cold-start fallback and degraded pages. Plus cacheHeaders(profile) and markDegraded().

    ", + "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": "

    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.

    ", + "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 matchRoute and stop request-time throws", + "plan": "

    A trailing /* or /:rest* at lowest precedence (also for Redirect.from, with substitution), a deterministic tie-break that returns a Result, and case-insensitive matching as an option.

    ", + "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 @decocms/blocks/secrets", + "plan": "

    @decocms/blocks/secrets: a secret block aliased as website/loaders/secret.ts that decrypts with DECO_CRYPTO_KEY, and the encrypt action createStudioHandler serves. Encryption stays on the site, so the key never leaves it. Amend 'the token is the only secret' in Preview.

    ", + "features": [ + "app-installation-and-store-config", + "invoke-and-actions", + "env-vars-and-secrets", + "commerce-platform-binding" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-a-site-wide-layout-helper", + "group": "api", + "title": "Add a site-wide layout helper", + "plan": "

    mergeGlobals(site, page), with layout blocks resolved on the page's client. Site SEO defaults go through the SEO kit's mergeSiteSeo.

    ", + "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": "Keep the TanStack router helpers available", + "plan": "

    Keep createDecoRouter, or export decoParseSearch/decoStringifySearch and the CSP-nonce wiring.

    ", + "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": "

    @decocms/blocks/image (optimized URLs, srcset, Studio quality params), a Next loaderFile, and TanStack <Image>/<Picture>. Export the widget aliases.

    ", + "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": "Add an analytics bootstrap and experiment support", + "plan": "

    A framework-neutral <DecoAnalytics /> (DECO.events + ANALYTICS_SCRIPT) and a typed useSendEvent/dispatch. An assignExperiments() middleware helper, and a way for random matchers to know their entry name.

    ", + "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": "

    A critical block (or seo) may return { notFound } or { redirect }, which the app maps to notFound()/redirect(). Framework control-flow errors are rethrown.

    ", + "features": [ + "not-found-and-error-pages", + "code-composed-pdp", + "redirects" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-observability-seams", + "group": "api", + "title": "Add observability seams", + "plan": "

    A createCMS onEvent/instrument hook, instrumentRequest(handler) outside createDecoWorkerEntry, a Node/Next meter bootstrap and a traced <Section>.

    ", + "features": [ + "otel-observability", + "upstream-fetch-instrumentation" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--add-predictive-search-and-shipping-rates-to-apps-shopify", + "group": "api", + "title": "Add predictive search and shipping rates to apps-shopify", + "plan": "

    A predictive-search suggestions loader and a shipping-rates helper.

    ", + "features": [ + "site-search-and-autocomplete", + "shipping-simulation" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-api--generate-the-sitemap-from-a-client", + "group": "api", + "title": "Generate the sitemap from a client", + "plan": "

    sitemapEntries(client, { types, expand, exclude }), keeping toSitemapXml/toSitemapIndex, plus a Shopify sitemap merge.

    ", + "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": "

    Move withABTesting to @decocms/tanstack/traffic-split and bypass drafts by default.

    ", + "features": [ + "ab-traffic-split" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-cli--write-a-studio-compatible-schema-file", + "group": "cli", + "title": "Write a Studio-compatible schema file", + "plan": "

    Write .deco/meta.gen.json in LiveMeta shape: manifest.blocks, schema.definitions keyed by padded, path-independent btoa, and schema.root unions. Add apps and actions groups. For every VariantOf<T> field, emit a monomorphized multivariate definition per T (value typed as T) under Studio's legacy per-kind keys (website/flags/multivariate/section.ts, …/image.ts, …), so the field variant UI renders the right input.

    ", + "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--widen-the-legacy-alias-bridge", + "group": "cli", + "title": "Widen the legacy alias bridge", + "plan": "

    Alias Lazy/SingleDeferred/Deferred, Seo*, site/apps/site.ts, resolved, requestToParam, secret, redirect (with shape mapping), the device and random matchers, the multi matcher (website/matchers/multi.ts and $live/matchers/MatchMulti.ts, whose and/or combinators keep the { op, matchers } shape that Studio's variant calendar and rule labels read), Analytics and Theme, and list exactly which paths.

    ", + "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 validation commands to the CLI", + "plan": "

    deco schema --check, plus deco check/validate reporting UNKNOWN_BLOCK, NOT_FOUND, orphans, schema violations and route collisions, with an optional resolve-every-page smoke test.

    ", + "features": [ + "orphan-and-dangling-blocks", + "content-schema-drift", + "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" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-cli--publish-the-tag-and-widget-vocabulary", + "group": "cli", + "title": "Publish the tag and widget vocabulary", + "plan": "

    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 generate-schema.ts.

    ", + "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": "

    deco migrate block-map 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 and a requestToParam scaffold.

    ", + "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--build-a-faststore-importer", + "group": "cli", + "title": "Build a FastStore importer", + "plan": "

    deco import faststore: VTEX Headless CMS content becomes .deco/blocks with $componentKey rewritten to path-shaped keys, content types become page entries, and JSONC becomes TS scaffolds.

    ", + "features": [ + "faststore-cli-overlay", + "block-type-discriminator", + "content-storage-and-delivery", + "product-card", + "native-platform-sections", + "per-page-section-whitelists", + "icon-by-name-enums", + "cms-schema-upload-pipeline" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-cli--define-the-loader-picker-rule", + "group": "cli", + "title": "Define the loader-picker rule", + "plan": "

    A field of type T accepts any block function that returns T (anyOf $refs). The CLI follows imported app maps with last-key-wins and warns on any.

    ", + "features": [ + "inline-loader-props", + "saved-loader-blocks", + "app-loader-overrides", + "web-fonts", + "product-card", + "commerce-platform-binding", + "route-params-request-to-param" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-cli--support-typed-section-references", + "group": "cli", + "title": "Support typed section references", + "plan": "

    Section / BlockOf<'hero' | 'alert'> becomes an anyOf or __SECTION_REF__. Also a SectionDescriptor<P> marker, @singleton and @catalog false.

    ", + "features": [ + "nested-section-props", + "per-page-section-whitelists", + "section-catalog", + "site-config-block", + "section-registration-conventions", + "tabbed-shelf-partial", + "dev-tooling-and-repo-hygiene" + ], + "docs": [], + "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": "

    One decodeURIComponent per stem, with a collision report. deco bundle / localLoader.fromGlob() emits { revision, blocks, schemaHash }, and computeRevision is exported.

    ", + "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": "

    The CLI emits an alias table that createCMS accepts, so list() and resolve() work through Content aliases.

    ", + "features": [ + "cms-page-routing", + "codegen-pipeline", + "studio-schema-generation", + "key-prefix-content-collections", + "redirects" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-cli--add-a-local-dev-loop", + "group": "cli", + "title": "Add a local dev loop", + "plan": "

    deco schema --watch / deco dev: a tunnel, a mounted Studio handler, and a reload broadcast when .deco/blocks changes.

    ", + "features": [ + "dev-studio-content-loop", + "bundler-config", + "codegen-pipeline", + "studio-schema-generation" + ], + "docs": [], + "pinned": false + }, + { + "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": "

    Support several page types, a content collection screen and singletons. Read flat redirect entries alongside the nested legacy shape, mapping type to permanent.

    ", + "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": "

    Replace the file-extension module-or-entry check and the '/sections/' heuristics, and refuse to save an entry whose name is a manifest key.

    ", + "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": "

    A release endpoint and site tokens (with a DECO_SITE_TOKEN lifecycle), a schema-hash compatibility input, rejection of ambiguous-route releases, and per-environment channels.

    ", + "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": [], + "pinned": false + }, + { + "id": "roadmap-platform--align-the-preview-protocol", + "group": "studio", + "title": "Align the preview protocol", + "plan": "

    Send an empty __draft= (or keep 'off' as a documented contract) and force variants in the draft content. Keep gallery, global and in-place previews on /live/previews, which createStudioHandler serves, and include the draft grant in in-place renders, as the handler requires.

    ", + "features": [ + "studio-preview", + "device-detection-and-targeting", + "matchers-and-variants", + "section-catalog", + "theming-and-styling", + "global-layout-sections" + ], + "docs": [], + "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": "

    Detect the blog from the manifest, and stop keying experiments on the random matcher's entry name.

    ", + "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": "

    Label short-key matchers from their schema, dedupe aliased matchers, and keep oneOf const titles.

    ", + "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": "

    Use a page type's sections anyOf as the catalog, and render the remaining page props as a form.

    ", + "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 Studio's SEO merge to parity with the kit", + "plan": "

    Run Studio's mergeSeo (seo-editor.tsx) against the kit's mergeSiteSeo test suite, and decide whether descriptionTemplate applies.

    ", + "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": "

    Every response carries the served revision in x-deco-revision, and cms.update() reports each revision it swaps in to the Deco API with the site token. Studio polls the API and shows a publish as pending until a report for its revision arrives, then live.

    ", + "features": [ + "fast-deploy-kv", + "edge-html-cache-profiles" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-platform--update-the-injected-agent-rules", + "group": "studio", + "title": "Update the injected agent rules", + "plan": "

    Replace the two wrong rules for next-major sites. Content edits go only in .deco/blocks/<encodeURIComponent(key)>.json. There are no blocks.gen.* files; the one generated file is the schema, which deco schema rewrites after any change to a block's types. A new type needs a block-map entry. remoteLoader has no hot reload, and with DECO_SITE_TOKEN set the dev server serves the API release, not local edits.

    ", + "features": [ + "ai-agent-instructions" + ], + "docs": [], + "pinned": false + }, + { + "id": "roadmap-docs--resolve-contradictions-in-these-docs", + "group": "docs", + "title": "Resolve contradictions in these docs", + "plan": "

    Also: make Page.seo optional, drop array elements that resolve to undefined (Studio's hidden array items), make a hidden section's undefined render nothing, say what a reference to a Content entry resolves to, and fix the credential sentence in Preview.

    ", + "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": [ + "standalone", + "routing", + "schema", + "loader-internals", + "variants", + "studio", + "model", + "troubleshooting", + "preview" + ], + "pinned": true + }, + { + "id": "roadmap-docs--correct-studio-compatibility", + "group": "docs", + "title": "Correct Studio compatibility", + "plan": "

    Say in Studio compatibility that Studio calls /live/_meta, /.decofile, /live/previews, /deco/invoke and /live/invoke (including the secrets encrypt action). State that sites allow framing through frame-ancestors, never X-Frame-Options, as the entry wrappers do.

    ", + "features": [ + "admin-protocol-endpoints", + "invoke-and-actions", + "csp-security-headers", + "studio-schema-generation" + ], + "docs": [ + "studio" + ], + "pinned": true + }, + { + "id": "roadmap-docs--fix-the-tanstack-guides", + "group": "docs", + "title": "Fix the TanStack guides", + "plan": "

    Mount withDeco() instead of hand-wiring the draft cookie and cms.update(). Add validateSearch/loaderDeps and staleTime, and state that getRequest() is the RPC request on SPA navigation, so the page URL comes from the request scope.

    ", + "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-data", + "tanstack-rsc", + "manifest" + ], + "pinned": false + }, + { + "id": "roadmap-docs--write-the-recipes-that-gate-cutover", + "group": "docs", + "title": "Write the recipes that gate cutover", + "plan": "

    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.

    ", + "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": "

    Page-level data shared by sections, client data fetching, server-only upstream proxies, overriding an app's block, tabs over pre-resolved data, head tags from sections, locale, and robots/static SEO files.

    ", + "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": "

    Decision: port @faststore/core's 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.

    ", + "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": "

    Mount decoProxy() instead of wiring the draft cookie and after(() => cms.update()) by hand. Wrap client() in cache(), map the full PageSeo and degrade SEO errors instead of throwing, make release routes ISR/static, and cover transpilePackages and outputFileTracingIncludes.

    ", + "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": "Write an operations page", + "plan": "

    Five deliverables: (1) the DECO_SITE_TOKEN lifecycle (issue, rotate, scope) and a one-time warning when createCMS runs in production without a token; (2) a per-host table of how withDeco/decoProxy schedule update() on Workers, Node and Vercel; (3) default bounds for every in-isolate cache and how to change them; (4) the observability chokepoint rule (createInstrumentedFetch + createFetchCache) and the DECO_OTEL_* contract; (5) 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.

    ", + "features": [ + "env-vars-and-secrets", + "hosting-and-deploy-config", + "in-isolate-caches", + "upstream-fetch-instrumentation", + "otel-observability", + "legacy-runtime-leftovers", + "shopify-autoconfig-workaround" + ], + "docs": [ + "operations" + ], + "pinned": false + }, + { + "id": "roadmap-docs--add-notes-to-the-rendering-section", + "group": "docs", + "title": "Add notes to the Rendering section", + "plan": "

    Add four statements to Rendering. Inline <script> in a streamed boundary runs on parse, before the boundary is revealed, so use effects. <Sections> keeps above-the-fold sections eager, with sized skeletons below. 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.

    ", + "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": "

    Unions to anyOf with const discriminators, intersections and recursion, type resolution through packages, filename-to-entry encoding, and how VariantOf<T> becomes one multivariate definition per T.

    ", + "features": [ + "conditional-schema-fields", + "cms-navigation-menus", + "app-domain-types", + "block-type-discriminator", + "matchers-and-variants" + ], + "docs": [ + "schema" + ], + "pinned": false + } + ], + "sites": [ + { + "id": "storefront-tanstack", + "name": "storefront-tanstack", + "short": "storefront", + "section": "roadmap-storefront", + "description": "Shopify storefront on the current split @decocms 7.x packages (TanStack Start, Cloudflare Workers).", + "headline": "Closest to next-major. Before it can move, the proposed API needs successors for the worker entry, edge cache, page-URL plumbing and Studio routes it relies on.", + "steps": [ + { + "id": "roadmap-storefront--fix-dark-metrics-and-deco-invoke-404s", + "kind": "now", + "title": "Fix dark metrics and /deco/invoke 404s", + "text": "createInstrumentedFetch('shopify') has no onComplete, so upstream metrics are dark. Site loaders called through /deco/invoke 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 X-Frame-Options: SAMEORIGIN and has no ?__draft binding. Moving past #390 (7.20.10) and #442 (7.34.0) lets Studio 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 Studio's forms, previews and @options working after the move", + "text": "Next-major serves none of /live/_meta, /live/previews or /deco/invoke, and its schema file isn't what Studio 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 apps-shopify's @decocms/tanstack dependency and document its SDK imports", + "text": "It imports @decocms/blocks/sdk/{fetchTimeout,instrumentedFetch} and depends on @decocms/tanstack.", + "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 createDecoWorkerEntry", + "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 x-deco-page-url and derivePageUrl 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 sections, 12 loaders) with the path-shaped keys Studio 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 /* to /:collection, guard the PDP's seo: null and hoist SEO sections out of sections[]. Register in the block map, or remove, ~20 unknown types, including the resolved built-in that Header.json's searchbar uses. Fix the Newsletter and Header drift, and hold MIGRATION_NEXT_STEPS' *Config 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--restore-deferral-and-stop-duplicate-loads", + "kind": "content", + "title": "Restore deferral and stop duplicate loads", + "text": "Its 43 Lazy wrappers (21 with inline loaders) lose deferral until Deferred<T> exists. '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": "blog-tanstack", + "short": "blog", + "section": "roadmap-blog", + "description": "Blog on the old single-package @decocms/start 6.12 with @decocms/apps 5.2 (TanStack Start, Workers).", + "headline": "Simple content and a clean route tree, but it's on 6.12: it upgrades to 7.x first (Studio can't frame it until then), and its Studio blog workflow depends on legacy loader names and apps-blog.", + "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 upgrade-6-to-7 for the blog's specifiers (~45 symbols across 13 subpaths), then run it. That also drops X-Frame-Options: SAMEORIGIN and adds ?__draft, so Studio 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 apps-blog", + "text": "It keys off blog/loaders/*.ts and @decocms/apps-blog ≥ 7.53.0, but the site is on @decocms/apps 5.2.0. Its meta sits at src/server/admin/meta.gen.json with Windows-path keys, and setInvokeLoaders exists only for Studio.", + "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 getRecordsByPath uses loadBlocks(), so under a pure CMS drafts read the wrong revision. It needs the request scope's client, 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": "regen-blocks.yml (Studio publish, then a regen commit, then a Workers Build) is how content ships today. Keep it until the release service and remoteLoader 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 /404 publicly", + "text": "The 7 pages form a valid trie, but unknown URLs are soft 200s via /:slug and /404 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 Content-typed 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 <img>, 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": "FastStore storefront", + "short": "FastStore", + "section": "roadmap-faststore", + "description": "VTEX storefront on FastStore v4 (Next.js Pages Router); content in VTEX Headless CMS. Not on deco today.", + "headline": "A re-platform, not an upgrade. The target framework, the CMS content, the native sections and the VTEX 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 the native sections get rebuilt. Its code imports @faststore/core internals, and most of its hook files, and the files that use its translation hook, lack 'use client'.", + "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. There's no Headless CMS-to-.deco/blocks importer, the JSONC schemas must become TS types, and per-page-type section whitelists and singletons have no TS or Studio 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 @faststore/core renders", + "text": "Its native sections, including the stateful cart, search and session ones, the global sections 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 apps-vtex 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 $componentKey values to path-shaped keys and avoid type-name collisions", + "text": "There's no legacy Deco content, so no legacy-type aliases. The importer must still rename $componentKey values (Navbar, Footer, Alert…) to path-shaped keys until Studio does manifest lookups, and avoid entry names that collide with type names. The Studio handler and schema fixes ({{item:roadmap-api--ship-the-studio-protocol-handler}}, {{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, named or global blocks, collections, props, rich text, editor widgets, drift and targeting rules." + }, + { + "id": "sections", + "title": "Sections", + "description": "Section registration, the section catalog, platform-native section overrides, and lazy, deferred or viewport-gated rendering." + }, + { + "id": "data", + "title": "Data", + "description": "Loaders, query props, section loaders, 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": "Studio", + "description": "Admin/Studio protocol, preview, schema generation or upload, and the local dev-to-Studio 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 Studio commit can make `matchRoute` throw on every request, and `Page.seo` is required while stored content holds `seo: null`.", + "docs": [ + "routing", + "content-types", + "router-internals", + "adoption", + "standalone", + "nextjs", + "tanstack-data", + "studio", + "releases" + ], + "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 `` navigation `getRequest()` returns the `/_serverFn/...` RPC URL, so search params and the page URL never reach blocks.", + "docs": [ + "tanstack-data", + "router-internals", + "manifest", + "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", + "manifest", + "decisions", + "adoption" + ], + "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-data", + "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": [ + "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 `` click.", + "docs": [ + "tanstack-data", + "nextjs", + "preview", + "adoption" + ], + "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", + "content-types" + ], + "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, Studio'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 Studio stores.", + "docs": [ + "content-types", + "routing", + "router-internals", + "nextjs", + "tanstack-data", + "studio", + "adoption" + ], + "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", + "standalone", + "content-types" + ], + "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 (Studio writes every key through `encodeURIComponent`), Studio still classifies keys by their path shape, and an unregistered legacy type now fails its parent section with UNKNOWN_BLOCK.", + "docs": [ + "quickstart", + "model", + "manifest", + "adoption", + "tanstack-data", + "studio", + "decisions" + ], + "confidence": "high", + "first_rated": "done", + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "global-layout-sections": { + "name": "Global/shared layout sections (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 lose their deferral.", + "docs": [ + "model", + "operations", + "nextjs", + "releases", + "rendering", + "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 section.", + "docs": [ + "model", + "manifest", + "routing", + "standalone", + "troubleshooting", + "studio" + ], + "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 section, 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": [ + "model", + "standalone", + "adoption", + "releases", + "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", + "standalone", + "adoption", + "releases" + ], + "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": [ + "content-types", + "schema", + "rendering" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": "done", + "unconfirmed_sub_claim": false + }, + "nested-section-props": { + "name": "Section-typed props (nested sections)", + "category": "content-model", + "status": "to-finish", + "effort": "S", + "sites": [ + "storefront-tanstack" + ], + "summary": "There's no documented way to type an input that takes a section so the CLI emits a section picker; today's schema uses `__SECTION_REF__`. Nested sections also resolve eagerly, with no Suspense boundary of their own.", + "docs": [ + "model", + "rendering", + "manifest", + "standalone" + ], + "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. How `list()` learns Content aliases is unexplained.", + "docs": [ + "standalone", + "content-types", + "schema", + "manifest", + "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. A real reference to a Content-only entry would fail with UNKNOWN_BLOCK.", + "docs": [ + "standalone", + "model", + "schema", + "manifest" + ], + "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": "Reading posts with `resolve: true` 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": [ + "model", + "standalone" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "per-page-section-whitelists": { + "name": "Per-page-type section whitelists", + "category": "content-model", + "status": "to-build", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "A sections field can't be typed as a subset of block types, because `Block` is untyped. Studio's add-section catalog is the union of all sections anyway, so a narrowed schema wouldn't restrict it.", + "docs": [ + "content-types", + "standalone", + "schema", + "studio" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "page-level-settings-fields": { + "name": "Page-level settings outside sections", + "category": "content-model", + "status": "to-finish", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "Studio edits only a page's name, path, SEO and sections, so typed page settings get no form. Only one page type can carry the `website/pages/Page.tsx` alias.", + "docs": [ + "content-types", + "routing", + "schema", + "studio" + ], + "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": "A section that references a Content-typed menu entry fails with UNKNOWN_BLOCK, which contradicts the advice to declare navigation in Content. Nothing says whether `deco schema` handles the intersections and recursion in `SiteNavigationElement`.", + "docs": [ + "model", + "content-types", + "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", + "content-types", + "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", + "content-types", + "manifest", + "studio", + "adoption" + ], + "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 section Studio hides with a never rule still runs its loaders, and its `undefined` renders 'temporarily unavailable'. Device and random matchers no longer ship, and which Studio matcher paths get aliases is unstated.", + "docs": [ + "variants", + "studio", + "tanstack-data" + ], + "confidence": "medium", + "first_rated": "site-code", + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "per-path-section-visibility": { + "name": "Per-path section 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": [ + "manifest", + "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 Studio's device and variant preview are unaddressed. Studio's variant tabs can't force a variant through an app-owned multivariate.", + "docs": [ + "variants", + "decisions", + "manifest", + "tanstack-data", + "studio", + "adoption" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "section-registration-conventions": { + "name": "Section 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 convention flags or section loaders has a home, so each site rebuilds the ~737-line DecoPageRenderer. There's no descriptor type, and `getRequest()` gives URL-dependent loaders the wrong URL on SPA navigation.", + "docs": [ + "manifest", + "tanstack-data", + "model", + "rendering", + "adoption", + "operations", + "standalone", + "studio", + "decisions" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "section-catalog": { + "name": "Section catalog exposed to editors", + "category": "sections", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "The catalog works for path-keyed sections, 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", + "schema", + "content-types", + "standalone", + "adoption", + "decisions" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "native-section-overrides": { + "name": "Overrides of platform-native sections", + "category": "sections", + "status": "to-finish", + "effort": "M", + "sites": [ + "faststore" + ], + "summary": "Overrides collapse into plain composition, but the core sections, 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": [ + "manifest", + "rendering", + "adoption", + "model", + "troubleshooting", + "decisions" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "native-platform-sections": { + "name": "Native platform sections used via schema only", + "category": "sections", + "status": "to-build", + "effort": "L", + "sites": [ + "faststore" + ], + "summary": "Nothing in the next major renders a section without site code, and UI stays out of `@decocms/apps`. Replacing the core sections, the stateful ones included, is real engineering, and FastStore's global-section and 404/500 content types have no layout pattern.", + "docs": [ + "decisions", + "standalone", + "studio", + "content-types" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "code-composed-pdp": { + "name": "Code-composed monolithic PDP section", + "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", + "manifest", + "routing", + "standalone", + "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, and a Content-typed card-config entry nested in a section fails with UNKNOWN_BLOCK.", + "docs": [ + "manifest", + "model", + "schema", + "rendering", + "content-types", + "studio" + ], + "confidence": "medium", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "lazy-deferred-sections": { + "name": "Lazy / deferred / viewport-gated sections", + "category": "sections", + "status": "to-finish", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack", + "faststore" + ], + "summary": "A wrapper's `section` input resolves fully before Lazy runs, so Lazy defers nothing (43 wrappers on the storefront). Viewport-gated second hops need a revision the client doesn't expose, and today's per-prop deferral has no counterpart.", + "docs": [ + "manifest", + "rendering", + "tanstack-data", + "model", + "variants", + "releases", + "standalone", + "decisions" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "section-loaders": { + "name": "Section loaders (server-side prop enrichment)", + "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": [ + "manifest", + "model", + "rendering", + "tanstack-data", + "nextjs", + "routing", + "operations", + "studio", + "adoption" + ], + "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": [ + "model", + "variants", + "rendering", + "tanstack-data", + "nextjs", + "adoption", + "standalone", + "studio" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": true + }, + "inline-loader-props": { + "name": "Loaders and query descriptors in section 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": [ + "model", + "studio", + "schema", + "standalone", + "troubleshooting", + "adoption", + "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": [ + "manifest", + "adoption", + "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": [ + "manifest", + "tanstack-data", + "operations", + "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 [Studio compatibility](#studio) is wrong: Studio calls `/deco/invoke` for @options fields, Run and the product picker, and `/live/invoke` to encrypt secrets.", + "docs": [ + "studio", + "internals", + "operations", + "standalone", + "tanstack-data" + ], + "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", + "manifest", + "operations", + "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", + "content-types" + ], + "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 Studio's iframe, and FastStore's search state machine must be rebuilt.", + "docs": [ + "manifest", + "tanstack-data", + "nextjs", + "router-internals", + "releases", + "standalone", + "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`: 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": [ + "model", + "variants", + "standalone", + "tanstack-data" + ], + "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": [ + "manifest", + "standalone", + "rendering", + "tanstack-data" + ], + "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", + "model", + "operations" + ], + "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": [ + "manifest", + "operations", + "variants", + "content-types", + "standalone" + ], + "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": [ + "manifest", + "routing", + "router-internals", + "adoption", + "decisions", + "tanstack-data", + "studio", + "standalone", + "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": [ + "decisions", + "manifest", + "standalone", + "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": [ + "operations", + "tanstack-data", + "manifest" + ], + "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": [ + "operations", + "manifest", + "rendering", + "tanstack-data" + ], + "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", + "operations", + "routing", + "content-types" + ], + "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": [ + "manifest", + "variants", + "operations", + "content-types" + ], + "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": [ + "content-types", + "manifest" + ], + "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": [ + "standalone", + "decisions", + "rendering", + "operations" + ], + "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": [ + "operations", + "variants", + "manifest", + "tanstack-data", + "adoption" + ], + "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", + "manifest", + "operations", + "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": [ + "operations", + "tanstack-data", + "manifest" + ], + "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": [ + "operations", + "tanstack-data", + "manifest" + ], + "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-data", + "manifest", + "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": [ + "architecture", + "manifest", + "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": [ + "content-types", + "routing", + "rendering", + "nextjs", + "tanstack-data", + "adoption", + "studio", + "manifest", + "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 Studio's `mergeSeo` exactly. Studio's preview applies titleTemplate but not descriptionTemplate, so production and preview already differ.", + "docs": [ + "model", + "schema", + "manifest", + "standalone", + "adoption", + "decisions" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "commerce-seo-sections-in-sections": { + "name": "Commerce SEO sections inside page sections", + "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": [ + "content-types", + "model", + "rendering", + "routing", + "operations", + "adoption", + "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 sections", + "category": "seo", + "status": "site-code", + "effort": "S", + "sites": [ + "faststore" + ], + "summary": "React 19 hoisting lets a section emit head tags, but the per-section Suspense pattern defeats in-section LCP preloads, and nothing says so. Preload from the page component instead.", + "docs": [ + "rendering", + "architecture", + "model", + "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 `` renderer.", + "docs": [ + "model", + "rendering", + "standalone", + "internals", + "manifest" + ], + "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", + "standalone", + "content-types", + "router-internals", + "releases" + ], + "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", + "architecture" + ], + "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, there's no revision to key by, and content refreshes on a ~5 min poll instead of ~10 s.", + "docs": [ + "operations", + "releases", + "standalone", + "loader-internals", + "nextjs", + "tanstack-data", + "rendering", + "preview", + "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 the SWR and dedup utilities apps and telemetry depend on aren't on the documented surface. Draft caches are unbounded, input-keyed caches miss request state, and Studio's bypass flag is ignored.", + "docs": [ + "operations", + "standalone", + "troubleshooting", + "manifest" + ], + "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. [Block maps](#manifest) says to read route params through `getRequest()`, which is wrong even on SSR.", + "docs": [ + "manifest", + "routing", + "tanstack-data", + "nextjs", + "rendering", + "operations" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "admin-protocol-endpoints": { + "name": "Admin/Studio protocol endpoints", + "category": "studio", + "status": "to-build", + "effort": "L", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "Nothing serves the endpoints Studio calls (`/live/_meta`, `/.decofile`, `/live/previews`, `/deco/invoke`), and Studio's code contradicts [Studio compatibility](#studio). Invoke is needed in production too, for the blog's product picker.", + "docs": [ + "studio", + "internals", + "schema", + "manifest", + "operations" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "studio-preview": { + "name": "Studio 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 Studio's iframe, and there's no no-store rule for drafts. Recovering `data-manifest-key` takes more than a wrapper.", + "docs": [ + "preview", + "standalone", + "nextjs", + "tanstack-data", + "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 Studio 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", + "adoption", + "manifest", + "quickstart", + "content-types", + "operations" + ], + "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" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "studio-workspace-spaces": { + "name": "Studio workspace layout (spaces.json)", + "category": "studio", + "status": "goes-away", + "effort": "S", + "sites": [ + "blog-tanstack" + ], + "summary": "Nothing in Studio reads `spaces.json`. What matters instead: Studio'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", + "model" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "dev-studio-content-loop": { + "name": "Local dev daemon and Studio content loop", + "category": "studio", + "status": "to-finish", + "effort": "M", + "sites": [ + "storefront-tanstack", + "blog-tanstack" + ], + "summary": "With a token in dev, `remoteLoader` swaps in the production release over local edits, so Studio sandbox saves vanish from the dev preview. Nothing regenerates the schema or serves the Local-mode endpoints.", + "docs": [ + "standalone", + "tanstack-data", + "preview", + "schema", + "loader-internals", + "quickstart" + ], + "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 sections and 12 loaders by hand on the storefront), bundled content never matches the API hash, and there's no lenient mode or Studio-shaped schema.", + "docs": [ + "quickstart", + "manifest", + "standalone", + "tanstack-data", + "loader-internals", + "schema", + "model", + "preview", + "studio", + "adoption" + ], + "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 sections into every page. In the next major, SEO-default merging and global-section injection move to app code, there's no singleton concept, and `site/apps/site.ts` isn't in the alias bridge.", + "docs": [ + "content-types", + "schema", + "model", + "manifest", + "standalone", + "adoption", + "studio" + ], + "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 Studio can't save secret fields.", + "docs": [ + "manifest", + "decisions", + "studio", + "schema", + "preview", + "standalone", + "tanstack-data" + ], + "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": [ + "standalone", + "tanstack-data", + "nextjs", + "preview", + "releases", + "operations" + ], + "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": [ + "model", + "content-types", + "manifest", + "tanstack-data", + "releases", + "preview" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + }, + "app-domain-types": { + "name": "App domain types driving Studio 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", + "content-types", + "studio" + ], + "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. Studio 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-data", + "standalone", + "preview", + "nextjs", + "operations", + "studio", + "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 Studio needs in frame-ancestors are undocumented.", + "docs": [ + "architecture", + "tanstack-data", + "nextjs", + "studio" + ], + "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": [ + "standalone", + "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-data", + "tanstack-rsc", + "nextjs", + "standalone", + "releases" + ], + "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. Nothing gives the loader the app's schema hash, so 'skip a release whose schema doesn't match' can't work as documented.", + "docs": [ + "quickstart", + "model", + "standalone", + "tanstack-data", + "releases", + "loader-internals", + "studio" + ], + "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` polls every ~5 min with no option, cold isolates serve build-time content, and a webhook reaches one isolate. Impact on these sites is low: none serves content from KV today.", + "docs": [ + "standalone", + "releases", + "loader-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 Studio reads and has no `--check` mode. The blog's `regen-blocks.yml` is its live content-delivery path today; deleting it early freezes its content.", + "docs": [ + "schema", + "releases", + "router-internals", + "studio", + "adoption" + ], + "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 Studio bridge and the section marker it needs are neither shipped nor documented. Dropping the DECO.events bootstrap also silently breaks OneDollarStats.", + "docs": [ + "tanstack-data", + "nextjs", + "rendering", + "studio" + ], + "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, but there's no Workers content bundle, no `--check` or `--watch`, no input for the schema gate, and Content aliases can't work at runtime.", + "docs": [ + "schema", + "manifest", + "tanstack-data", + "releases", + "loader-internals", + "adoption", + "studio" + ], + "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 Studio duties: schema regeneration, the Studio tunnel, the build hash and section preloads. Next also needs transpilePackages and file-tracing notes.", + "docs": [ + "architecture", + "tanstack-data", + "tanstack-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, no content importer and no serverless deployment note. Everything the overlay supplied becomes app code, and its imports of core internals, `@faststore/core` and `@generated` have to go.", + "docs": [ + "nextjs", + "architecture", + "schema", + "manifest", + "standalone" + ], + "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": [ + "standalone", + "variants", + "content-types", + "manifest", + "schema", + "internals" + ], + "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", + "manifest" + ], + "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 `--check` mode or content validator. Sites' own lint and quality scripts carry over as app scripts.", + "docs": [ + "schema", + "router-internals", + "adoption" + ], + "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-data", + "preview", + "standalone", + "operations" + ], + "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": [ + "architecture", + "standalone", + "adoption", + "manifest" + ], + "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 Studio's injected rules drift from the next major.", + "docs": [ + "architecture", + "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 Studio address a site that lives in a package inside a larger repository.", + "docs": [ + "schema", + "preview", + "nextjs", + "standalone", + "releases" + ], + "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": [ + "model", + "adoption", + "schema", + "rendering", + "releases", + "nextjs", + "studio" + ], + "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": [ + "manifest", + "adoption", + "operations", + "studio", + "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 Studio's quality choice, and without `format: image-uri` Studio shows a text box instead of the image picker.", + "docs": [ + "standalone", + "internals", + "decisions", + "content-types", + "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": [ + "manifest", + "variants", + "operations", + "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": "The tracing obligation is stated, but the app gets no seam to meet it: request telemetry is locked in `createDecoWorkerEntry`, `remoteLoader` fails silently, and spans around JSX blocks miss the upstream fetches.", + "docs": [ + "operations", + "standalone", + "loader-internals", + "preview", + "releases", + "troubleshooting", + "manifest", + "tanstack-data", + "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": [ + "operations", + "manifest", + "rendering", + "internals" + ], + "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: Studio keys them on the random matcher's saved-entry name, which a pure block function never sees.", + "docs": [ + "operations", + "nextjs", + "variants", + "decisions", + "studio", + "adoption", + "manifest", + "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", + "operations" + ], + "confidence": "high", + "first_rated": null, + "rated_before_review": null, + "unconfirmed_sub_claim": false + } + } +} diff --git a/site/gen_roadmap.py b/site/gen_roadmap.py new file mode 100755 index 00000000..09b0a6d1 --- /dev/null +++ b/site/gen_roadmap.py @@ -0,0 +1,714 @@ +#!/usr/bin/env python3 +"""Render the Roadmap page of the Deco Blocks docs site from data/roadmap.json. + +The Roadmap is the to-do list for the next major. Everything it shows comes from +data/roadmap.json (see README.md for the format); this script holds the page's structure, its +framing sentences and its self-checks, and fails loudly when the data doesn't hold together +(unknown ids, broken links, counts that disagree with the copy). + +Nothing on the roadmap is done yet, so every to-do renders unchecked. The boxes are drawn by CSS +and are not interactive: a checklist kept in one reader's browser would look shared and isn't. + +Output: a run of top-level
    blocks plus one small inline """ + + +class Roadmap: + def __init__(self, data): + self.doc_label, self.site_ids = site_ids() + self.load(data) + + # ------------------------------------------------------------------ data + def load(self, d): + # Statuses, in rank order (Feature readiness sorts by this order). + self.VERDICTS = [s["id"] for s in d["statuses"]] + check(self.VERDICTS == list(VC), f"statuses must be {list(VC)} in that order, got {self.VERDICTS}") + st = {s["id"]: s for s in d["statuses"]} + self.VLABEL = {v: st[v]["label"] for v in self.VERDICTS} + self.VWORD = {v: st[v]["word"] for v in self.VERDICTS} + self.VWORD1 = {v: st[v]["word_one"] for v in self.VERDICTS} + self.TILE = {v: st[v]["tile"] for v in self.VERDICTS} + self.LEGEND = {v: st[v]["legend"] for v in self.VERDICTS} + + self.SEC = {s["id"]: s for s in d["sections"]} + check([s["id"] for s in d["sections"]] == SEC_IDS, "sections must list the page's section ids in order") + for s in d["sections"]: + check(s.get("nav") and s.get("eyebrow"), f"section {s['id']} needs nav and eyebrow") + self.check_title(s["title"], f"section {s['id']}") + + self.CATS = d["categories"] + cat_ids = [c["id"] for c in self.CATS] + self.FEATS = d["features"] + self.REPOS = [s["id"] for s in d["sites"]] + for fid, f in self.FEATS.items(): + check(f["category"] in cat_ids, f"feature {fid}: unknown category {f['category']!r}") + check(f["status"] in VC, f"feature {fid}: unknown status {f['status']!r}") + check(f["effort"] in EFF_RANK, f"feature {fid}: effort must be L, M or S") + check(f["sites"] and set(f["sites"]) <= set(self.REPOS), f"feature {fid}: unknown sites {f['sites']}") + for k in ("first_rated", "rated_before_review"): + check(f[k] is None or f[k] in VC, f"feature {fid}: {k} must be a status id or null") + bad = [x for x in f["docs"] if x not in self.doc_label] + check(not bad, f"feature {fid}: docs ids not found in src/content.html: {bad}") + # Every feature names at least one part of these docs. + check(f["docs"], f"feature {fid}: no docs ids") + self.TOTALS = dict(self.counts(list(self.FEATS))) + self.REPO_IDS = {r: [fid for fid in self.FEATS if r in self.FEATS[fid]["sites"]] for r in self.REPOS} + + self.SHORT = {s["id"]: s["short"] for s in d["sites"]} + # The name the page shows for a site (bars, the intro's links, the site filter's tooltip). + self.NAME = {s["id"]: s["name"] for s in d["sites"]} + self.SITE_SEC = {s["id"]: s["section"] for s in d["sites"]} + check(list(self.SITE_SEC.values()) == ["roadmap-storefront", "roadmap-blog", "roadmap-faststore"], + "sites must map, in order, to the roadmap-storefront, roadmap-blog and roadmap-faststore sections") + self.REPO_DESC = {s["id"]: s["description"] for s in d["sites"]} + + # Every to-do has an explicit id (its anchor); titles are looked up by id for links. + self.TITLE = {} + + def reg(item, prefix, what): + check(item["id"].startswith(prefix + "--"), f"{what} {item['id']!r} must start with {prefix}--") + check(item["id"] not in self.TITLE, f"duplicate id {item['id']!r}") + self.check_title(item["title"], f"{what} {item['id']}") + self.check_features(item["features"], f"{what} {item['id']}") + self.TITLE[item["id"]] = item["title"] + + self.TOP = d["blockers"] + for b in self.TOP: + reg(b, "roadmap-blockers", "blocker") + check(len(self.TOP) == N_BLOCKERS, f"the page says 'ten release blockers'; the data has {len(self.TOP)}") + self.STUDIO_NEW, self.STUDIO_LEGACY = d["studio_new"], d["studio_legacy"] + for x in self.STUDIO_NEW: + reg(x, "roadmap-studio-new", "Studio item") + for x in self.STUDIO_LEGACY: + reg(x, "roadmap-studio-legacy", "Studio item") + self.CHANGES = d["work_items"] + for c in self.CHANGES: + check(c["group"] in GROUP_SEC, f"work item {c['id']}: unknown group {c['group']!r}") + reg(c, GROUP_SEC[c["group"]], "work item") + bad = [x for x in c["docs"] if x not in self.doc_label] + check(not bad, f"work item {c['id']}: docs ids not found in src/content.html: {bad}") + self.WORK = {c["id"]: c for c in self.CHANGES} + self.PLAN, self.STEP = {}, {} # step id -> (site, step number, step) + for s in d["sites"]: + self.PLAN[s["id"]] = s["steps"] + for n, step in enumerate(s["steps"], 1): + reg(step, s["section"], "site step") + check(step["kind"] in KIND, f"site step {step['id']}: unknown kind {step['kind']!r}") + # A Note with nothing to do (the migration removes it) is drawn without a box and isn't counted. + check(not step["no_action"] or step["kind"] == "fix", f"site step {step['id']}: only a Note can carry no action") + self.STEP[step["id"]] = (s["id"], n, step) + + # Work items delivering each blocker (each is one part of it, not all of it), and back. + self.CLEARS = {} + for i, b in enumerate(self.TOP, 1): + for k in b["delivered_by"]: + check(k in self.WORK, f"blocker {b['id']}: delivered_by {k!r} is not a work item") + self.CLEARS.setdefault(k, []).append(i) + # Studio items point to work items or site steps; a pointer must share at least one feature with the item. + for x in self.STUDIO_NEW + self.STUDIO_LEGACY: + for ref in x["delivered_by"]: + check(ref in self.WORK or ref in self.STEP, f"Studio item {x['id']}: delivered_by {ref!r} is not a work item or site step") + ids = self.WORK[ref]["features"] if ref in self.WORK else self.STEP[ref][2]["features"] + check(set(ids) & set(x["features"]), f"Studio item {x['id']}: {ref!r} shares no feature with it") + + # {{item:ID}} in any HTML field becomes a link to that to-do, named by its current title. + def expand(s): + def one(m): + check(m.group(1) in self.TITLE, f"{{{{item:{m.group(1)}}}}} names no to-do") + return f'{self.TITLE[m.group(1)]}' + return re.sub(r"\{\{item:([a-z0-9-]+)\}\}", one, s) + + for b in self.TOP: + b["today"], b["plan"] = expand(b["today"]), expand(b["plan"]) + for x in self.STUDIO_NEW + self.STUDIO_LEGACY: + x["today"] = expand(x["today"]) + for c in self.CHANGES: + c["plan"] = expand(c["plan"]) + for s in d["sites"]: + for step in s["steps"]: + step["text"] = expand(step["text"]) + self.HEADLINE = {s["id"]: expand(s["headline"]) for s in d["sites"]} + self.OV = {k: expand(v) for k, v in d["overview"].items()} + + def check_title(self, t, where): + # Titles are HTML: plain text, entities and only. + rest = re.sub(r"", "", t) + check(t.strip() == t and t, f"{where}: empty title or stray whitespace") + check(not re.search(r"[<>]|&(?!(?:amp|lt|gt|quot|#\d+);)", rest), f"{where}: title has markup other than : {t!r}") + + def check_features(self, ids, where): + bad = [i for i in ids if i not in self.FEATS] + check(ids and not bad, f"{where}: unknown or missing feature ids {bad}") + + # ------------------------------------------------------------------ helpers + def counts(self, ids): + c = Counter(self.FEATS[i]["status"] for i in ids) + return [(v, c.get(v, 0)) for v in self.VERDICTS] + + def vword(self, v, n): + return self.VWORD1[v] if n == 1 else self.VWORD[v] + + def vq(self, v): + return f"“{self.VWORD1[v]}”" + + def clink(self, k): + return f'{self.TITLE[k]}' + + def section(self, sid, body, cls="", count=None): + """A page section. Its eyebrow carries the section's honest count ("24 to do").""" + s = self.SEC[sid] + eb = esc(s["eyebrow"]) + (f'{count}' if count else "") + return (f'
    \n' + f'

    {eb}

    {s["title"]}

    \n{body}\n
    ') + + def vpill(self, v, own=True): + """Status pill. Inside an element that already carries data-v, own=False.""" + return f'{self.VLABEL[v]}' + + def chips(self, ids): + """Feature chips. To keep the single-file page small, the ids are listed once and the + inline script at the end expands each into Name, reading + the name and status from that feature's row (static HTML). The "Features" label is CSS.""" + return f'
    ' + + def site_tags(self, repos): + return f'{" · ".join(self.SHORT[r] for r in repos)}' if repos else "" + + def sites_of(self, ids): + return [r for r in self.REPOS if any(r in self.FEATS[x]["sites"] for x in ids)] + + # .rm-sr is visually hidden (roadmap.css) and left out of the search index (app.js). + SR_TODO = 'To do' + + @staticmethod + def todo_h3(hid, title_html, n=None): + """A to-do's heading. The unchecked box is CSS (h3::before, decorative); the item's first + child says "To do" to screen readers. A number and the title sit in two spans side by side, + so a title that wraps hangs under its own first line.""" + num = f'{n:02d} ' if n else "" + return f'

    {num}{title_html}

    ' + + @staticmethod + def detail(rows): + """Today / Plan (and link rows) as a description list. rows: (label, html). The Today row carries + a class: it sits on the plain surface, and the rows after it on a fill (roadmap.css). Link-run rows + (any label but Today and Plan) mark their dd, so roadmap.css can space the links apart.""" + return '
    ' + "".join( + f'
    {lab}
    {body}
    ' + for lab, body in rows) + "" + + def todo_li(self, h3, *parts, note=False): + """A to-do (box drawn by CSS), or with note=True a note with nothing to do (no box, "Note" to screen readers).""" + head = '
  • Note' if note else f"
  • {self.SR_TODO}" + return head + h3 + "".join(p for p in parts if p) + "
  • " + + def legend(self, ids): + return "".join(f'{n} {self.vword(v, n)}' for v, n in self.counts(ids) if n) + + def key(self): + # The colour key for chip dots (the to-build marker is a diamond, so the key doesn't rely on colour alone). + return "".join(f'{self.vword(v, 1)}' for v in self.VERDICTS if self.TOTALS[v]) + + def open_work(self, ids): + eff = Counter(self.FEATS[i]["effort"] for i in ids if self.FEATS[i]["status"] in OPEN) + return f'Open work: {eff.get("L", 0)} L · {eff.get("M", 0)} M · {eff.get("S", 0)} S' + + def bar_row(self, label_html, ids, desc=""): + cs = [(v, n) for v, n in self.counts(ids) if n] + aria = ", ".join(f"{n} {self.vword(v, n)}" for v, n in cs) + segs = "".join(f'' for v, n in cs) + return (f'

    {label_html}{len(ids)} features

    ' + f'' + f'

    {self.legend(ids)}{self.open_work(ids)}

    ' + + (f'

    {desc}

    ' if desc else "") + "
    ") + + def legend_p(self): + # The status legend: a short definition list, one status per line, each label with its status marker (CSS). + items = "".join(f'
    {self.VLABEL[v]}
    {self.LEGEND[v]}
    ' + for v in self.VERDICTS) + return f'
    {items}
    ' + + def ref_link(self, ref): + """A link to a work item or, with the site and step number, to a site step.""" + if ref in self.STEP: + r, n, st = self.STEP[ref] + return f'{st["title"]} ({self.SHORT[r]} step {n})' + return self.clink(ref) + + def blocker_lede(self): + all_sites = all(self.sites_of(g["features"]) == self.REPOS for g in self.TOP) + return ("Ranked by blast radius, the assessment's judgment of how much each one blocks, so the order doesn't follow the " + "feature count shown beside each." + (" Every one hits all three sites." if all_sites else "")) + + # ------------------------------------------------------------------ 1 · overview + def overview(self): + n, T = len(self.FEATS), self.TOTALS + intro, lead = self.OV["intro"].strip(), self.OV["readiness_lead"].strip() + # The intro and the readiness line are copy; fail if the numbers or links move under them. + R = self.REPOS + for s in (*(f'{self.NAME[r]}' for r in R), + 'The ten release blockers gate the release', + 'Release readiness'): + check(s in intro, f"overview.intro must contain {s!r}") + for s in (f"None of the {n} features", f"{T['to-build']} are still to build", f"{T['to-finish']} are to finish", + f"{T['site-code']} are left to site code", f"{T['goes-away']} go away"): + check(s in lead, f"overview.readiness_lead must contain {s!r} (the counts come from the features)") + tiles = "".join(f'
  • {T[v]}{self.vpill(v, False)}{esc(self.TILE[v])}
  • ' + for v in self.VERDICTS) + bars = self.bar_row("All three sites", list(self.FEATS)) + "".join( + self.bar_row(f'{self.NAME[r]}', self.REPO_IDS[r], f"{self.REPO_DESC[r]} {self.HEADLINE[r]}") + for r in self.REPOS) + top = "".join( + f'
  • To do: {g["title"]}{len(g["features"])} features
  • ' + for g in self.TOP) + shorts = [f"{self.SHORT[r]}" for r in self.REPOS] + short_names = ", ".join(shorts[:-1]) + " and " + shorts[-1] + n_studio = len(self.STUDIO_NEW) + len(self.STUDIO_LEGACY) + n_steps = sum(len(v) for v in self.PLAN.values()) + # The overview's to-do callouts share one shape: the box, an imperative title that links to the item, then the text. + # fix_now (a live bug to fix whether or not a site migrates) is optional. + fix_now = f'

    To do: {self.OV["fix_now"]}

    \n' if self.OV.get("fix_now") else "" + fix_docs = f'

    To do: {self.OV["fix_docs"]}

    ' + F = self.FEATS.values() + n_first = sum(1 for f in F if f["first_rated"]) + n_review = sum(1 for f in F if f["rated_before_review"]) + n_medium = sum(1 for f in F if f["confidence"] != "high") + n_unv = sum(1 for f in F if f["unconfirmed_sub_claim"]) + body = f"""{intro} +{fix_now}

    The ten release blockers

    +

    {self.blocker_lede()} Each links to where it stands today, the plan and the work items that deliver it.

    +
      {top}
    +

    Release readiness

    +

    {lead}

    +
      {tiles}
    +
    {bars}
    +

    Each bar counts the features in its scope: all three sites, then each site. Open work is the effort of the features to build, to finish and left to site code; effort is the assessor's relative size, S, M or L. Short names on this page: {short_names}.

    +{fix_docs} +

    Using this page

    +
      +
    • Release blockers: the ten items that gate the release, each with where it stands today, the plan and the work items that deliver it.
    • +
    • Studio support: what has to work for editors, on a next-major site and with legacy content ({n_studio} items).
    • +
    • Work items: {len(self.CHANGES)} changes, deduplicated from the {n} per-feature proposals, in four groups: the API, the CLI, Studio and the Deco API, and these docs.
    • +
    • Site migrations: each site's plan as a checklist, in execution order ({n_steps} steps).
    • +
    • Feature readiness: 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.
    • +
    +

    The lists overlap, so their counts don't add up to one total: the release blockers and most Studio 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.

    +

    How this list was made: each site's features were catalogued from its code, with file and line evidence, and grouped into {len(self.CATS)} categories. Each feature was then assessed against these docs and verified against Studio's and the framework's code. The verifier changed {n_first} statuses, all toward “to finish”, and a later review changed {n_review} more. {n_medium} of the {n} assessments are medium confidence and the rest are high, and {n_unv} contain a sub-claim the verifier couldn't confirm. Feature readiness marks each of these on its row.

    """ + return self.section("roadmap", body) + + # ------------------------------------------------------------------ 2 · release blockers + def blockers(self): + lede = (f"

    {self.blocker_lede()} Each says where things stand today, the plan, and the work items that deliver it; the chips link to " + "the affected features.

    ") + all_sites = all(self.sites_of(g["features"]) == self.REPOS for g in self.TOP) + lis = [] + for i, g in enumerate(self.TOP, 1): + by = " ".join(self.clink(k) for k in g["delivered_by"]) + (f' {g["delivered_note"]}' if g.get("delivered_note") else "") + rows = [("Today", g["today"]), ("Plan", g["plan"]), ("Delivered by", by)] + meta = "" if all_sites else f'

    {self.site_tags(self.sites_of(g["features"]))}

    ' + lis.append(self.todo_li(self.todo_h3(g["id"], g["title"], i), meta, self.detail(rows), self.chips(g["features"]))) + return self.section("roadmap-blockers", f'{lede}\n
      {"".join(lis)}
    ', "gx-items", f"{len(self.TOP)} to do") + + # ------------------------------------------------------------------ 3 · studio support + def studio(self, sid, lede, items): + # Unnumbered: unlike the blockers and the site plans, the order isn't a ranking. + lis = [self.todo_li(self.todo_h3(x["id"], x["title"]), + self.detail([("Today", x["today"])] + ([("Delivered by", " ".join(self.ref_link(r) for r in x["delivered_by"]))] if x["delivered_by"] else [])), + self.chips(x["features"])) for x in items] + return self.section(sid, f'

    {lede}

    \n
      {"".join(lis)}
    ', "gx-items", f"{len(items)} to do") + + # ------------------------------------------------------------------ 4 · work items + def changes(self, key): + sid = GROUP_SEC[key] + # Pinned items first, in data order (the docs items that correct statements that are wrong today); + # then by how many features need them, ties in data order. + items = sorted((c for c in self.CHANGES if c["group"] == key), + key=lambda c: (0, 0) if c.get("pinned") else (1, -len(c["features"]))) + n_pinned = sum(1 for c in items if c.get("pinned")) + check(key == "docs" and n_pinned == 2 or key != "docs" and n_pinned == 0, + "exactly two docs work items are pinned (the docs lede says so), and no other") + sort = "They're sorted by how many features need them." + lede = { + "api": f"{len(items)} changes to the SDK, the bindings and the @decocms/apps-* packages. {sort}", + "cli": f"{len(items)} changes to the deco CLI's output, codegen and migration tooling. {sort}", + "studio": f"{len(items)} changes on the Studio side and in the Deco API's release service. {sort}", + "docs": f"{len(items)} 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.", + }[key] + lis = [] + for c in items: + 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. + n_build = sum(1 for x in ids if self.FEATS[x]["status"] == "to-build") + nf = f'{len(ids)} feature{"s" if len(ids) != 1 else ""}' + if n_build: + nf += ", to build" if n_build == len(ids) == 1 else f", {n_build} of them to build" + meta = f'{nf}' + self.site_tags(self.sites_of(ids)) + rows = [("Plan", c["plan"])] + if c["docs"]: + rows.append(("In these docs", " ".join(f'{esc(self.doc_label[x])}' for x in c["docs"]))) + if c["id"] in self.CLEARS: + # One part of the blocker, not all of it: most blockers need two to four work items. + cl = self.CLEARS[c["id"]] + rows.append(("Part of blocker" + ("s" if len(cl) > 1 else ""), + " ".join(f'{self.TOP[i - 1]["title"]}' for i in cl))) + lis.append(self.todo_li(self.todo_h3(c["id"], c["title"]), f'

    {meta}

    ', self.detail(rows), self.chips(ids))) + body = (f'

    {lede} A chip\'s dot shows that feature\'s status: {self.key()}.

    \n' + f'
      {"".join(lis)}
    ') + return self.section(sid, body, "gx-items", f"{len(items)} to do") + + # ------------------------------------------------------------------ 5 · site migrations + def site_plan(self, r): + sid = self.SITE_SEC[r] + ids = self.REPO_IDS[r] + steps = self.PLAN[r] + n_note = sum(1 for s in steps if s["no_action"]) + parts = [f"

    {self.HEADLINE[r]}

    ", + f'

    The site{self.REPO_DESC[r]}

    ', + f'
    {self.bar_row(f"{self.NAME[r]}", ids)}
    ', + "

    The steps are in execution order, each tagged: Fix now (a live bug, migration or not), Before migrating " + "(an upgrade that comes first), Blocker (must be solved before this site can move; separate from the ten release " + "blockers), Site work, Content (stored content and data shapes), and Note." + + (" A note with nothing to do has no box." if n_note else "") + " " + f'See where the {len(ids)} features it uses stand.

    '] + lis = [self.todo_li(self.todo_h3(s["id"], s["title"], i), f'

    {KIND[s["kind"]]}

    ', + f'

    {s["text"]}

    ', self.chips(s["features"]), note=s["no_action"]) + for i, s in enumerate(steps, 1)] + parts.append(f'
      {"".join(lis)}
    ') + count = f"{len(steps) - n_note} to do" + (f" · {n_note} note" if n_note else "") + return self.section(sid, "\n".join(parts), "gx-items gx-plan", count) + + # ------------------------------------------------------------------ 6 · feature readiness + def row_tags(self, f): + # 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 ..."). + t = "" + if f["confidence"] != "high": + t += 'Medium confidence' + if f["first_rated"]: + t += f'First rated {self.vq(f["first_rated"])}' + if f["rated_before_review"]: + t += f'Rated {self.vq(f["rated_before_review"])} before review' + if f["unconfirmed_sub_claim"]: + t += 'Unconfirmed sub-claim' + return t + + def feature_index(self): + V_RANK = {v: i for i, v in enumerate(self.VERDICTS)} + T = self.TOTALS + vbtn = 'Status' + "".join( + f'' + for v in self.VERDICTS if T[v]) + sbtn = 'Site' + "".join( + f'' + for r in self.REPOS) + parts = [ + f"

    All {len(self.FEATS)} features the three sites use, grouped into {len(self.CATS)} 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.

    ", + self.legend_p(), + f''] + for cat in self.CATS: + ids = [fid for fid, f in self.FEATS.items() if f["category"] == cat["id"]] + ids.sort(key=lambda x: (V_RANK[self.FEATS[x]["status"]], EFF_RANK[self.FEATS[x]["effort"]], self.FEATS[x]["name"])) + title = cat["title"] + rows = [] + for fid in ids: + f = self.FEATS[fid] + docs = " ".join(f'{esc(self.doc_label[x])}' for x in f["docs"]) + rows.append( + f'
  • ' + f'

    {esc(f["name"])}

    {self.vpill(f["status"], False)}Effort {f["effort"]}{self.site_tags(f["sites"])}{self.row_tags(f)}

    ' + f'

    {md(f["summary"])}

    ' + (f'

    {docs}

    ' if docs else "") + "
  • ") + parts.append( + f'

    {esc(title)}

    \n' + f'

    {len(ids)} feature{"s" if len(ids) != 1 else ""} · {esc(cat["description"])}{self.legend(ids)}

    \n' + f'
      {"".join(rows)}
    ') + # "Goes away" features can never be done, so the count leaves them out and names them. + done, gone = T["done"], T["goes-away"] + return self.section("roadmap-features", "\n".join(parts), "gx-index", f"{done} of {len(self.FEATS) - gone} done · {gone} go away") + + # ------------------------------------------------------------------ assemble + def render(self): + secs = [ + self.overview(), + self.blockers(), + self.studio("roadmap-studio-new", "What has to work when a migrated site runs against today's Studio. 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.", self.STUDIO_NEW), + self.studio("roadmap-studio-legacy", "Content and assumptions Studio 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.", self.STUDIO_LEGACY), + self.changes("api"), self.changes("cli"), self.changes("studio"), self.changes("docs"), + *(self.site_plan(r) for r in self.REPOS), + self.feature_index(), + ] + out = "\n\n".join(secs) + "\n\n" + SCRIPT + "\n" + out = re.sub(r"\n +<", "\n<", out) # no source indentation: every byte counts in the single-file build + out = re.sub(r"\n +", "\n", out) + self.checks(out) + self.out = out + return out + + # ------------------------------------------------------------------ checks + def checks(self, out): + visible = re.sub(r"", "", out, flags=re.S) + text = html.unescape(re.sub(r"<[^>]+>", " ", visible)) + + def near(pat): + return re.findall(r".{0,40}(?:" + pat + r").{0,40}", text, re.I)[:5] + + # Wording: these docs are the spec here, anchors are links not "#ids", and no undefined abbreviations. + for pat, what in ((r"\bspec\b", "'spec'"), (r"(?]*>(.*?)', out) + labels += re.findall(r'<(dt|button|b class="gx-vp"|span class="gx-kind"|i class="gx-tg"|p class="eyebrow")[^>]*>(.*?) 1] + check(not dupes, f"duplicate ids: {dupes}") + clash = sorted(set(ids) & self.site_ids) + check(not clash, f"ids that collide with the rest of the site: {clash}") + sec_ids = re.findall(r'
    To do

    To do'), f"to-dos: {len(todos)} rendered, {n_todo} in the data") + check(out.count('
  • Note') == len(self.TOP) + len(self.STUDIO_NEW) + len(self.STUDIO_LEGACY) + len(self.CHANGES), "detail lists") + rows = re.findall(r'
  • ', out) for x in lst.split()] + check(chip_targets and all(t in self.FEATS for t in chip_targets), "chip targets") + check(out.count('class="gx-chips"') == len(re.findall(r'
    ', out)), "empty chip lists") + n_tags = Counter(re.findall(r']*>([A-Z][a-z]+)', out)) + F = self.FEATS.values() + check(n_tags["Medium"] == sum(1 for f in F if f["confidence"] != "high") + and n_tags["Rated"] == sum(1 for f in F if f["rated_before_review"]) + and n_tags["Unconfirmed"] == sum(1 for f in F if f["unconfirmed_sub_claim"]) + and n_tags["First"] == sum(1 for f in F if f["first_rated"]), f"row tags: {n_tags}") + counts_by_sec = {s: c for s, c in re.findall(r'
    ]*>\n

    [^<]*([^<]+)', out)} + check(list(counts_by_sec) == SEC_IDS[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(sum(len(v) for v in self.CLEARS.values()) == sum(len(b["delivered_by"]) for b in self.TOP), "blocker back-links") + self.stats = dict(sec_ids=sec_ids, counts_by_sec=counts_by_sec, todos=len(todos), rows=len(rows), chips=len(chip_targets), + hrefs=hrefs, n_tags=n_tags, doc_links=sorted({h for h in hrefs if h in self.doc_label})) + + +def render(data=None): + """The Roadmap fragment. data: the parsed roadmap.json (read from data/roadmap.json by default).""" + if data is None: + data = json.loads(DATA.read_text(encoding="utf-8")) + return Roadmap(data).render() + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + ap.add_argument("--out", help="also write the rendered fragment to this file") + args = ap.parse_args() + # The summary has a few non-ASCII characters (·, ›); never fail on a terminal that can't show them. + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(errors="replace") + rm = Roadmap(json.loads(DATA.read_text(encoding="utf-8"))) + out = rm.render() + if args.out: + pathlib.Path(args.out).write_text(out, encoding="utf-8") + print(f"wrote {args.out} {len(out.encode()) / 1024:.1f} KB") + s = rm.stats + print("sections:", ", ".join(f"{x} [{s['counts_by_sec'].get(x, '-')}]" for x in s["sec_ids"])) + print(f"to-dos: {s['todos']} (blockers {len(rm.TOP)}, studio {len(rm.STUDIO_NEW)}+{len(rm.STUDIO_LEGACY)}, work items {len(rm.CHANGES)}, " + f"site steps {'+'.join(str(len(rm.PLAN[r])) for r in rm.REPOS)}) rows: {s['rows']} chips: {s['chips']} internal links: {len(s['hrefs'])}") + print("statuses:", {rm.VLABEL[k]: v for k, v in rm.TOTALS.items()}, + "per site:", {rm.SHORT[r]: dict(rm.counts(rm.REPO_IDS[r])) for r in rm.REPOS}) + print("row tags:", dict(s["n_tags"])) + print("links into Docs/Under the hood:", s["doc_links"]) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/site/src/app.js b/site/src/app.js new file mode 100644 index 00000000..ce6ebad8 --- /dev/null +++ b/site/src/app.js @@ -0,0 +1,920 @@ +(() => { + 'use strict'; + const ICONS = {{icons_json}}; + const $ = (s, r = document) => r.querySelector(s); + const $$ = (s, r = document) => [...r.querySelectorAll(s)]; + const root = document.documentElement; + const body = document.body; + const reduceMotion = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; + const icon = name => ICONS[name] || ''; + + /* ------------------------------------------------------------------ Theme */ + const THEME_KEY = 'deco-blocks-docs-theme'; + const mqDark = window.matchMedia ? matchMedia('(prefers-color-scheme: dark)') : null; + // The header toggle, plus the one in the mobile drawer (the landing moves it there below 900px). + const themeBtns = $$('[data-theme-toggle]'); + const effectiveTheme = () => { + const t = root.dataset.theme; + if (t === 'dark' || t === 'light') return t; + return mqDark && mqDark.matches ? 'dark' : 'light'; + }; + function syncThemeButton() { + const next = effectiveTheme() === 'dark' ? 'light' : 'dark'; + themeBtns.forEach(btn => { + btn.setAttribute('aria-label', 'Switch to ' + next + ' mode'); + btn.title = 'Switch to ' + next + ' mode'; + const label = $('.theme-label', btn); + if (label) label.textContent = next === 'dark' ? 'Dark mode' : 'Light mode'; + }); + } + function setTheme(theme) { + root.dataset.theme = theme; + try { localStorage.setItem(THEME_KEY, theme); } catch (e) {} + syncThemeButton(); + } + themeBtns.forEach(btn => btn.addEventListener('click', () => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark'))); + if (mqDark && mqDark.addEventListener) mqDark.addEventListener('change', syncThemeButton); + syncThemeButton(); + + /* -------------------------------------------------------------- Structure */ + const pages = $$('main .page'); + const sections = $$('main .page > section'); + const pageOf = new Map(); + pages.forEach(page => $$(':scope > section', page).forEach(section => pageOf.set(section.id, page.dataset.page))); + const groupsByPage = { + home: [], + docs: [["Getting started", ["architecture", "quickstart"]], ["Core concepts", ["model", "schema", "preview"]], ["Advanced", ["manifest", "variants"]], ["Websites", ["content-types", "routing", "rendering"]], ["Reference", ["standalone"]], ["Framework guides", ["nextjs", "tanstack-data", "tanstack-rsc"]], ["Production", ["releases", "operations", "adoption", "troubleshooting"]]], + internals: [["Under the hood", ["internals", "walkthrough", "loader-internals", "router-internals", "studio", "decisions"]]], + roadmap: [["Overview", ["roadmap"]], ["Release blockers", ["roadmap-blockers"]], ["Studio support", ["roadmap-studio-new", "roadmap-studio-legacy"]], ["Work items", ["roadmap-api", "roadmap-cli", "roadmap-platform", "roadmap-docs"]], ["Site migrations", ["roadmap-storefront", "roadmap-blog", "roadmap-faststore"]], ["Feature readiness", ["roadmap-features"]]], + }; + const pageLabels = { home: 'Home', docs: 'Docs', internals: 'Under the hood', roadmap: 'Roadmap' }; + const pageHeadings = { docs: 'Deco Blocks documentation', internals: 'Deco Blocks: under the hood', roadmap: 'Deco Blocks: next-major roadmap' }; + const groupIcons = { 'Getting started': 'play', 'Core concepts': 'box', 'Advanced': 'sliders', 'Websites': 'globe', 'Reference': 'book', 'Framework guides': 'layers', 'Production': 'server', 'Under the hood': 'cpu', 'Overview': 'eye', 'Release blockers': 'zap', 'Studio support': 'pencil', 'Work items': 'git-branch', 'Site migrations': 'globe', 'Feature readiness': 'list' }; + const pageEntry = { home: 'home', docs: 'architecture', internals: 'internals', roadmap: 'roadmap' }; + const groupOf = new Map(); + const reading = []; + // One reading order across the doc pages: the pager runs Docs → Under the hood → Roadmap. + ['docs', 'internals', 'roadmap'].forEach(page => groupsByPage[page].forEach(([group, ids]) => ids.forEach(id => { groupOf.set(id, group); reading.push(id); }))); + const byId = id => document.getElementById(id); + const navLabel = section => section.dataset.nav || ($('h2', section) && $('h2', section).textContent.trim()) || 'Overview'; + // A section alone in a sidebar group of the same name (the Roadmap's "Overview") goes by its + // heading in search results and the pager, where "Overview" alone would say little. + const soleInGroup = section => groupOf.get(section.id) === navLabel(section); + const cardTitle = section => (soleInGroup(section) && section.dataset.title) || navLabel(section); + + /* ------------------------------------------------ Heading ids and anchors */ + const slugify = text => text.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'section'; + // A section's title heading: the landing's h1, otherwise the article's h2. + const titleHeading = section => $('h1', section) || $('h2', section); + // The permalink sits inside its heading; the heading keeps its own text as its name, so a + // screen reader's heading list says "API reference", not "API reference Link to API reference". + const addAnchor = (heading, href, text) => { + heading.setAttribute('aria-label', text); + const a = document.createElement('a'); + a.className = 'heading-anchor'; a.href = href; a.innerHTML = icon('link'); + a.setAttribute('aria-label', 'Link to ' + text); + heading.append(a); + }; + sections.forEach(section => { + const h2 = titleHeading(section); + if (h2) { section.dataset.title = h2.textContent.trim().replace(/\s+/g, ' '); h2.tabIndex = -1; } + if (section.id === 'home') return; + if (h2) addAnchor(h2, '#' + section.id, section.dataset.title); + $$('h3', section).forEach(h3 => { + const text = h3.textContent.trim(); + if (!h3.id) { + const base = section.id + '--' + slugify(text); + let id = base, n = 2; + while (byId(id)) id = base + '-' + n++; + h3.id = id; + } + h3.dataset.title = text; + addAnchor(h3, '#' + h3.id, text); + }); + }); + + /* ------------------------------------------------------------------ Toast */ + const live = $('#live'); + function announce(message) { + // Cleared first and set a beat later, so a repeat of the same message is still read out. + live.textContent = ''; + clearTimeout(announce.timer); announce.timer = setTimeout(() => { live.textContent = message; }, 60); + } + function toast(message) { + const el = $('#toast'); + el.textContent = message; el.hidden = false; + announce(message); + clearTimeout(toast.timer); toast.timer = setTimeout(() => { el.hidden = true; }, 2200); + } + async function copyText(text) { + try { await navigator.clipboard.writeText(text); return true; } catch (e) { + const field = document.createElement('textarea'); + field.value = text; field.setAttribute('readonly', ''); field.style.position = 'fixed'; field.style.opacity = '0'; + document.body.append(field); field.select(); + let ok = false; try { ok = document.execCommand('copy'); } catch (err) {} + field.remove(); return ok; + } + } + + /* ------------------------------------------------------------ Code blocks */ + const languageLabels = { typescript: 'TypeScript', tsx: 'TSX', json: 'JSON', jsonc: 'JSON', bash: 'Shell', text: 'Text' }; + const extensionLanguages = { ts: 'typescript', mts: 'typescript', tsx: 'tsx', jsx: 'tsx', js: 'typescript', json: 'json', jsonc: 'jsonc', sh: 'bash' }; + // JSX in a TypeScript block: a closing tag, a self-closing capitalized tag, or a fragment. + const looksLikeJsx = text => /<\/[A-Za-z][\w.]*>|<[A-Z][\w.]*(\s[^<>]*)?\/>|<>|<\/>/.test(text); + function fileNameFor(pre) { + const caption = pre && pre.closest('figure.code-example') && pre.closest('figure.code-example').querySelector('figcaption'); + return caption ? caption.textContent.trim() : ''; + } + function highlightCode(code, language) { + const pre = code.closest('pre'); + const fileName = fileNameFor(pre); + const extension = (fileName.match(/\.(\w+)(?:\s|$|\))/) || [])[1]; + language = language || extensionLanguages[extension] || (code.className.match(/language-([\w-]+)/) || [])[1] || 'typescript'; + if (language === 'typescript' && looksLikeJsx(code.textContent)) language = 'tsx'; + code.className = 'language-' + language; + if (pre) { + pre.dataset.lang = languageLabels[language] || language; + pre.dataset.label = fileName || pre.dataset.lang; + pre.classList.toggle('has-file', Boolean(fileName)); + if (fileName) pre.setAttribute('aria-label', fileName); + } + if (window.Prism && Prism.languages[language]) Prism.highlightElement(code); + } + $$('pre > code').forEach(code => { if (!code.closest('.jp, .ci-visual')) highlightCode(code); }); + const langIcon = label => label === 'Shell' ? icon('terminal') : icon('code'); + + function makeCopyButton(getText, withLabel) { + const btn = document.createElement('button'); + btn.type = 'button'; btn.className = 'copy-button' + (withLabel ? ' has-label' : ''); + btn.setAttribute('aria-label', 'Copy code example'); + btn.innerHTML = icon('copy') + icon('check') + (withLabel ? 'Copy' : ''); + const label = btn.querySelector('.copy-label'); + btn.addEventListener('click', async () => { + const ok = await copyText(getText()); + if (!ok) { toast('Select the code to copy it.'); return; } + btn.classList.add('copied'); btn.setAttribute('aria-label', 'Copied'); announce('Copied to clipboard'); + if (label) label.textContent = 'Copied'; + setTimeout(() => { btn.classList.remove('copied'); btn.setAttribute('aria-label', 'Copy code example'); if (label) label.textContent = 'Copy'; }, 1800); + }); + return btn; + } + const langBadge = label => { const lang = document.createElement('span'); lang.className = 'code-lang'; lang.textContent = label; lang.setAttribute('aria-hidden', 'true'); return lang; }; + // "src/cms/cms.ts" is a path; "A plain request handler" describes the block; "cms.index.tsx + // (Next.js)" and ".deco/schema.json, abridged" are a path with a note. + const PATH = /^([^\s,]*\/[^\s,]*|[^\s,]+\.[a-z]\w*)(?=$|[\s,])/i; + $$('pre').forEach(pre => { + if (pre.closest('.explorer, .jp, .ci-visual, .sw')) return; + const code = $('code', pre); + const text = () => (code ? code.textContent : pre.textContent); + const file = fileNameFor(pre); + const raw = text().replace(/\s+$/, ''); + const isBash = /language-bash/.test(code ? code.className : ''); + const panel = document.createElement('div'); + panel.className = 'code-panel' + (raw.includes('\u2502') ? ' is-diagram' : ''); + pre.before(panel); + let label = pre.dataset.lang || 'Code'; + if (!file && isBash && /^https?:\/\//.test(raw)) label = 'URL'; + if (!pre.hasAttribute('aria-label')) pre.setAttribute('aria-label', label === 'URL' ? 'URL' : label + ' code'); + if (!file && !raw.includes('\n') && isBash && /^(npm|npx|pnpm|yarn|bun)\b/.test(raw)) { + // A one-line shell command: a compact prompt row with its language badge and copy button. + panel.classList.add('is-oneline', 'is-cmd'); + panel.append(pre, langBadge(label), makeCopyButton(text, false)); + return; + } + const path = file && file.match(PATH); + const kind = !file ? 'is-lang' : path ? '' : 'is-desc'; + const head = document.createElement('div'); + head.className = 'code-head' + (kind ? ' ' + kind : ''); + head.innerHTML = kind ? langIcon(label) : icon('file'); + const name = document.createElement('span'); name.className = 'code-file'; name.setAttribute('aria-hidden', 'true'); + if (path && path[0].length < file.length) { + const note = document.createElement('span'); note.className = 'cf-note'; note.textContent = file.slice(path[0].length); + name.append(path[0], note); + } else name.textContent = file || label; + head.append(name); + if (file) head.append(langBadge(label)); + head.append(makeCopyButton(text, true)); + panel.append(head, pre); + }); + + /* Scroll hints and focus for code that doesn't fit its panel. A block that scrolls is a tab + stop (a named region, so arrow keys can scroll it); one that fits is not. The fade marks the + edge it continues past, and goes once that edge is reached. */ + function fadeCode(pre) { + const x = pre.scrollLeft, maxX = pre.scrollWidth - pre.clientWidth, 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); + } + function fitCode(scope) { + $$('.code-panel > pre', scope).forEach(pre => { + 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'); } + else { pre.removeAttribute('tabindex'); pre.removeAttribute('role'); } + fadeCode(pre); + }); + } + $$('.code-panel > pre').forEach(pre => pre.addEventListener('scroll', () => { + if (pre.fadeTick) return; + pre.fadeTick = requestAnimationFrame(() => { pre.fadeTick = 0; fadeCode(pre); }); + }, { passive: true })); + + // Short inline code ("product-card") stays on one line; long tokens may still wrap anywhere. + $$('.doc-section :not(pre) > code').forEach(c => { if (c.textContent.trim().length <= 24) c.classList.add('is-short'); }); + $$('main table').forEach(table => { + if (!table.parentElement.classList.contains('table-wrap')) { + const wrap = document.createElement('div'); wrap.className = 'table-wrap'; + table.before(wrap); wrap.append(table); + } + // Multi-word code (signatures like `localLoader({ revision, blocks })`) may wrap at its + // spaces on phones; single identifiers such as "not-found" stay on one line. + $$('td code, th code', table).forEach(c => { if (/\s/.test(c.textContent.trim())) c.classList.add('can-wrap'); }); + }); + + /* ------------------------------------------------------------ Sidebar toc */ + const toc = $('#toc'); + const navLinks = new Map(); + let tocPage = null; + function buildToc(page) { + const source = page === 'home' ? 'docs' : page; + if (tocPage === source) return; + tocPage = source; + toc.replaceChildren(); navLinks.clear(); + groupsByPage[source].forEach(([title, ids], gi) => { + const group = document.createElement('div'); group.className = 'nav-group'; + const label = document.createElement('p'); label.className = 'nav-label'; + label.id = 'nav-group-' + source + '-' + gi; + label.innerHTML = icon(groupIcons[title] || 'book'); label.append(title); + const list = document.createElement('ul'); list.className = 'nav-list'; + list.setAttribute('aria-labelledby', label.id); + ids.forEach(id => { + const section = byId(id); if (!section) return; + const li = document.createElement('li'); + const a = document.createElement('a'); a.href = '#' + id; a.textContent = navLabel(section); + li.append(a); list.append(li); navLinks.set(id, a); + }); + group.append(label, list); toc.append(group); + }); + } + function fadeToc() { + const max = toc.scrollHeight - toc.clientHeight; + toc.classList.toggle('more-above', toc.scrollTop > 1); + toc.classList.toggle('more-below', max > 1 && toc.scrollTop < max - 1); + } + toc.addEventListener('scroll', fadeToc, { passive: true }); + + /* ----------------------------------------------------------- Article chrome */ + const crumb = $('#breadcrumb'); + function buildBreadcrumb(section) { + const page = pageOf.get(section.id); + crumb.replaceChildren(); + const add = (text, href) => { + const li = document.createElement('li'); + if (href) { const a = document.createElement('a'); a.href = href; a.textContent = text; li.append(a); } + else { const span = document.createElement('span'); span.textContent = text; li.append(span); } + crumb.append(li); return li; + }; + add(pageLabels[page], '#' + pageEntry[page]); + const group = groupOf.get(section.id); + if (group && group !== pageLabels[page] && group !== navLabel(section)) add(group); + const last = add(navLabel(section)); + last.firstChild.setAttribute('aria-current', 'page'); + } + const pager = $('#pager'); + function pagerCard(id, dir) { + const section = byId(id); + const a = document.createElement('a'); + a.href = '#' + id; a.className = dir === 'prev' ? 'pg-prev' : 'pg-next'; + const d = document.createElement('span'); d.className = 'pg-dir'; + d.innerHTML = dir === 'prev' ? icon('arrow-left') + 'Previous' : 'Next' + icon('arrow-right'); + const t = document.createElement('span'); t.className = 'pg-title'; t.textContent = cardTitle(section); + const s = document.createElement('span'); s.className = 'pg-sub'; + const page = pageOf.get(id); + const group = groupOf.get(id); + s.textContent = group && group !== pageLabels[page] && group !== navLabel(section) ? pageLabels[page] + ' › ' + group : pageLabels[page]; + a.append(d, t, s); + return a; + } + function buildPager(section) { + pager.replaceChildren(); + const i = reading.indexOf(section.id); + if (i < 0) return; + if (i > 0) pager.append(pagerCard(reading[i - 1], 'prev')); + if (i < reading.length - 1) pager.append(pagerCard(reading[i + 1], 'next')); + } + + /* -------------------------------------------------- Right rail + scroll-spy */ + const railList = $('#rail-list'); + const indicator = $('#rail-indicator'); + const inlineList = $('#toc-inline-list'); + const tocInline = $('#toc-inline'); + let spy = []; + function railLabel(el) { + const clone = el.cloneNode(true); + $$('.heading-anchor', clone).forEach(n => n.remove()); + return clone.innerHTML.trim(); + } + const railNav = $('#rail-nav'); + inlineList.addEventListener('click', event => { if (event.target.closest('a')) tocInline.open = false; }); + function placeInlineToc(section) { + // Below 1200px the page outline sits under the title and lede, not above them. + const h2 = $('h2', section); + let anchor = h2; + if (h2 && h2.nextElementSibling && h2.nextElementSibling.tagName === 'P') anchor = h2.nextElementSibling; + if (anchor) anchor.after(tocInline); else section.prepend(tocInline); + } + function buildRail(section) { + railList.replaceChildren(); inlineList.replaceChildren(); + spy = []; + const items = [{ id: section.id, el: section, html: 'Overview', top: true }] + .concat($$('h3', section).filter(h3 => !h3.hidden).map(h3 => ({ id: h3.id, el: h3, html: railLabel(h3) }))); + railNav.hidden = items.length < 2; + placeInlineToc(section); + items.forEach(item => { + const li = document.createElement('li'); + const a = document.createElement('a'); a.href = '#' + item.id; a.innerHTML = item.html; + li.append(a); railList.append(li); + const li2 = document.createElement('li'); if (item.top) li2.className = 'is-top'; + const a2 = document.createElement('a'); a2.href = '#' + item.id; a2.innerHTML = item.html; + li2.append(a2); inlineList.append(li2); + spy.push({ el: item.el, link: a }); + }); + tocInline.open = false; + tocInline.hidden = items.length < 2; + } + let lastActive = null; + document.addEventListener('roadmap:filter', () => { + if (activeSection && activeSection.id === 'roadmap-features') { buildRail(activeSection); lastActive = null; updateSpy(); } + }); + function updateSpy() { + if (!spy.length || body.dataset.layout !== 'docs') return; + const line = $('#site-header').offsetHeight + 40; + let current = spy[0]; + for (let i = 1; i < spy.length; i++) if (spy[i].el.getBoundingClientRect().top <= line) current = spy[i]; + const doc = document.documentElement; + if (scrollY > 0 && doc.scrollHeight > innerHeight + 8 && innerHeight + scrollY >= doc.scrollHeight - 2) { + // At the very bottom the last headings can't reach the line; pick the last one that is on screen. + for (let i = spy.length - 1; i > 0; i--) { if (spy[i].el.getBoundingClientRect().top < innerHeight) { current = spy[i]; break; } } + } + if (current !== lastActive) { + spy.forEach(s => { s.link.classList.toggle('active', s === current); if (s === current) s.link.setAttribute('aria-current', 'location'); else s.link.removeAttribute('aria-current'); }); + lastActive = current; + // A long outline scrolls inside the rail; keep the current entry in view, clear of the edges. + if (railNav.scrollHeight > railNav.clientHeight + 1) { + const box = railNav.getBoundingClientRect(), r = current.link.getBoundingClientRect(); + if (r.top < box.top + 40 || r.bottom > box.bottom - 24) railNav.scrollTop += r.top - box.top - box.height / 3; + } + } + const a = current.link; + if (a.offsetParent) { + indicator.style.transform = 'translateY(' + a.offsetTop + 'px)'; + indicator.style.height = a.offsetHeight + 'px'; + indicator.classList.add('on'); + } + } + + /* ----------------------------------------------------------------- Header */ + const header = $('#site-header'); + function updateHeader() { + // On the landing the header keeps its on-band look everywhere: over the hero it is + // transparent, and once scrolled it becomes the site's dark floating pill. + const on = body.dataset.layout === 'landing'; + header.classList.toggle('on-band', Boolean(on)); + header.classList.toggle('is-scrolled', window.scrollY > 4); + } + + /* ------------------------------------------------ Entrance on scroll-in */ + // The site's deco-reveal: fade up once when a block first enters the viewport. + let pendingReveals = $$('.reveal'); + const revealAll = () => { pendingReveals.forEach(el => el.classList.add('in')); pendingReveals = []; }; + // Fallback beside the observer: anything already scrolled into (or past) view is shown. + function checkReveals() { + if (!pendingReveals.length || body.dataset.layout !== 'landing') return; + const limit = innerHeight * 0.95; + pendingReveals = pendingReveals.filter(el => { + const r = el.getBoundingClientRect(); + if (r.width && r.top < limit) { el.classList.add('in'); return false; } + return true; + }); + } + if (reduceMotion || !('IntersectionObserver' in window)) revealAll(); + else { + const io = new IntersectionObserver(entries => entries.forEach(entry => { + if (entry.isIntersecting) { entry.target.classList.add('in'); io.unobserve(entry.target); pendingReveals = pendingReveals.filter(el => el !== entry.target); } + }), { rootMargin: '0px 0px -6% 0px', threshold: 0.05 }); + pendingReveals.forEach(el => io.observe(el)); + window.addEventListener('beforeprint', revealAll); + } + + /* ---------------------------------------------------------------- Routing */ + let activePage = null; + let activeSection = null; + function setTabs(page) { + $$('.tabs a, .drawer-tabs a').forEach(a => { + if (a.dataset.tab === page) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); + }); + } + function showSection(section) { + const page = pageOf.get(section.id); + if (page !== activePage) { + activePage = page; + pages.forEach(p => p.classList.toggle('active', p.dataset.page === page)); + body.dataset.layout = page === 'home' ? 'landing' : 'docs'; + body.dataset.page = page; + setTabs(page); + buildToc(page); + const ph = $('#page-heading'); if (ph) ph.textContent = pageHeadings[page] || pageHeadings.docs; + } + if (section !== activeSection) { + activeSection = section; + sections.forEach(s => s.classList.toggle('is-current', s === section)); + navLinks.forEach((a, id) => { const on = id === section.id; a.classList.toggle('active', on); if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); }); + lastActive = null; + if (page !== 'home') { buildBreadcrumb(section); buildPager(section); buildRail(section); fitCode(section); } + document.title = page === 'home' ? 'Deco Blocks — The AI-native headless CMS' : (navLabel(section) === 'Overview' ? pageLabels[page] : navLabel(section)) + ' — Deco Blocks'; + const active = navLinks.get(section.id); + if (active && active.scrollIntoView) { + // Keep the current link clear of the edge fades (28px top, 48px bottom), not just inside the box. + const box = toc.getBoundingClientRect(), r = active.getBoundingClientRect(); + if ((r.top < box.top + 28 && toc.scrollTop > 0) || r.bottom > box.bottom - 48) toc.scrollTop += r.top - box.top - box.height / 3; + } + fadeToc(); + } + } + // A deep link into a collapsed

    opens it first, so the target can be scrolled to. + // (The Roadmap's feature list clears its own status/site filter, before this runs.) + function revealTarget(target, section) { + for (let d = target.closest('details'); d && section.contains(d); d = d.parentElement.closest('details')) d.open = true; + } + function currentId() { + const raw = location.hash.slice(1); + try { return decodeURIComponent(raw); } catch (e) { return raw; } + } + function route(fromUser) { + const id = currentId() || 'home'; + let target = byId(id); + let section = target && target.closest('main .page > section'); + if (!section) { + const entry = pageEntry[id]; + section = byId(entry || 'home'); + target = section; + } + const switching = section !== activeSection; + showSection(section); + closeMenu(false); + if (target && target !== section) revealTarget(target, section); + if (!target || target === section) { + window.scrollTo(0, 0); + if (fromUser && switching) { const h = titleHeading(section); if (h) h.focus({ preventScroll: true }); } + } else { + target.scrollIntoView({ block: 'start', behavior: !switching && !reduceMotion ? 'smooth' : 'auto' }); + } + updateHeader(); + requestAnimationFrame(() => { updateSpy(); checkReveals(); }); + } + document.addEventListener('click', event => { + const a = event.target.closest && event.target.closest('a[href^="#"]'); + if (!a || event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return; + const href = a.getAttribute('href'); + if (href === location.hash || (href === '#home' && !location.hash)) { event.preventDefault(); route(true); } + }); + window.addEventListener('hashchange', () => route(true)); + + $('.skip').addEventListener('click', event => { + event.preventDefault(); + const target = body.dataset.layout === 'docs' && activeSection ? ($('h2', activeSection) || $('#main')) : $('#main'); + target.focus(); + }); + $('#back-to-top').addEventListener('click', () => { + window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' }); + const h = activeSection && $('h2', activeSection); if (h) h.focus({ preventScroll: true }); + }); + + /* ------------------------------------------------------------ Mobile menu */ + const sidebar = $('#sidebar'), overlay = $('#mobile-overlay'), menu = $('#menu-button'); + // While open, the drawer is modal: the page behind it is inert for keyboard and screen readers. + const behindDrawer = () => [$('#main'), $('.site-footer')].filter(Boolean); + function setDrawerModal(on) { + if (on) { sidebar.setAttribute('role', 'dialog'); sidebar.setAttribute('aria-modal', 'true'); } + else { sidebar.removeAttribute('role'); sidebar.removeAttribute('aria-modal'); } + behindDrawer().forEach(el => { el.inert = on; }); + } + function openMenu() { + body.classList.add('nav-open'); menu.setAttribute('aria-expanded', 'true'); + setDrawerModal(true); + const first = $('#toc a.active') || $('.drawer-tabs a') || $('#menu-close'); + setTimeout(() => first && first.focus({ preventScroll: true }), 50); + } + function closeMenu(restoreFocus) { + if (!body.classList.contains('nav-open')) return; + body.classList.remove('nav-open'); menu.setAttribute('aria-expanded', 'false'); + setDrawerModal(false); + if (restoreFocus) menu.focus(); + } + menu.addEventListener('click', () => body.classList.contains('nav-open') ? closeMenu(true) : openMenu()); + $('#menu-close').addEventListener('click', () => closeMenu(true)); + overlay.addEventListener('click', () => closeMenu(true)); + sidebar.addEventListener('keydown', event => { + if (event.key !== 'Tab' || !body.classList.contains('nav-open')) return; + const focusables = $$('a[href], button', sidebar).filter(el => el.offsetParent !== null); + const first = focusables[0], last = focusables[focusables.length - 1]; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + }); + + /* ----------------------------------------------------------------- Search */ + const dialog = $('#search-dialog'), searchInput = $('#search-input'), results = $('#search-results'), searchStatus = $('#search-status'); + // Combobox pattern: the input keeps focus; aria-activedescendant names the result Enter opens. + let optionSeq = 0; + function setActive(link) { + $$('a', results).forEach(a => { const on = a === link; a.classList.toggle('is-active', on); a.setAttribute('aria-selected', on ? 'true' : 'false'); }); + if (link) searchInput.setAttribute('aria-activedescendant', link.id); else searchInput.removeAttribute('aria-activedescendant'); + } + const index = []; + // Index what the reader sees in the content, not the injected code-panel chrome. + // Two elements that touch with no text between them (a caption and its code, a node's title + // and its note, table cells) are separate runs of text; code inside
     is left as written.
    +  // (On the Roadmap page that also leaves out metadata, tags, feature chips, runs of links, the
    +  // detail labels, the section counts and the screen-reader "To do".)
    +  const unindexed = '.code-head, .copy-button, .code-lang, .heading-anchor, .gx-meta, .gx-kind, .gx-chips, .gx-fx-m, .gx-fx-d, .gx-fx-c, .gx-catc, .gx-filter, .gx-bar, .gx-bar-h, .gx-bar-l, .gx-tl-s, .gx-meta-k, .gx-wf dt, .gx-wf > div:not(.rm-t) + div, .rm-sr, .rm-count';
    +  const indexText = el => {
    +    if (!el.querySelector) return el.textContent;
    +    if (el.matches(unindexed)) return '';
    +    const clone = el.cloneNode(true);
    +    $$(unindexed, clone).forEach(n => n.remove());
    +    $$('*', clone).forEach(n => { if (n.nextSibling && n.nextSibling.nodeType === 1 && !n.closest('pre')) n.after(' '); });
    +    return clone.textContent;
    +  };
    +  sections.forEach(section => {
    +    const page = pageOf.get(section.id);
    +    const secLabel = page === 'home' ? 'Overview' : navLabel(section);
    +    // The overview entry also carries the article's visible title and eyebrow, so a search
    +    // for the title ("Routes, redirects, and content types") finds the section itself.
    +    // The eyebrow (usually the group name) is searchable but earns no title bonus.
    +    const eyebrow = [...section.children].find(c => c.classList.contains('eyebrow'));
    +    const title = (section.dataset.title || '').replace(/\s+/g, ' ').trim();
    +    let bucket = { id: section.id, page, section: secLabel, name: page === 'home' ? '' : cardTitle(section), heading: '', title, text: eyebrow ? indexText(eyebrow) : '' };
    +    const flush = () => { bucket.text = bucket.text.replace(/\s+/g, ' ').trim(); if (bucket.text || !bucket.heading) index.push(bucket); };
    +    // On the Roadmap page an 

    may sit inside a list item (a to-do); each one still starts its own + // entry there. (The doc pages keep indexing their direct children only, as before.) + const walk = parent => [...parent.children].forEach(child => { + if (child.tagName === 'H3') { flush(); bucket = { id: child.id, page, section: secLabel, heading: child.dataset.title || child.textContent.trim(), title: '', text: '' }; } + else if (page === 'roadmap' && child.querySelector('h3')) walk(child); + else if (child.tagName !== 'H2' && !child.classList.contains('eyebrow')) bucket.text += ' ' + indexText(child); + }); + walk(section); + flush(); + }); + // Every row of the Roadmap's feature list is its own result, so a feature's name opens its row. + $$('main .page[data-page="roadmap"] [id^="roadmap-f-"]').forEach(row => { + const section = row.closest('main .page > section'); + if (!section) return; + const named = $('h4', row); + const heading = (named ? named.textContent : '').replace(/\s+/g, ' ').trim(); + if (!heading) return; + const text = indexText(row).replace(/\s+/g, ' ').trim(); + index.push({ id: row.id, page: 'roadmap', section: navLabel(section), heading, title: '', text: text.startsWith(heading) ? text.slice(heading.length).trim() : text }); + }); + index.forEach(item => { + item.hay = (item.section + ' ' + item.title + ' ' + item.heading + ' ' + item.text).toLowerCase(); + item.label = (item.section + ' ' + item.title).toLowerCase(); + }); + const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + function highlightInto(el, text, words) { + if (!words.length) { el.textContent = text; return; } + const re = new RegExp('(' + words.map(escapeRe).join('|') + ')', 'gi'); + text.split(re).forEach((part, i) => { + if (i % 2) { const m = document.createElement('mark'); m.textContent = part; el.append(m); } + else if (part) el.append(document.createTextNode(part)); + }); + } + const suggestions = ['quickstart', 'model', 'routing', 'preview', 'standalone', 'nextjs', 'walkthrough', 'troubleshooting']; + function resultLink(item, words) { + const a = document.createElement('a'); a.href = '#' + item.id; + a.id = 'search-opt-' + (++optionSeq); a.setAttribute('role', 'option'); a.setAttribute('aria-selected', 'false'); + const ico = document.createElement('span'); ico.className = 'sr-icon'; ico.innerHTML = item.heading ? icon('hash') : icon('file'); + const bodyEl = document.createElement('span'); bodyEl.className = 'sr-body'; + const path = document.createElement('span'); path.className = 'sr-path'; + const group = groupOf.get(item.id); + path.textContent = pageLabels[item.page] + (item.heading ? ' › ' + item.section : (group && group !== pageLabels[item.page] && group !== item.section ? ' › ' + group : '')); + const title = document.createElement('span'); title.className = 'sr-title'; + highlightInto(title, item.heading || item.name || item.section, words); + const enter = document.createElement('span'); enter.className = 'sr-enter'; enter.innerHTML = icon('corner'); + bodyEl.append(path, title); + a.append(ico, bodyEl, enter); + if (words.length) { + const lower = item.text.toLowerCase(); + let at = -1; for (const w of words) { at = lower.indexOf(w); if (at >= 0) break; } + if (at >= 0) { + const start = Math.max(0, at - 48); + const snip = document.createElement('span'); snip.className = 'sr-snip'; + highlightInto(snip, (start ? '…' : '') + item.text.slice(start, start + 180) + '…', words); + bodyEl.append(snip); + } + } + a.addEventListener('click', () => { dialog.close(); closeMenu(false); }); + return a; + } + function search(query) { + results.replaceChildren(); + const words = query.toLowerCase().trim().split(/\s+/).filter(Boolean); + if (!words.length) { + const label = document.createElement('p'); label.className = 'search-label'; label.textContent = 'Suggested'; label.setAttribute('aria-hidden', 'true'); results.append(label); + suggestions.forEach(id => { const item = index.find(i => i.id === id); if (item) results.append(resultLink(item, [])); }); + setActive($('a', results)); + searchInput.setAttribute('aria-expanded', 'true'); + searchStatus.textContent = ''; + return; + } + let scored = index.filter(item => words.every(w => item.hay.includes(w))).map(item => { + let score = 0; + words.forEach(w => { + if (item.heading.toLowerCase().includes(w)) score += 6; + if (item.section.toLowerCase().includes(w)) score += 4; + else if (item.title.toLowerCase().includes(w)) score += 3; + if (!item.heading && item.label.includes(w)) score += 8; + score += Math.min(3, item.text.toLowerCase().split(w).length - 1); + }); + if (item.page === 'home') score -= 3; + return { item, score }; + }).sort((a, b) => b.score - a.score); + // The Roadmap page's many items would crowd the docs out of a search made from the docs: + // there, docs results come first and Roadmap ones follow (at least three kept). + if (activePage !== 'roadmap') { + const docs = scored.filter(s => s.item.page !== 'roadmap'), roadmap = scored.filter(s => s.item.page === 'roadmap'); + const mine = docs.slice(0, 20 - Math.min(3, roadmap.length)); + scored = mine.concat(roadmap.slice(0, 20 - mine.length)); + } else scored = scored.slice(0, 20); + if (!scored.length) { + const p = document.createElement('p'); p.className = 'search-empty'; + p.textContent = 'No matching sections. Try “schema”, “rollback”, or “TanStack”.'; + p.setAttribute('aria-hidden', 'true'); + results.append(p); setActive(null); + searchInput.setAttribute('aria-expanded', 'false'); + searchStatus.textContent = 'No results.'; + return; + } + const count = document.createElement('p'); count.className = 'search-label'; count.setAttribute('aria-hidden', 'true'); + count.textContent = scored.length + (scored.length === 1 ? ' result' : ' results') + (scored.length === 20 ? ', best matches first' : ''); + results.append(count); + scored.forEach(({ item }) => results.append(resultLink(item, words))); + setActive($('a', results)); + searchInput.setAttribute('aria-expanded', 'true'); + searchStatus.textContent = count.textContent + '.'; + } + function openSearch() { + closeMenu(false); + searchInput.value = ''; search(''); + if (!dialog.open) dialog.showModal(); + searchInput.focus(); + } + $$('[data-search-open]').forEach(button => button.addEventListener('click', openSearch)); + $('#search-close').addEventListener('click', () => dialog.close()); + searchInput.addEventListener('input', () => search(searchInput.value)); + searchInput.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); dialog.close(); } }); + dialog.addEventListener('keydown', event => { + const links = $$('a', results); + if (!links.length) return; + const i = links.indexOf(document.activeElement); + const cur = links.findIndex(a => a.classList.contains('is-active')); + const move = to => { setActive(links[to]); links[to].scrollIntoView({ block: 'nearest' }); return links[to]; }; + if (event.key === 'ArrowDown') { event.preventDefault(); const to = Math.min(links.length - 1, (i >= 0 ? i : cur) + 1); move(to).focus(); } + else if (event.key === 'ArrowUp') { event.preventDefault(); const from = i >= 0 ? i : cur; if (from <= 0) { move(0); searchInput.focus(); } else move(from - 1).focus(); } + else if (event.key === 'Enter' && document.activeElement === searchInput) { event.preventDefault(); const target = links[cur >= 0 ? cur : 0]; if (target) target.click(); } + }); + dialog.addEventListener('click', event => { + if (event.target !== dialog) return; + const r = dialog.getBoundingClientRect(); + if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) dialog.close(); + }); + document.addEventListener('keydown', event => { + const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement && document.activeElement.tagName); + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); if (dialog.open) dialog.close(); else openSearch(); } + else if (event.key === '/' && !typing && !dialog.open) { event.preventDefault(); openSearch(); } + else if (event.key === 'Escape') closeMenu(true); + }); + + /* ---------------------------------------------------------- Scroll + print */ + let scheduled = false; + function onScroll() { scheduled = false; updateSpy(); updateHeader(); checkReveals(); } + window.addEventListener('scroll', () => { if (!scheduled) { scheduled = true; requestAnimationFrame(onScroll); } }, { passive: true }); + let fitTimer = 0; + window.addEventListener('resize', () => { + if (window.innerWidth >= 900) closeMenu(false); + onScroll(); + clearTimeout(fitTimer); + fitTimer = setTimeout(() => { if (activeSection && activeSection.id !== 'home') fitCode(activeSection); fadeToc(); }, 120); + }); + $$('[data-print]').forEach(button => button.addEventListener('click', () => window.print())); + $$('[data-copy-link]').forEach(button => button.addEventListener('click', async () => { + const id = activeSection ? activeSection.id : 'home'; + const url = location.href.split('#')[0].split('?')[0] + '#' + id; + const ok = await copyText(url); + toast(ok ? 'Link copied' : 'Copy the address bar to share this page.'); + })); + let printedDetails = []; + window.addEventListener('beforeprint', () => { printedDetails = $$('details').map(el => [el, el.open]); printedDetails.forEach(([el]) => { el.open = true; }); }); + window.addEventListener('afterprint', () => printedDetails.forEach(([el, open]) => { el.open = open; })); + + /* ------------------------------------------------------- Landing: journey */ + // Baseline is what's committed on main; values are what the Studio form shows. + const saved = { newCheckout: 10, stickyHeader: 50, freeShippingBanner: 0 }; + const expLabels = { newCheckout: 'new checkout', stickyHeader: 'sticky header', freeShippingBanner: 'free shipping banner' }; + const values = { newCheckout: 25, stickyHeader: 50, freeShippingBanner: 0 }; + const jsonCode = $('#exp-json'); + const expFoot = $('#exp-foot'); + const saveBtn = $('#studio-save'); + let lastCommit = ''; + function commitMessage() { + const changed = Object.keys(saved).filter(k => values[k] !== saved[k]); + if (changed.length === 1) { const k = changed[0]; return (values[k] > saved[k] ? 'ramp ' : 'lower ') + expLabels[k] + ' to ' + values[k] + '%'; } + return changed.length ? 'update ' + changed.length + ' experiments' : ''; + } + function renderJson(changedKey) { + if (!jsonCode) return; + const keys = Object.keys(saved); + const line = (cls, gut, inner) => '' + gut + '' + inner + ''; + const prop = (k, v, comma) => ' "' + k + '": ' + v + '' + comma; + let html = line('', ' ', '{') + line('', ' ', ' "__resolveType": "experiments",'); + keys.forEach((k, i) => { + const comma = i < keys.length - 1 ? ',' : ''; + if (values[k] !== saved[k]) { + html += line('del', '-', prop(k, saved[k], comma)); + html += line('add' + (k === changedKey ? ' flash' : ''), '+', prop(k, values[k], comma)); + } else html += line('', ' ', prop(k, values[k], comma)); + }); + html += line('', ' ', '}'); + jsonCode.innerHTML = html; + const changed = keys.filter(k => values[k] !== saved[k]).length; + if (changed) { + expFoot.dataset.state = 'dirty'; + $('#exp-status').textContent = changed + (changed === 1 ? ' change' : ' changes') + ' · not committed yet'; + $('#exp-commit').textContent = commitMessage(); + } else if (lastCommit) { + expFoot.dataset.state = 'committed'; + $('#exp-status').textContent = 'Committed'; + $('#exp-commit').textContent = lastCommit; + } else { + expFoot.dataset.state = 'clean'; + $('#exp-status').textContent = 'No changes'; + $('#exp-commit').textContent = 'up to date with main'; + } + if (saveBtn) { saveBtn.disabled = !changed; saveBtn.textContent = changed ? 'Save' : 'Saved'; } + $('#exp-odds').textContent = values.newCheckout; + } + $$('.studio input[type="range"]').forEach(input => { + const key = input.dataset.exp; + const out = byId('out-' + key); + const sync = () => { + values[key] = Number(input.value); + input.style.setProperty('--val', input.value + '%'); + out.textContent = input.value; + renderJson(key); + }; + input.addEventListener('input', sync); + }); + renderJson(); + if (saveBtn) saveBtn.addEventListener('click', () => { + const message = commitMessage(); + if (!message) return; + lastCommit = message; + Object.assign(saved, values); + renderJson(); + expFoot.classList.remove('just'); void expFoot.offsetWidth; expFoot.classList.add('just'); + toast('In Studio, Save commits the file to your repository.'); + }); + $$('[data-copy]').forEach(button => button.addEventListener('click', async () => { + const ok = await copyText(button.dataset.copy); + toast(ok ? 'Copied: ' + button.dataset.copy : 'Select the text to copy it.'); + if (ok) { button.classList.add('copied'); setTimeout(() => button.classList.remove('copied'), 1600); } + })); + + /* ------------------------------------- Landing: journey carousel (phones) */ + // Below 768px the four panels become one scroll-snap row; the step pills follow and drive it. + const journey = $('.journey'); + const jpSteps = $$('.journey-nav [data-jp]'); + if (journey && jpSteps.length) { + const panels = $$('.jp', journey); + const markStep = i => jpSteps.forEach((b, j) => { if (j === i) b.setAttribute('aria-current', 'step'); else b.removeAttribute('aria-current'); }); + let jpTick = false; + journey.addEventListener('scroll', () => { + if (jpTick) return; jpTick = true; + requestAnimationFrame(() => { + jpTick = false; + const left = journey.scrollLeft, pad = parseFloat(getComputedStyle(journey).paddingLeft) || 0; + let best = 0, 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; + markStep(best); + }); + }, { passive: true }); + const showPanel = i => { + const pad = parseFloat(getComputedStyle(journey).paddingLeft) || 0; + journey.scrollTo({ left: panels[i].offsetLeft - pad, behavior: reduceMotion ? 'auto' : 'smooth' }); + markStep(i); + }; + jpSteps.forEach((b, i) => b.addEventListener('click', () => showPanel(i))); + journey.addEventListener('focusin', event => { + if (journey.scrollWidth <= journey.clientWidth + 1) return; + const i = panels.findIndex(p => p.contains(event.target)); + if (i < 0) return; + const box = journey.getBoundingClientRect(), r = panels[i].getBoundingClientRect(); + if (r.left < box.left - 1 || r.right > box.right + 1) showPanel(i); + }); + } + + /* ------------------------------------------------------- Landing: stepper */ + const stepTabs = $$('.stepper [role="tab"]'); + function selectStep(n, focus) { + stepTabs.forEach(tab => { + const on = tab.dataset.step === String(n); + tab.setAttribute('aria-selected', String(on)); + tab.tabIndex = on ? 0 : -1; + if (on && focus) tab.focus(); + }); + $$('.sw-pane').forEach(pane => pane.classList.toggle('is-on', pane.id === 'step-pane-' + n)); + $$('.sw-tab').forEach(t => t.classList.toggle('is-on', t.dataset.step === String(n))); + } + stepTabs.forEach((tab, i) => { + tab.addEventListener('click', () => selectStep(tab.dataset.step)); + tab.addEventListener('keydown', event => { + const k = event.key; + let to = null; + if (k === 'ArrowDown' || k === 'ArrowRight') to = (i + 1) % stepTabs.length; + else if (k === 'ArrowUp' || k === 'ArrowLeft') to = (i - 1 + stepTabs.length) % stepTabs.length; + else if (k === 'Home') to = 0; else if (k === 'End') to = stepTabs.length - 1; + if (to !== null) { event.preventDefault(); selectStep(stepTabs[to].dataset.step, true); } + }); + }); + $$('.sw-tab').forEach(t => t.addEventListener('click', () => selectStep(t.dataset.step))); + + /* ------------------------------------------------ Walkthrough */ + const traceCode = $('#trace-code'); + if (traceCode) { + let mode = 'data', operation = 'resolve', step = 0; + const product = { name: 'Summer shirt', price: 49 }; + const stored = { __resolveType: 'product-card', title: 'Summer collection', product: { __resolveType: 'CurrentProduct' } }; + const expanded = { __resolveType: 'product-card', title: 'Summer collection', product: { __resolveType: 'catalog-product', slug: 'summer-shirt' } }; + const children = { __resolveType: 'product-card', title: 'Summer collection', product }; + const stringify = value => JSON.stringify(value, null, 2); + const captions = { + resolve: [ + 'The saved entry. Its product input names CurrentProduct, another saved entry.', + 'Look up CurrentProduct: data. The reference is replaced with the entry, and the rule runs again on the result.', + 'Look up catalog-product: function. Its inputs contain no blocks, so it runs, and the product data takes its place.', + 'Look up product-card: function. Its inputs are resolved, so it runs. get returns the result untouched.', + ], + get: [ + 'The saved entry. Its product input names CurrentProduct, another saved entry.', + 'With { resolve: false }, only saved entries are in the registry. CurrentProduct expands; catalog-product and product-card are left as they are. No code runs.', + ], + }; + const stats = { + resolve: ['0 function calls', '0 function calls · data hit', '1 function call · catalog-product', '2 function calls · opaque result'], + get: ['0 function calls', '0 function calls · final value'], + }; + function renderTrace() { + const last = captions[operation].length - 1; + step = Math.min(step, last); + $$('[data-trace-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.traceMode === mode))); + $$('[data-trace-operation]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.traceOperation === operation))); + $$('[data-trace-step]').forEach(button => { button.setAttribute('aria-current', Number(button.dataset.traceStep) === step ? 'step' : 'false'); button.disabled = Number(button.dataset.traceStep) > last; }); + let code = [stored, expanded, children][step] && stringify([stored, expanded, children][step]); + let language = 'json'; + if (step === 3) { + if (mode === 'data') code = stringify({ component: 'ProductCard', props: { title: 'Summer collection', product } }); + else { code = ''; language = 'tsx'; } + } + traceCode.textContent = code; + highlightCode(traceCode, language); + $('#trace-caption').textContent = captions[operation][step]; + $('#trace-stat').textContent = stats[operation][step]; + $('#trace-next').disabled = step === last; + } + $$('[data-trace-mode]').forEach(button => button.addEventListener('click', () => { mode = button.dataset.traceMode; renderTrace(); })); + $$('[data-trace-operation]').forEach(button => button.addEventListener('click', () => { operation = button.dataset.traceOperation; step = 0; renderTrace(); })); + $$('[data-trace-step]').forEach(button => button.addEventListener('click', () => { step = Number(button.dataset.traceStep); renderTrace(); })); + $('#trace-next').addEventListener('click', () => { step += 1; renderTrace(); }); + renderTrace(); + } + + /* ------------------------------------------------------------------- Boot */ + route(false); + // Web fonts can reflow the article after the first scroll; re-anchor a deep link once they're in, + // unless the reader has already started scrolling. + let userMoved = false; + ['wheel', 'touchmove', 'keydown', 'mousedown'].forEach(type => window.addEventListener(type, () => { userMoved = true; }, { passive: true, once: true })); + const reanchor = () => { + if (activeSection && activeSection.id !== 'home') fitCode(activeSection); + fadeToc(); + if (!userMoved) { + const target = byId(currentId()); + if (target && activeSection && target !== activeSection && activeSection.contains(target)) target.scrollIntoView({ block: 'start' }); + } + requestAnimationFrame(updateSpy); + }; + if (document.fonts && document.fonts.ready) document.fonts.ready.then(reanchor); + window.addEventListener('load', reanchor); +})(); diff --git a/site/src/brand/favicon.svg b/site/src/brand/favicon.svg new file mode 100644 index 00000000..99d2d099 --- /dev/null +++ b/site/src/brand/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/site/src/brand/symbol-on-dark.svg b/site/src/brand/symbol-on-dark.svg new file mode 100644 index 00000000..0e3379e8 --- /dev/null +++ b/site/src/brand/symbol-on-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/site/src/brand/symbol-on-light.svg b/site/src/brand/symbol-on-light.svg new file mode 100644 index 00000000..89a4fc25 --- /dev/null +++ b/site/src/brand/symbol-on-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/site/src/brand/wordmark-on-dark.svg b/site/src/brand/wordmark-on-dark.svg new file mode 100644 index 00000000..27002906 --- /dev/null +++ b/site/src/brand/wordmark-on-dark.svg @@ -0,0 +1,16 @@ + + + + diff --git a/site/src/brand/wordmark-on-light.svg b/site/src/brand/wordmark-on-light.svg new file mode 100644 index 00000000..e96387ab --- /dev/null +++ b/site/src/brand/wordmark-on-light.svg @@ -0,0 +1,16 @@ + + + + diff --git a/site/src/cobogo-defs.svg b/site/src/cobogo-defs.svg new file mode 100644 index 00000000..6419de08 --- /dev/null +++ b/site/src/cobogo-defs.svg @@ -0,0 +1 @@ + diff --git a/site/src/content.html b/site/src/content.html new file mode 100644 index 00000000..be4871ec --- /dev/null +++ b/site/src/content.html @@ -0,0 +1,1426 @@ + +
    +
    +

    Getting started

    How it works

    +

    Next major. This reference describes the proposed API for the next major version of Deco Blocks. The APIs shown here and the deco schema command aren't in the current @decocms/blocks release.

    +

    Deco Blocks has two pieces you install, and one you don't:

    +
    + + + + + + +
    PieceWhat it doesWhere it runs
    SDK @decocms/blocksReads content from files or the Deco API and runs your block functions on it.Your app: any JavaScript server, Node or Cloudflare Workers
    CLI deco schemaGenerates the JSON Schema from your types.Your machine or CI
    StudioBuilds a visual editor from that schema and saves edits back to your files.Hosted by Deco
    + +

    Authoring: from types to an editor

    +
    +
    Your TypeScriptComponents and data functions with typed inputs
    +
    CLI → JSON Schemadeco schema writes .deco/schema.json
    +
    StudioForms and previews for editors, built from the schema
    +
    +

    You write functions with typed inputs, for example a hero section that takes a title and an image. The CLI turns those types into a schema, and Studio turns the schema into forms. Editors, and AI agents, save content as JSON files in .deco/blocks, in your repository.

    + +

    Delivery: from files to your app

    +
    +
    Content in GitJSON files in .deco/blocks
    +
    SDKclient.list and client.resolve read and resolve content
    +
    Your appNext.js, TanStack Start, or any server
    +
    +

    At request time, the SDK reads those files, follows references between them, and runs each block through the function you mapped to its type in your block map. What you render, and how, is up to your framework.

    +

    In production the same files are read from the Deco API instead of disk, but a request never waits for it: content is served from memory and refreshed in the background, and the files the build shipped with are always there as the fallback. See how the loader stays current.

    + +

    Small on purpose

    +

    Deco Blocks stays out of your way, and every piece is replaceable:

    +
      +
    • Content is plain JSON. Open it in any editor, grep it, diff it, or generate it with a script. There's no database and no content API to run.
    • +
    • Blocks are plain functions. No framework plugin, no base classes, no magic directories. The block map is a plain object, and you can call any block function directly in a test.
    • +
    • Loaders are one method. Loading from the filesystem, KV, or your own store is a load() that returns { revision, blocks }. Swap it and nothing else changes.
    • +
    • The schema is a file. Commit it, inspect it, or generate it another way. Studio only needs the JSON.
    • +
    • Nothing is hidden. If the SDK doesn't do what you need, read the files yourself. The format is documented in Blocks.
    • +
    +
    + +
    +

    Getting started

    Quickstart

    +

    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:

    +
    config/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),
    +  };
    +}
    +
    checkout.ts
    import experiments from "./config/experiments";
    +
    +experiments({ newCheckout: 10, stickyHeader: 50, freeShippingBanner: 0 });
    +// { newCheckout: false, stickyHeader: true, freeShippingBanner: false }
    +

    The split is hardcoded at the call site. Ramping the new checkout from 10% to 25% means a pull request and a deploy. In this guide you'll move those numbers into content that your team edits in Studio, and that AI agents edit as files, while the function stays exactly as it is. It runs in plain Node, with no framework.

    +

    Prerequisites: Node.js 22 or later and a way to run TypeScript, such as tsx.

    +
    npm install @decocms/blocks
    + +

    1. Make the function configurable

    +

    Any function with a typed first parameter can be a block. Give it a name in a block map. The key, experiments, is the block type that content will refer to. This file is plain data: the CLI reads it, and your app hands it to createCMS.

    +
    cms.index.ts
    import type { Blocks } from "@decocms/blocks";
    +import experiments from "./config/experiments";
    +
    +export default { experiments } satisfies Blocks;
    + +

    2. Give editors a form

    +

    The CLI reads the map and turns the Experiments type into a JSON Schema. The JSDoc tags become labels and validation. Studio builds the editor form from it:

    +
    npx deco schema --entry ./cms.index.ts --out ./.deco/schema.json
    +

    Commit the file. From now on, Studio shows each experiment as a number field clamped to 0–100.

    + +

    3. Add content

    +

    There are two ways to create content, and they produce the same thing:

    +
      +
    • In Studio. Open the editor, pick the experiments block type, fill in the form, and save. Studio writes the JSON file into your repository.
    • +
    • Manually. Create the JSON file yourself, or let an AI agent asked to "ramp the new checkout to 25%" do it. The file is the only source of truth, so both routes are equivalent.
    • +
    +

    Either way, the file lands in .deco/blocks. The filename is the entry's name, and __resolveType is the block type:

    +
    .deco/blocks/Experiments.json
    {
    +  "__resolveType": "experiments",
    +  "newCheckout": 10,
    +  "stickyHeader": 50,
    +  "freeShippingBanner": 0
    +}
    + +

    4. Read the content from your code

    +

    Create the CMS once, over the .deco directory and your block map. Ask it for a client, then fetch the entry by name. The client runs experiments on the saved inputs, so you get the same result as the direct call, with the same type:

    +
    src/cms/cms.ts
    import { createCMS } from "@decocms/blocks";
    +import blocks from "../../cms.index";
    +
    +export const cms = createCMS({ loader: "./.deco", blocks });
    +
    checkout.ts
    import { cms } from "./src/cms/cms";
    +
    +const client = cms.forRelease();
    +const [enabled, error] = await client.resolve("Experiments");
    +if (error) throw error;
    +
    +enabled;
    +// { newCheckout: false, stickyHeader: true, freeShippingBanner: false }
    +

    Every call returns a [value, error] tuple, so a missing entry or a failing function never throws. Pass { resolve: false } to get the saved JSON instead of the function's result.

    +

    That's it. The function didn't change, but its inputs now live in a file that editors change through a form and agents change directly, and every change is a commit. A real implementation would hash a visitor ID from a cookie instead of rolling a die, so each visitor gets a stable result. The CMS doesn't hand blocks the request; read it the way the rest of your app does (see Block maps).

    + +

    Next steps

    +
      +
    • Blocks: nesting, saved entries, and references between them. That's how a whole page is composed out of sections.
    • +
    • Schema generation: every JSDoc tag the CLI understands.
    • +
    • Routes and redirects: the two framework types behind every URL.
    • +
    • Preview: render drafts in your real pages, no deploy.
    • +
    • Routing: match URLs to pages and redirects.
    • +
    • Block maps, when you need more: lazy loading and naming types.
    • +
    • Framework guides for Next.js and TanStack Start.
    • +
    +
    + +
    +

    Core concepts

    Blocks

    +

    A block is a JSON object with a __resolveType. The type names what the block is, and the other properties are its inputs. Any input can itself be a block, so content forms a tree:

    +
    {
    +  "__resolveType": "product-card",
    +  "title": "Summer collection",
    +  "product": {
    +    "__resolveType": "catalog-product",
    +    "slug": "summer-shirt"
    +  }
    +}
    +

    Use the same model for anything your site needs: UI sections, product data, SEO, navigation, campaign settings. The CMS doesn't reserve any directories or categories.

    + +

    Saved entries and references

    +

    Every file in .deco/blocks is a saved entry, named after the file. Other content refers to an entry by putting its name in __resolveType, and can override some of its inputs for that one use:

    +
    // Use the saved entry as is.
    +{ "__resolveType": "SummerSEO" }
    +
    +// Override one input for this occurrence.
    +{ "__resolveType": "SummerSEO", "title": "Summer sale" }
    +

    An entry doesn't have to be a block itself. It can be any JSON value that contains blocks, like the page in the next example.

    + +

    How get works

    +

    Every __resolveType is a name looked up in one registry, and a name is either data or code:

    +
    const registry = { ...savedEntries, ...blocks };   // name → saved JSON | function
    +

    Saved entries come from the loader. Functions come from the block map you give createCMS. Resolution is one rule, applied to every object with a __resolveType:

    +
      +
    1. Look up the name.
    2. +
    3. Data: replace the object with { ...entry, ...inputs }, so your inputs override the entry's, and apply the rule to the result again.
    4. +
    5. Function: resolve the inputs first, children before parents, then call the function with them. Its return value takes the block's place.
    6. +
    7. Neither: fail with UNKNOWN_BLOCK.
    8. +
    +

    Take these two entries and a block map with seo and Carousel functions:

    +
    .deco/blocks/pages-summer.json
    {
    +  "seo": { "__resolveType": "SummerSEO" },
    +  "sections": [{ "__resolveType": "Carousel", "banners": [] }]
    +}
    +
    .deco/blocks/SummerSEO.json
    { "__resolveType": "seo", "title": "Sunny!" }
    +

    client.resolve("pages-summer") walks the page. seo holds { "__resolveType": "SummerSEO" }: a data hit, so it becomes { "__resolveType": "seo", "title": "Sunny!" } and the rule runs again. Now seo is a function hit, so seo({ title: "Sunny!" }) runs. Carousel is a function hit straight away. The result:

    +
    {
    +  seo: { title: "Sunny!" },
    +  sections: [{ component: "Carousel", props: { banners: [] } }]
    +}
    +

    With { resolve: false }, the registry holds data only. Names that aren't saved entries pass through untouched, so client.resolve("pages-summer", { resolve: false }) returns the page with SummerSEO expanded and the seo and Carousel blocks left as they are. That's how you read content without running anything.

    + +

    The rule in detail

    +
      +
    • A saved entry is a preset. It's a block, or any JSON containing blocks, saved under a name. Referring to it by name and overriding a field is the same as writing the merged block inline. Overrides merge shallowly: an override replaces the whole property, and the saved entry itself doesn't change.
    • +
    • Bottom-up and parallel. A function only runs once its inputs are resolved, so product-card receives product data, never a catalog-product block. Sibling blocks, such as sections, resolve concurrently.
    • +
    • Failures stop the parent. If a nested block fails, its parent never runs and get returns the error. A reference to an entry that doesn't exist fails with NOT_FOUND.
    • +
    • Results are opaque. The CMS never walks a function's return value. Return plain data, a descriptor, or JSX. This is also why a function that returns its input can't loop: __resolveType is stripped before the call, and whatever comes back is never looked up again.
    • +
    • Only data can loop. A data hit is the one branch that says "apply 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.
    • +
    • Stored form. To read an entry exactly as saved, with nothing expanded, take it from loader.load().
    • +
    +

    Entry names and block types share one registry. A saved entry called seo and a seo function can't coexist. deco schema fails when an entry name matches a block type, so rename the entry.

    +
    + +
    +

    Core concepts

    Schema generation

    +

    Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. Point it at the file that exports your block map, the cms.index.ts from the quickstart:

    +
    npx deco schema --entry ./cms.index.ts --out ./.deco/schema.json
    +

    For each block type in the map, the CLI reads the type of the function's first parameter and turns it into a form definition. Commit .deco/schema.json with your code, and run the command in CI so the schema never drifts from your types.

    +

    The CLI also checks your content model: it fails if a saved entry's name collides with a block type, or if an alias collides with either.

    + +

    Content that is only data

    +

    A page, a blog post, a redirect: these have a shape but no function. Nothing runs when they resolve; your app reads them and resolves the blocks inside them. They still need a schema so Studio can create them, so declare them for the CLI in a type-level map next to the block map:

    +
    cms.index.ts
    import type { Blocks, Redirect } from "@decocms/blocks";
    +import type { Page } from "./content/page";
    +import type { Post } from "./content/post";
    +import seo from "./content/seo";
    +import hero from "./views/hero";
    +
    +export default { seo, hero } satisfies Blocks;          // code: a schema per function parameter
    +
    +export type Content = {                                  // data: a schema per type
    +  page: Page;
    +  post: Post;
    +  redirect: Redirect;
    +};
    +

    The CLI reads both exports. Keys of the default export get a schema from each function's parameter type; keys of Content get one from the type itself. The two share one namespace, and a key in both is an error. The runtime never sees Content: list("page") works because the entries exist, and resolving a page means resolving its fields, not the page.

    + +

    Studio reads the schema this command writes, and nothing else about your code. What it looks for, and how the CLI bridges today's Studio conventions, is covered under the hood.

    + +

    There is no publish command. Content is published by changing it: commit a file in .deco/blocks and push, or save in Studio, which commits for you. Both land in the repository, and the Deco API serves the latest commit of the production branch as the current release. If an agent runs deco publish, the CLI only says this.

    +
    + +
    +

    Core concepts

    Preview

    +

    Editors want to see a change before it's published. Because block functions ship with your app and only content changes, previewing a draft needs no deploy: the app renders the same code against different content.

    +

    Here's the whole mechanism. Studio opens your site with a draft pointer in the URL:

    +
    https://store.example.com/summer?__draft=studio.decocms.com/api/acme/decofile/store/feat-summer%3Ftoken%3D…@9f3c1a
    +

    The pointer is a string, <host><path>@<version>: where the draft content lives, and which version. draftPointer(request) reads it, from ?__draft= or from the cookie described below, and the CMS gives you a client over that draft. Everything after that line is the same code that serves visitors:

    +
    A plain request handler
    import { draftPointer } from '@decocms/blocks'
    +import { cms } from './cms'
    +
    +export async function handle(request: Request) {
    +  const pointer = draftPointer(request)               // null on an ordinary request
    +  const client = pointer ? cms.forDraft(pointer) : cms.forRelease()
    +
    +  return render(client, request)                      // list, matchRoute, resolve: as usual
    +}
    +

    forDraft hands the pointer to the loader. remoteLoader fetches that exact version from Studio's content API, waits for it, and caches it by version, so the second request for the same draft is a memory read. A draft replaces the whole revision, deletions included, so a page in preview looks exactly as it will after publishing. localLoader ignores the pointer: in development you're already looking at the files. That's why the code above never checks which loader it has.

    + +

    Keep the draft across navigation

    +

    The pointer is only in the URL of the first page Studio opens. draftCookie(request) gives you the Set-Cookie value that response needs, and null on every other request. Append it, and draftPointer reads the cookie from then on, so every link the editor clicks stays in preview:

    +
    Query parameter first, cookie afterwards
    import { draftPointer, draftCookie } from '@decocms/blocks'
    +import { cms } from './cms'
    +
    +export async function handle(request: Request) {
    +  const pointer = draftPointer(request)
    +  const client = pointer ? cms.forDraft(pointer) : cms.forRelease()
    +  const response = await render(client, request)
    +
    +  const cookie = draftCookie(request)
    +  if (cookie) response.headers.append('Set-Cookie', cookie)
    +  return response
    +}
    +

    Studio ends a preview by opening the site with an empty ?__draft=; draftCookie returns an expiring cookie for that, so the same two lines cover leaving preview. The parameter and cookie names never appear in your code. In Next.js the two steps split across proxy.ts, which appends the cookie, and a server helper that reads it with cookies(). The Next.js guide has both files.

    + +

    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 Studio opens, or from a "preview" field on a debug screen, and keeps it in app state:

    +
    // 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 reference; forDraft parses on its own, so normally you don't have to.

    + +

    Who may preview

    +

    Two checks, and neither needs configuration in your app. remoteLoader uses its token to learn the site's content API from decocms, and forDraft ignores any pointer whose authority isn't that API, so a pointer can never turn into a request to an arbitrary origin. The pointer's path carries a signed grant that the content API verifies, so a draft is only served to someone Studio let in.

    +

    If a website wants to show drafts on its preview domain only, it checks the request's host before calling forDraft:

    +
    const host = new URL(request.url).host
    +const pointer = host === 'preview.store.example.com' ? draftPointer(request) : null
    +const client = pointer ? cms.forDraft(pointer) : cms.forRelease()
    +

    The token is the only secret. The site holds no other credential. Invalid or unexpected pointers are ignored and the request falls back to published content, never to an error page.

    +
    + +
    +

    Advanced

    Block maps

    +

    A block map is the code half of the registry: a plain object from block type to function. The quickstart's cms.index.ts is one. This section covers what else can go in it.

    +
    cms.index.ts
    import type { Blocks } from "@decocms/blocks";
    +import seo from "./content/seo";
    +import productCard from "./views/product-card";
    +import hero from "./views/hero";
    +
    +export default {
    +  seo,
    +  "product-card": productCard,
    +  hero,
    +} satisfies Blocks;
    +

    Each key is a block type. Each value is a function (inputs) => value, where inputs is the block without its __resolveType, with nested blocks already resolved. Use satisfies Blocks rather than a type annotation: it checks the shape while keeping the concrete keys and function types, which is what makes resolve<T> inference and the schema work.

    +

    Keep the map a plain object at module level. The CLI reads the file without running it, so build it once and give it to createCMS. Only functions go in the map. Types that are pure data, such as pages, posts, and redirects, have nothing to run; they're declared for the CLI instead, see content that is only data.

    +

    The CMS is pure. It passes your function the block and nothing else: no context object, no request, no hidden state. When a block needs the current request, a cookie, or a route parameter, read it the way the rest of your app does, usually through the AsyncLocalStorage your server or framework already provides (next/headers in Next.js, getRequest() in TanStack Start). That keeps block functions ordinary functions you can call and test directly.

    + +

    Lazy loading

    +

    The map above imports every block at the top, so they all load with the map. That's fine for functions like seo. It's not what you want for a section that drags in a charting library, or for the two hundred sections a large store has when any page uses five.

    +

    What's heavy is the component a block renders, and your framework already knows how to load components lazily. So do it in one place, the map, and let the block files stay plain components:

    +
    cms.index.tsx (Next.js)
    import dynamic from 'next/dynamic'
    +import type { Blocks } from '@decocms/blocks'
    +import seo from './content/seo'
    +import type { ProductProps } from './sections/Product'
    +import type { HeroProps } from './sections/Hero'
    +
    +// Each section is split into its own chunk and fetched on first render.
    +const Product = dynamic(() => import('./sections/Product'))
    +const Hero    = dynamic(() => import('./sections/Hero'))
    +
    +export default {
    +  seo,                                                    // eager: tiny, used everywhere
    +  product: (input: ProductProps) => <Product {...input} />,
    +  hero:    (input: HeroProps)    => <Hero {...input} />,
    +} satisfies Blocks
    +

    Swap dynamic for React's lazy on TanStack RSC, with the sections rendered under a Suspense boundary. In TanStack's data mode the blocks return descriptors and the browser-side view registry is the map to do this in: const views = { product: lazy(() => import('./sections/Product')) }. In every case the section files know nothing about the CMS, the map stays a plain object the CLI can read, and the bundler splits on the import().

    +

    For a block that's heavy for a non-UI reason, say a data function that pulls in a large SDK, a dynamic import inside the function body is enough. It's one line and needs no helper:

    +
    "catalog-product": async (input: ProductQuery) => {
    +  const { fetchProduct } = await import("./commerce/client")   // loaded on first call, cached by the runtime
    +  return fetchProduct(input)
    +},
    +

    Keep import paths literal in both cases. A bundler can split import("./sections/Product") into its own chunk; it can't split import(`./sections/${name}`).

    + +

    Choose type names that last

    +

    A type name is what content stores in __resolveType, so pick it like a database column, not like a file path: short (seo, hero) or namespaced when a site has many (commerce/product). Moving a file only means updating an import; the name doesn't change. Renaming a type means migrating every block that uses it, or adding an alias.

    +
    + +
    +

    Advanced

    Matchers and variants

    +

    Editors often want content that depends on a condition: a banner that only shows during a campaign, a title that changes on a date, an experiment. Two small ideas cover all of it, and neither knows what a website is, so they work the same in a storefront, a mobile app, or a job.

    +

    A matcher is a block function that returns a boolean. A multivariate is a block function that takes a list of { rule, value } pairs, where each rule is a matcher, and returns the first value whose rule matched. Because the CMS resolves inputs before calling a function, the multivariate never sees a matcher, only booleans.

    + +

    The three matchers that ship

    +

    They depend on nothing but the clock, so they're the only ones the framework provides:

    +
    import { always, never, date } from "@decocms/blocks/matchers";
    +
    +// always()                              => true    the default rule of a variant
    +// never()                               => false   how Studio hides a section without deleting it
    +// date({ start?: string; end?: string }) => boolean, true when now is inside [start, end]
    +

    Everything else that could gate content, a device, a cookie, a country, a user's plan, depends on where your app runs and how it sees its environment. Those are yours to write, below.

    + +

    The multivariate is three lines you own

    +
    content/multivariate.ts
    export function multivariate<T>({ variants }: { variants: { rule: boolean; value: T }[] }) {
    +  return variants.find((v) => v.rule)?.value;   // first match wins; put the always() variant first as the default
    +}
    +
    cms.index.ts
    import { always, never, date } from "@decocms/blocks/matchers";
    +import { multivariate } from "./content/multivariate";
    +
    +export default { always, never, date, multivariate, seo, hero } satisfies Blocks;
    +

    Editors compose them in Studio: a variant is a tab, its rule is picked from the matchers in your map, and the first tab is the default. A hero whose title changes for a campaign is a hero whose title is a multivariate:

    +
    .deco/blocks/HomeHero.json
    {
    +  "__resolveType": "hero",
    +  "title": {
    +    "__resolveType": "multivariate",
    +    "variants": [
    +      { "rule": { "__resolveType": "date", "start": "2026-11-20", "end": "2026-11-30" }, "value": "Black Friday starts now" },
    +      { "rule": { "__resolveType": "always" }, "value": "New season, new arrivals" }
    +    ]
    +  }
    +}
    +

    Resolution does the rest: each rule runs and yields a boolean, multivariate runs with those booleans and yields a string, hero runs with a plain title. Nothing in hero knows a variant was involved. The same shape wraps a whole section, or a page's whole sections array, which is how Studio does page-level variants.

    + +

    Write your own matcher

    +

    A matcher is any block function that returns a boolean. It reads whatever your app knows about the current request or user the way every other block does, through the context your runtime already provides:

    +
    content/matchers.ts (website)
    import { headers } from "next/headers";
    +
    +// A website: the device from the user agent.
    +export async function device({ device }: { device: "mobile" | "desktop" }) {
    +  const ua = (await headers()).get("user-agent") ?? "";
    +  return (/Mobi|Android/i.test(ua) ? "mobile" : "desktop") === device;
    +}
    +
    content/matchers.ts (app)
    // A mobile app: the user's plan from app state. No request, no headers.
    +export const plan = ({ plan }: { plan: "free" | "pro" }) => session.current().plan === plan;
    +

    Add them to the map and Studio's rule picker shows them beside date: it lists every function in the manifest's matchers group, which the CLI fills with every block function that returns a boolean. Combinators are matchers too: and, or, and not are one-liners over booleans, if you want them.

    + +

    Every variant resolves

    +

    Inputs resolve before a function runs, so every variant's value resolves before multivariate picks one. For a title that's free. For two product queries it isn't, and a failure in a variant you didn't pick still fails the whole thing.

    +

    The lazy form is to make the values entry names instead of inline blocks. A string isn't a block, so nothing runs; the multivariate returns a name, and whoever holds the client resolves it:

    +
    {
    +  "__resolveType": "multivariate",
    +  "variants": [
    +    { "rule": { "__resolveType": "date", "start": "2026-11-20", "end": "2026-11-30" }, "value": "BlackFridayHero" },
    +    { "rule": { "__resolveType": "always" }, "value": "DefaultHero" }
    +  ]
    +}
    +
    const [name] = await client.resolve(section);   // "BlackFridayHero"
    +const [hero] = await client.resolve(name);      // only this variant ran
    +

    A variant is not access control. It chooses between values that are all fine to compute and show. 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.

    +

    Anything a matcher looks at is a cache dimension. If a page varies by device, the device belongs in the cache key.

    +

    + +
    +

    Websites

    Routes, redirects, and content types

    +

    The framework ships two content types. Everything that has a URL is built from them.

    +
    /** 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"
    +}
    +
    +/** A saved entry of the built-in `redirect` type. */
    +interface Redirect {
    +  from: string;        // literal or template
    +  to: string;
    +  permanent: boolean;  // 301 or 302
    +}
    +

    A page is a route with SEO and sections. It's not special to the router; it's just the first type that extends Route. Like every data-only type, it's declared in Content, not in the block map:

    +
    interface Page extends Route {
    +  seo: PageSeo;
    +  sections: VariantOf<Array<VariantOf<Block>>>;
    +}
    +
    .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" }
    +  ]
    +}
    +
    .deco/blocks/LegacySummer.json
    {
    +  "__resolveType": "redirect",
    +  "from": "/campaigns/summer",
    +  "to": "/summer",
    +  "permanent": true
    +}
    +

    matchRoute's rule is one line: an entry with a string path matches at that path. Route is what makes that intentional. The CLI checks that any type declaring path extends Route, so a stray path field on an unrelated block is a schema error rather than a surprise URL. Editors see name and path as ordinary fields on every routable thing, in Studio, which is how a marketing team gets /summer-sale live without a developer. Keep path and from plain strings, not blocks, so the router can match without running code.

    + +

    Your own routable types

    +

    A blog doesn't want a page entry per post. Extend Route and every saved post routes on its own:

    +
    content/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;
    +}
    +
    .deco/blocks/HelloWorld.json
    { "__resolveType": "post", "name": "Hello, world", "path": "/blog/hello-world", "date": "2026-09-01", "body": "…" }
    +

    Studio pre-fills path from a per-type template such as /blog/{slug}, so editors get consistent URLs by default and can still change one. Two entries with the same path, or two templates that can match the same URL, are rejected by the CLI and by matchRoute.

    + +

    Pages are data; resolve their fields

    +

    A page has no function, so you don't resolve the page. You resolve the blocks it holds: client.resolve(page.seo), and one client.resolve(section) per section, so sections can stream as they finish (see the framework guides). A redirect is never resolved at all; matchRoute reads it. If you do want code to run for a whole page, such as validation or a layout step, add a page function to the block map and move Page out of Content; then client.resolve(page) runs it after every section has resolved.

    + +

    Content types without a URL

    +

    Navigation, email templates, and campaign settings work the same way, minus Route: declare the type in Content if it's data, or register a function if something should run, and save entries of that type. Block is a stored block. VariantOf<T> accepts a T or a flag that picks one.

    +
    + +
    +

    Websites

    Routing

    +

    The CMS doesn't route. It lists and resolves. Routing is three steps in your request handler: list the types you want at URLs, hand them to matchRoute, resolve the winner. Because the client is made per request, all three see the same revision.

    +
    import { matchRoute, type Redirect } from "@decocms/blocks";
    +
    +const client = cms.forRelease();
    +const [pages]     = await client.list<Page>("page");
    +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":
    +    switch (match.entry.__resolveType) {
    +      case "page": {
    +        const sections = match.entry.sections.map((s) => client.resolve(s));   // start them all
    +        const [seo] = await client.resolve(match.entry.seo);                   // then await
    +        return render(seo, sections);
    +      }
    +      case "post":
    +        return renderPost(match.entry);                                        // data: nothing to resolve
    +    }
    +}
    +
    + + + + + + +
    kindFields
    "match"entry (the stored route you passed in) and params from the path template.
    "redirect"location with parameters filled in, and status (301 or 302).
    "not-found"None
    +

    matchRoute is a helper, not a requirement. The CMS has no idea what a URL is. If your framework already routes, or you'd rather look up a page by name (client.resolve("SummerPage")) or by a field of your own, skip it. It exists because "an editor typed a path into Studio and that page is now live" is the common case, and getting precedence and parameters right by hand is tedious.

    +

    Which types route is your decision, per handler: a blog lists post, a docs site lists doc. match.params holds the URL parameters; hand them to your code however your app shares request state. The CMS never injects them into blocks.

    + +

    Matching is a lookup, not a scan: entries are compiled into a segment trie once per list, so a match costs the URL's depth, not the catalogue's size. The algorithm is described under the hood.

    + +

    Path templates and match order

    +

    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". Redirects are checked before routes, so a redirect overrides a route at the same path. Within each group, exact paths win over templates. Two entries with the same path, or two templates that can match the same URL, make matchRoute throw, and the CLI reports them at schema time. Serve framework endpoints, API routes, and static assets before the CMS catch-all; those paths don't belong to the CMS.

    + +

    Linking and listing

    +

    Because the URL is a field, linking to an entry is reading it, and listing a routable type gives you an index for free:

    +
    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"
    +
    +const match = matchRoute("/blog/hello-world", { routes: posts });
    +// { kind: "match", entry: { __resolveType: "post", name: "Hello, world", path: "/blog/hello-world", … }, params: {} }
    + +

    Example project

    +

    The framework guides share these files. The product function is different in each guide.

    +
    src/cms/model.ts
    import type { Block, Route } from '@decocms/blocks'
    +
    +export interface SEO { title: string; description: string }
    +export interface ProductProps { id: string; name: string; price: number; currency: string }
    +export interface SectionData { component: 'product'; props: ProductProps }
    +
    +// A stored page, as client.list returns it.
    +export interface StoredPage extends Route {
    +  seo: SEO | Block
    +  sections: Block[]
    +}
    +
    src/cms/blocks.base.ts
    import type { Redirect } from '@decocms/blocks'
    +import type { SEO, StoredPage } from './model'
    +
    +export const baseBlocks = {
    +  seo: (input: SEO) => input,
    +}
    +
    +export type Content = {
    +  page: StoredPage
    +  redirect: Redirect
    +}
    +
    .deco/blocks/ProductPage.json
    {
    +  "__resolveType": "page",
    +  "name": "Product page",
    +  "path": "/:slug/p",
    +  "seo": {
    +    "__resolveType": "seo",
    +    "title": "Summer shirt | Deco example",
    +    "description": "A small example of independently resolved fields."
    +  },
    +  "sections": [
    +    { "__resolveType": "product", "id": "details", "name": "Summer shirt", "price": 49, "currency": "USD" },
    +    { "__resolveType": "product", "id": "related", "name": "Related shirt", "price": 39, "currency": "USD" }
    +  ]
    +}
    +
    .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 page uses literal product values to keep the example small. A real page would use a block that fetches the product for the slug in match.params.

    +
    + +
    +

    Websites

    Rendering

    +

    The CMS returns whatever your block function returns. Your framework decides how that value reaches the browser:

    +
    + + + + + + +
    IntegrationBlock returnsStreamingShips to the browser
    Next.js App RouterJSXServer Component Suspense boundariesClient Components
    TanStack Start, dataSerializable descriptorsDeferred promises with AwaitThe view registry and its components
    TanStack Start, RSCJSXReact FlightClient references and the RSC runtime
    + +

    Block functions run on the server

    +

    In all three integrations, 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.

    +

    Render interactive components as elements, such as <CartButton />. Don't register a Client Component as a block function: the "use client" boundary preserves a reference, not a callable function.

    + +

    Errors, streaming, and cancellation

    +
      +
    • Start every section first. Await critical data, such as SEO, only after all sections have started.
    • +
    • One Suspense boundary per section lets each section appear as soon as it's ready.
    • +
    • Suspense handles loading, not errors. Check each section's error and render a fallback. Errors thrown later while React renders need an error boundary.
    • +
    • Cancellation is your app's job. The 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. A later error can't change it.
    • +
    + +

    Choose a transport

    +

    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.

    +
    + +
    +

    Reference

    API reference

    +

    Everything is exported from @decocms/blocks.

    + +

    createCMS(config)

    +

    Two levels. A CMS is a loader plus a block map, created once at module scope. It hands out clients, one per request, for the release or for a draft, and keeps content current. A client resolves and lists, and that's all. Both are cheap: content caches in the loader, and dynamically imported modules are cached by the runtime.

    +
    function createCMS(config: {
    +  loader:
    +    | string                             // a content directory: localLoader(path)
    +    | { root?: string; token?: string }  // local content in root (default "./.deco"); with a token, remote releases over it
    +    | Loader;                            // anything else: bundled content, KV, your own
    +  blocks: Blocks;
    +}): CMS;
    +
    +interface CMS {
    +  forRelease(): Client;                         // a client reading loader.load()
    +  forDraft(pointer: string): Client;            // a client reading loader.load(pointer); an invalid pointer behaves like the release
    +  update(): Promise<{ updated: boolean }>;      // loader.update(), or a no-op
    +}
    +
    +interface Client {
    +  resolve<T = unknown>(target: unknown, options?: { resolve?: boolean }): Promise<Result<T>>;   // resolve: false reads without running
    +  list<T = Block>(type: string, options?: ListOptions<T>): Promise<Result<T[]>>;
    +}
    +

    The loader option covers the common cases by shape. A path is the files in that directory. { token } is releases from the Deco API with the files in ./.deco as fallback, and root changes the directory. Leave token undefined and it's just the files, which is what makes one line work in development and production. Anything else is a Loader you built yourself. Create the CMS once, at module scope, and ask it for a client per request: forRelease() for visitors, forDraft(pointer) when the request carries one. Clients are free to make and hold nothing you'd want to share. Everything either does is a few lines over a loader, and the registry rule from Blocks is how it resolves; those internals aren't part of the public API.

    + +

    Loaders

    +

    One interface. load() with no argument is production; load(pointer) is a draft. update() checks for newer content.

    +
    interface Loader {
    +  load(pointer?: string | null): Promise<{ revision: string; blocks: Record<string, Block> }>;
    +  update?(): Promise<{ updated: boolean }>;
    +}
    +
    + + + + + +
    LoaderLoadsUse it for
    localLoader(dir)
    localLoader({ revision, blocks })
    The content that ships with the app: a .deco directory on Node, or JSON imported at build time on Workers. Ignores the pointer: there are no drafts in local content. No update().Development, tests, and the fallback in production
    remoteLoader(fallback, { token })The current release from the Deco API over a fallback loader, or the draft a pointer names. The token identifies the site; everything else comes from decocms.Production
    +

    remoteLoader(localLoader("./.deco"), { token }): load() never waits on the network. It returns what's in memory, the newest release fetched or else the fallback loader's content, and update() checks for newer content in the background. See how it stays current. load(pointer) fetches the draft a pointer names and caches it by version; drafts never fall back. See Preview.

    +

    update() is rate-limited to the polling interval, never throws, and resolves at once if a check ran recently. On Node, remoteLoader calls it on its own timer. Cloudflare Workers have no timers between requests, so call it yourself, off the request path, with ctx.waitUntil(cms.update()) or from a Cron Trigger. A webhook or an admin "refresh now" button calls the same method. A loader without update() is always current by construction, like the filesystem.

    +

    Draft pointers

    +

    A pointer is a string, <host[:port]><path[?query]>@<version>, that names where a draft lives and which version it is. Studio makes them; forDraft reads them. Two helpers cover everything in between:

    +
    interface DraftPointer {
    +  host: string;      // content-API authority, e.g. "studio.decocms.com"
    +  path: string;      // rooted path, with the signed grant in its query
    +  version: string;   // opaque, immutable: the branch head or ETag
    +}
    +
    +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.

    +

    For websites, two more helpers do the handshake, so the parameter and cookie names never appear in app code:

    +
    function draftPointer(request: Request | { url: string; headers: Headers }): string | null;
    +// ?__draft= from the URL first, then the deco-draft cookie. Null when neither is present.
    +
    +function draftCookie(request: Request | { url: string; headers: Headers }): string | null;
    +// When the URL carries ?__draft=, the Set-Cookie value that stores it (HttpOnly, SameSite=Lax, Path=/),
    +// or one that expires the cookie when the value is empty. Null on every other request.
    +
    +const DRAFT_COOKIE = "deco-draft";   // for frameworks whose cookie API has no Request, e.g. Next.js cookies()
    +

    Both take anything with a url and headers, so they work with a fetch Request, a Next.js NextRequest, or a framework's request wrapper. DRAFT_COOKIE is for the one place that has no request at all: reading the cookie in a Next.js Server Component.

    +
    const pointer = parseDraftPointer(cookie);
    +if (pointer) console.log(`previewing ${pointer.version} from ${pointer.host}`);
    +
    +formatDraftPointer({ host: "studio.decocms.com", path: "/api/acme/decofile/store/main?token=…", version: "9f3c1a" });
    +// "studio.decocms.com/api/acme/decofile/store/main?token=…@9f3c1a"
    +

    Caching, ETags, and fallbacks live inside the loader, which may be shared by any number of clients. The client only ever sees { revision, blocks }.

    + +

    client.resolve(target, options?)

    +

    Loads the content once and applies the registry rule to the target: saved entries expand, and each block type in the map runs with its resolved inputs. { resolve: false } expands entries but runs nothing.

    +
    + + + + + + + + +
    CallReturns
    resolve("SummerSEO")The result of running the entry's function
    resolve("SummerSEO", { resolve: false })The saved entry, with references expanded and nothing run
    resolve({ __resolveType: "seo", title: "Sale" })The result for an inline block
    resolve(page.sections)An array of results, one per section
    resolve({ title: "Store" })The same object: it contains no blocks
    +

    A string target is always an entry 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; it isn't validated at runtime.

    +

    A client reads one revision for its lifetime. Its first call loads; every later call, list or resolve, reuses that content and shares a cache. That's why a client is made per request: everything a response renders agrees with itself, sections can stream as separate calls, and the next request sees the next revision. Two clients can see different revisions, so don't share one across requests.

    + +

    client.list(type, options?)

    +

    Every saved entry of a block type, through aliases. Returns stored entries by default; resolve: true runs each one.

    +
    interface ListOptions<T> {
    +  where?: (entry: T) => boolean;
    +  sort?: (a: T, b: T) => number;
    +  limit?: number;
    +  resolve?: boolean;
    +}
    +
    +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, which is the honest cost of a file-based store. 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 Routing.

    +
    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 }
    +  | { kind: "not-found" };
    +

    Redirects win over routes, exact paths win over templates, and two entries that can match the same URL throw. Entries are compiled into a segment trie, cached per items array, so a lookup costs the URL's depth, not the catalogue's size; see how it matches.

    + +

    Types

    +
    type Block = { __resolveType: string; [input: string]: unknown };
    +
    +// A block map: what cms.index.ts exports and the CLI reads.
    +type BlockFunction = (inputs: any) => unknown | Promise<unknown>;
    +type Blocks = Record<string, BlockFunction>;
    +
    +interface Loader {
    +  load(pointer?: string | null): Promise<{ revision: string; blocks: Record<string, Block> }>;
    +  update?(): Promise<{ updated: boolean }>;
    +}
    +
    +interface DraftPointer { host: string; path: string; version: string }
    +
    +interface Route { name: string; path: string }                    // extend it to give a type a URL
    +interface Redirect { from: string; to: string; permanent: boolean }
    +
    +type Match<T> =
    +  | { kind: "match";    entry: T; params: Record<string, string> }
    +  | { kind: "redirect"; location: string; status: 301 | 302 }
    +  | { kind: "not-found" };
    +
    +type Result<T> = [T, null] | [null, CMSError];
    + +

    Errors

    +

    Nothing in the SDK throws. Every call returns [value, null] or [null, error]. Check the error, not the value, because a block function can legitimately return null.

    +
    + + + + + + + + +
    CodeWhen
    NOT_FOUNDA string target or a reference names an entry that doesn't exist.
    UNKNOWN_BLOCKA name is neither a saved entry nor a type in blocks.
    CYCLEReferences lead back to an entry that's already being expanded.
    BLOCK_FAILEDA block function threw. The original error is in cause.
    LOADER_FAILEDThe loader couldn't load the content. The original error 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.

    +
    + +
    +

    Framework guides

    Next.js App Router

    +

    In this guide, you'll render CMS pages with Server Components, stream each section under its own Suspense boundary, and generate metadata from the page's SEO block.

    +

    Prerequisites: an App Router app on the Node runtime with a root layout, plus the example project files under src.

    + +

    1. Add the views

    +
    src/views/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>
    +  )
    +}
    +
    src/views/Product.tsx
    import CartButton from './CartButton'
    +import type { ProductProps } from '../cms/model'
    +
    +const money = (props: ProductProps) =>
    +  new Intl.NumberFormat('en-US', { style: 'currency', currency: props.currency }).format(props.price)
    +
    +export default function Product(props: ProductProps) {
    +  return (
    +    <section id={props.id}>
    +      <h2>{props.name}</h2>
    +      <p>{money(props)}</p>
    +      <CartButton />
    +    </section>
    +  )
    +}
    + +

    2. Create the CMS

    +

    The product block returns JSX. Next renders it as a Server Component, and CartButton stays a Client Component.

    +
    src/blocks/product.tsx
    import Product from '../views/Product'
    +import type { ProductProps } from '../cms/model'
    +
    +export default async function product(input: ProductProps) {
    +  return <Product {...input} />
    +}
    +
    src/cms/blocks.ts
    import type { Blocks } from '@decocms/blocks'
    +import { baseBlocks } from './blocks.base'
    +import product from '../blocks/product'
    +
    +export default { ...baseBlocks, product } satisfies Blocks
    +
    src/cms/cms.ts
    import { createCMS } from '@decocms/blocks'
    +import blocks from './blocks'
    +
    +// Development (no token) reads the files in .deco. Production reads releases from the Deco API
    +// and falls back to those same files.
    +export const cms = createCMS({ loader: { token: process.env.DECO_SITE_TOKEN }, blocks })
    +
    src/cms/client.server.ts
    import 'server-only'
    +import { cookies } from 'next/headers'
    +import { DRAFT_COOKIE } from '@decocms/blocks'
    +import { cms } from './cms'
    +
    +// The client for this request: a draft when the cookie holds a valid pointer, production otherwise.
    +// Without a token, in development, this is always the release client; only the API loader fetches drafts.
    +export async function client() {
    +  const pointer = (await cookies()).get(DRAFT_COOKIE)?.value
    +  return pointer ? cms.forDraft(pointer) : cms.forRelease()
    +}
    +

    Generate the schema from the map file: npx deco schema --entry ./src/cms/blocks.ts --out ./.deco/schema.json. For preview, let proxy.ts store the draft pointer in a cookie on the request Studio opens, so navigation keeps it:

    + +
    src/proxy.ts
    import { NextResponse, type NextRequest } from 'next/server'
    +import { draftCookie } from '@decocms/blocks'
    +
    +export function proxy(request: NextRequest) {
    +  const response = NextResponse.next()
    +  const cookie = draftCookie(request)          // only set on the request Studio opens; expiring when leaving preview
    +  if (cookie) response.headers.append('Set-Cookie', cookie)
    +  return response
    +}
    + +

    3. Open the page once per request

    +

    generateMetadata and the page component both need the page. Wrap the lookup in React's cache so they share one client, one match, and the same section promises.

    +
    src/cms/open-page.server.ts
    import 'server-only'
    +import { cache } from 'react'
    +import 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 { SEO, 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) permanentRedirect(match.location)
    +    redirect(match.location)
    +  }
    +
    +  const page = match.entry
    +  return {
    +    sections: page.sections.map((section, index) => ({
    +      key: `${pathname}:${index}`,
    +      result: c.resolve<ReactNode>(section),
    +    })),
    +    seo: c.resolve<SEO>(page.seo),
    +  }
    +})
    +
    +export const pathnameFor = (segments: string[] = []) =>
    +  '/' + segments.map(encodeURIComponent).join('/')
    + +

    4. Render the catch-all route

    +
    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 '../../cms/open-page.server'
    +
    +export const runtime = 'nodejs'
    +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 }
    +}
    +
    +async function Section({ result }: { result: Promise<Result<ReactNode>> }) {
    +  const [node, error] = await result
    +  if (error) {
    +    console.error(error)
    +    return <p role="status">This section is temporarily unavailable.</p>
    +  }
    +  return node
    +}
    +
    +export default async function Page({ params }: Props) {
    +  const page = await openPage(pathnameFor((await params).path))
    +  return (
    +    <main>
    +      {page.sections.map((section) => (
    +        <Suspense key={section.key} fallback={<p>Loading section…</p>}>
    +          <Section result={section.result} />
    +        </Suspense>
    +      ))}
    +    </main>
    +  )
    +}
    + +

    5. List another content type

    +

    A blog index needs no page entry. List the post type; each entry carries its own path:

    +
    src/app/blog/page.tsx
    import Link from 'next/link'
    +import { client } from '../../cms/client.server'
    +import type { Post } from '../../../content/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>
    +  )
    +}
    +

    How it works

    +
      +
    • Preview is a different client. client() is pointer ? cms.forDraft(pointer) : cms.forRelease(), and the pointer only exists while the cookie does. Nothing else in the guide knows about preview.
    • +
    • 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.
    • +
    • Metadata waits only for SEO. Next can stream metadata separately. A metadata error after streaming starts can't change headers that were already sent.
    • +
    • Redirect status codes. Next's navigation helpers send 308 and 307. For an exact 301 or 302, match redirects in proxy.ts with matchRoute and return NextResponse.redirect(url, match.status). Proxy runs separately and doesn't share the page's revision.
    • +
    +

    These files use the default App Router configuration. Cache Components adds caching and Suspense requirements of its own. See the Next.js docs on metadata and proxy.

    +
    + +
    +

    Framework guides

    TanStack Start with data descriptors

    +

    In this guide, block functions return serializable descriptors, and a component registry renders them. Start streams each section's result, server-renders the views, and hydrates the same views in the browser. It deploys to Cloudflare Workers.

    +

    Prerequisites: a Start app with its route tree, router, and document shell, plus the example project files and the Product and CartButton views from the Next.js guide. Enable resolveJsonModule in tsconfig.json.

    + +

    1. Return descriptors from blocks

    +
    src/blocks/product.data.ts
    import type { ProductProps, SectionData } from '../cms/model'
    +
    +export default async function product(input: ProductProps): Promise<SectionData> {
    +  return { component: 'product', props: input }
    +}
    + +

    2. Create the CMS

    +

    Workers have no filesystem, so local content is the JSON imported at build time, and remoteLoader serves releases over it. wrangler dev reads the token from .dev.vars.

    +
    src/cms/blocks.ts
    import type { Blocks } from '@decocms/blocks'
    +import { baseBlocks } from './blocks.base'
    +import product from '../blocks/product.data'
    +
    +export default { ...baseBlocks, product } satisfies Blocks
    +
    src/cms/cms.ts
    import { createCMS, localLoader, remoteLoader, draftPointer } from '@decocms/blocks'
    +import { env } from 'cloudflare:workers'
    +import ProductPage from '../../.deco/blocks/ProductPage.json'
    +import LegacyProduct from '../../.deco/blocks/LegacyProduct.json'
    +import blocks from './blocks'
    +
    +// The content this build shipped with. remoteLoader falls back to it if the API is unreachable.
    +const local = localLoader({ revision: 'bundled', blocks: { ProductPage, LegacyProduct } })
    +
    +export const cms = createCMS({ loader: remoteLoader(local, { token: env.DECO_SITE_TOKEN }), blocks })
    +
    +// The client for this request: the draft it points at, or the current release.
    +// Workers have no timers between requests, so each request also kicks the update check;
    +// it returns at once unless five minutes have passed, and never blocks the response.
    +export const client = (request: Request) => {
    +  cms.update()
    +  const pointer = draftPointer(request)
    +  return pointer ? cms.forDraft(pointer) : cms.forRelease()
    +}
    + +

    3. Open the page

    +

    This helper is shared by both TanStack guides. The server function hands the incoming request to client(), so preview needs no framework support. Each section promise resolves to the value, or to null if the section failed, so no internal error details reach the browser.

    +
    src/cms/open-page.server.ts
    import { notFound, redirect } from '@tanstack/react-router'
    +import { matchRoute, type Redirect } from '@decocms/blocks'
    +import { client } from './cms'
    +import type { SEO, StoredPage } from './model'
    +
    +export async function openPage<T>(href: string, request: Request) {
    +  const c = 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
    +  const sections = page.sections.map((section, index) => ({
    +    key: `${href}:${index}`,
    +    value: c.resolve<T>(section).then(([value, sectionError]) => {
    +      if (sectionError) console.error(sectionError)
    +      return sectionError ? null : value
    +    }),
    +  }))
    +
    +  // Every section has started before SEO is awaited.
    +  const [seo, seoError] = await c.resolve<SEO>(page.seo)
    +  if (seoError) throw seoError
    +  return { seo, sections }
    +}
    + +

    4. Expose it through a server function

    +
    src/server/page.functions.ts
    import { createServerFn } from '@tanstack/react-start'
    +import { getRequest } from '@tanstack/react-start/server'
    +import { z } from 'zod'
    +import { openPage } from '../cms/open-page.server'
    +import type { SectionData } from '../cms/model'
    +
    +export const loadPage = createServerFn({ method: 'GET' })
    +  .inputValidator(z.object({ href: z.string().startsWith('/') }))
    +  .handler(({ data }) => openPage<SectionData>(data.href, getRequest()))
    + +

    5. Render the catch-all route

    +
    src/routes/$.tsx
    import { Suspense } from 'react'
    +import { Await, createFileRoute } from '@tanstack/react-router'
    +import Product from '../views/Product'
    +import { loadPage } from '../server/page.functions'
    +
    +const views = { product: Product }
    +
    +export const Route = createFileRoute('/$')({
    +  loader: ({ location }) => loadPage({ data: { href: location.pathname + location.searchStr } }),
    +  head: ({ loaderData }) => ({
    +    meta: loaderData
    +      ? [{ 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.sections.map((section) => (
    +        <Suspense key={section.key} fallback={<p>Loading section…</p>}>
    +          <Await promise={section.value}>
    +            {(data) => {
    +              if (!data) return <p role="status">This section is temporarily unavailable.</p>
    +              const View = views[data.component]
    +              return <View {...data.props} />
    +            }}
    +          </Await>
    +        </Suspense>
    +      ))}
    +    </main>
    +  )
    +}
    +

    Keep section promises unawaited. Start streams nested promises through its own transport and Await consumes them. A plain JSON endpoint can't do this: it has to await every value first.

    +

    Import the server function statically from the route, so Start can replace it with an RPC stub in the client build. The component registry and its views ship to the browser. A block that needs the request calls getRequest() from @tanstack/react-start/server. Check preview authorization in the handler before selecting a private release. See Start server functions.

    + +

    6. Configure Cloudflare Workers

    +
    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()],
    +})
    +
    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"]
    +}
    +

    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.

    +
    + +
    +

    Framework guides

    TanStack Start with React Server Components

    +

    Experimental. TanStack Start's RSC APIs are experimental and need an RSC compiler configuration.

    +

    This guide reuses the data guide's content, CMS, and openPage helper, but the product block returns JSX instead of a descriptor. The server renders the tree through React Flight, so the browser needs no view registry.

    + +

    1. Swap the product block

    +

    Use src/blocks/product.tsx from the Next.js guide, and import it in src/cms/blocks.ts in place of product.data. Keep cms.ts as it is.

    + +

    2. Render sections on the server

    +
    src/server/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 '../cms/open-page.server'
    +
    +async function Section({ value }: { value: Promise<ReactNode | null> }) {
    +  const node = await value
    +  return node ?? <p role="status">This section is temporarily unavailable.</p>
    +}
    +
    +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.sections.map((section) => (
    +          <Suspense key={section.key} fallback={<p>Loading section…</p>}>
    +            <Section value={section.value} />
    +          </Suspense>
    +        ))}
    +      </main>,
    +    )
    +    return { seo: page.seo, content }
    +  })
    + +

    3. Render the route

    +
    src/routes/$.tsx
    import { createFileRoute } from '@tanstack/react-router'
    +import { loadPage } from '../server/page.functions'
    +
    +export const Route = createFileRoute('/$')({
    +  loader: ({ location }) => loadPage({ data: { href: location.pathname + location.searchStr } }),
    +  head: ({ loaderData }) => ({
    +    meta: loaderData
    +      ? [{ 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 through Flight, including references to Client Components like CartButton. Adding "use client" alone doesn't enable this. Start's custom serializers don't apply inside the server tree. If you cache RSC values with TanStack Query, set structuralSharing: false. See Start Server Components.

    + +

    4. Configure Cloudflare Workers

    +

    Make rsc a child environment of the ssr Worker, and keep wrangler.jsonc from the data guide.

    +
    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' } },
    +  },
    +})
    +

    Also requires @vitejs/plugin-rsc 0.5.30 and react-server-dom-webpack 19.2.7. If one repository maintains both integrations, give each its own build configuration.

    +
    + +
    +

    Production

    Loaders and deployment

    +

    A loader returns a revision: a hash of the content plus every saved entry in it. localLoader, the CLI, and the Deco API all compute the hash the same way, so "is what I have current?" is one string comparison. Your block functions don't know where content came from, so the same code runs against local files, content bundled into the build, or published releases.

    +
    {
    +  "revision": "b1946ac92492d2347c6235b4d2611184",
    +  "blocks": {
    +    "SummerSEO": { "__resolveType": "seo", "title": "Summer collection" }
    +  }
    +}
    +
    + + + + + +
    Content fromDeployment
    localLoaderThe content that ships with the app: a directory on Node, or JSON bundled into the build on Workers. On its own, changing content means redeploying (or, on Node, editing files); as the fallback of remoteLoader, it's what the app serves when the API is down.
    remoteLoader(fallback, …)The Deco API serves each commit of the production branch as an immutable release. The loader reads the current one at request time, cached by revision, with the content the build shipped with as the fallback. A content change reaches visitors without a deploy. This is what "instant deploys" means.
    +

    Revisions are immutable: change any entry and the hash changes. A revision is a safe cache key everywhere.

    + +

    One revision per response

    +

    A client reads one revision for its lifetime: its first call loads, and every later list and resolve reuses that content, even concurrent ones. Two clients can see different revisions, so make one client per request and do everything the response needs through it.

    +

    When the browser requests more data for a page it has already rendered, send the page's revision along so the data matches the HTML. A revision ID picks content; it doesn't grant access. Preview access is the draft pointer's signed grant and the site's preview hosts on decocms, see Preview.

    + +

    Publishing

    +

    Publishing is committing. A developer or an agent edits .deco/blocks and pushes; an editor saves in Studio, which commits to the same repository. Either way the production branch moves, and the Deco API turns that commit into a release. remoteLoader resolves the production channel to a revision once per load, caches immutable releases indefinitely, and polls for a new revision every five minutes, so a change reaches every visitor within about five minutes of the commit. Because content and code share one history, a deploy and a content change are the same kind of event: a commit. Rolling content back is reverting one.

    +

    The API skips a release whose schema doesn't match the running app's, so content can't get ahead of code: if a commit adds a block type the deployed build doesn't have, the loader keeps serving the previous release until the build catches up.

    + +

    Never waiting on the network

    +

    A production request never waits for the API. load() returns from memory, immediately, every time, and the loader checks for newer content in the background every five minutes. Publishing reaches visitors within about that long. How that works, step by step, is under the hood.

    + +

    Fallback

    +

    The fallback loader is the only fallback, and it's always available: it's the content the build shipped with, which is the content the deploy was tested against, so it's always compatible with the code. It always swaps whole revisions: mixing entries from different revisions breaks references and can bring back deleted content. The loader reports the revision it actually served.

    + +

    Write your own loader

    +

    The contract is one method. A KV-backed loader for a Worker:

    +
    src/cms/kv-loader.ts
    import type { Loader } from '@decocms/blocks'
    +
    +export function kvLoader(kv: KVNamespace, key = 'release:current'): Loader {
    +  return {
    +    async load() {
    +      const content = await kv.get(key, 'json')
    +      if (!content) throw new Error('No content in KV')
    +      return content   // { revision, blocks }
    +    },
    +  }
    +}
    +

    Pass it as createCMS's loader, or as the fallback of remoteLoader. If your store has drafts, honour the pointer argument; if it can go stale, add update(). The client only ever sees { revision, blocks }.

    +
    + +
    +

    Production

    Caching and observability

    +

    Each cache layer has its own lifetime and key:

    +
    + + + + + + + +
    LayerLifetimeYour responsibility
    Dynamically imported modulesCached by the JavaScript runtime after the first import(), for the life of the process.Keep per-request state out of module globals.
    Resolved blocksOne client: one request.Make a client per request and resolve everything the response renders through it.
    Cross-request resultsYour policy.Key by revision, app version, inputs, and relevant request context. Define TTL and invalidation.
    Upstream dataFollows the upstream API.Include tenant, locale, currency, or customer in the key. Never cache private data as public.
    +

    A block type alone is never a cache key. Include every input that can change the result. A cache hit doesn't bypass authorization. Keep state-changing operations out of content resolution entirely.

    + +

    Tracing

    +

    Make each request span the parent of block resolution spans and outbound HTTP spans. Record the block type, revision, duration, errors, and cache outcome, and keep credentials and customer data out of attributes. Export telemetry asynchronously, so an unavailable collector never blocks rendering. OpenTelemetry covers traces, metrics, and logs.

    +

    Studio and MCP tools build authoring and publishing workflows on these sources. They're not part of a storefront's request path.

    +
    + +
    +

    Production

    Upgrading and compatibility

    +

    Block type names and input shapes are persistent interfaces: saved content depends on them. Moving a file is free. Renaming a type or removing an input affects every block that uses it.

    + +

    Rename a type with an alias

    +

    An alias is another key pointing at the same thing: the same function in the block map, or the same type in Content. Content that stores the old name keeps working, and routing is unaffected because it looks at path, not the type name:

    +
    cms.index.ts
    export default {
    +  "product-card": productCard,
    +  "site/sections/Product.tsx": productCard,     // the name legacy content stores
    +} satisfies Blocks;
    +
    +export type Content = {
    +  page: Page;
    +  "website/pages/Page.tsx": Page;               // same type, legacy name; entries still route
    +  redirect: Redirect;
    +};
    +

    An alias only works while both names take the same inputs. If the old shape differs, register a function that converts it, or migrate the content first. deco schema rejects an alias that collides with an entry name.

    + +

    Upgrade checklist

    +
      +
    1. Keep existing names. Register existing path-like types as they are, or alias them.
    2. +
    3. Check inputs. Regenerate the schema and validate saved content. Review new required fields, narrowed unions, and changed defaults.
    4. +
    5. Migrate into a new revision. Keep unknown fields for inspection, and report unresolved references instead of dropping them.
    6. +
    7. Test the integration. Check routing, metadata, streamed sections, error fallbacks, and client navigation against the migrated content.
    8. +
    9. Keep a rollback pair. Keep the previous build with compatible content. Rolling back content alone can't restore removed code.
    10. +
    +

    Test behavior, not just schemas. A function can keep its input type and still change its output, defaults, or upstream calls.

    +
    + +
    +

    Production

    Troubleshooting

    +

    Start with three facts: the revision served, the entry name, and the block type. Call client.resolve(target, { resolve: false }) to see the content before any code runs, and loader.load() to see an entry exactly as stored.

    +
    + + + + + + + + + + + + + + +
    SymptomWhat to check
    UNKNOWN_BLOCKCompare __resolveType with your block map's keys, including case. Add an alias for renamed types. If the function does a dynamic import(), confirm the imported module has a default export.
    NOT_FOUNDA string target is always an entry name, never a block type. Confirm the entry exists in the served revision. To run a type directly, pass an inline block: client.resolve({ __resolveType: "seo", … }).
    My function never runs for a typeA saved entry probably has the same name. Run deco schema, which reports the collision, and rename the entry.
    Content comes back unresolvedYou passed { resolve: false }, or the client was created without a block map.
    A function runs more than once per requestUse one client for the whole request, and keep the block map at module level. Each new client, and each new map object, gets its own cache.
    CYCLEFollow the references in error.path and remove the one that leads back to an entry already being expanded.
    Wrong fields in the editorRegenerate the schema from the right cms.index.ts. Check the first parameter's type and its property JSDoc.
    A result can't cross the networkJSON can't carry functions or React trees. Return serializable descriptors, or use an RSC integration.
    Hydration mismatchCompare revisions and request context between server and client. Look for time-dependent or random rendering, and send the page's revision with follow-up requests.
    Content changes mid-responseUse one client for the whole response; a client never changes revision. Check that the loader never rewrites an existing revision.
    Stale or fallback contentCheck the revision the loader served, channel freshness, and release availability. Never mix entries from different revisions.
    +
    + + +
    +
    +

    Under the hood

    Under the hood

    +

    The developer docs describe how to use Deco Blocks. This part describes how it works as a whole: the resolution algorithm, how the production loader stays current without ever blocking a request, how the router matches, what Deco Studio reads and how the framework stays compatible with it, and the decisions behind all of that. It's for contributors, for anyone integrating a new framework or store, and for the curious.

    + +

    Contributing

    +

    The framework is a Bun workspace at decocms/blocks. Five packages, one-way dependency graph, and that direction is load-bearing:

    +
    packages/
    +├── blocks/        @decocms/blocks         the SDK: loaders, client, resolution, router. Zero deco dependencies.
    +├── blocks-admin/  @decocms/blocks-admin   the Studio protocol handlers.            depends on: blocks
    +├── blocks-cli/    @decocms/blocks-cli     schema generation and migrations.        depends on: blocks
    +├── tanstack/      @decocms/tanstack       TanStack Start + Cloudflare binding.     depends on: blocks, admin, cli
    +└── next/          @decocms/nextjs         Next.js App Router binding.              depends on: blocks, admin
    +

    blocks never imports from the others, and the two framework bindings never import from each other. When a concern needs both sides, split it rather than adding a back-edge. Cross-package imports resolve to .ts source, so no package bundles another's output; that's the guarantee the split exists for.

    +
    bun install
    +bun run check     # typecheck + lint
    +bun run test      # vitest, per package
    +

    Two rules worth knowing before a first PR. A site must never need a local shim for something a package should export: if a symbol is missing from a package's public surface, add the export. And if a test named after a production incident fails, don't relax the assertion; it's there because that exact invariant broke once.

    +
    + +
    +

    Under the hood

    How resolution works

    +

    Step through the registry rule for one saved entry. Switch between reading and resolving, and between a descriptor and a React tree as the function output. This is an illustration, so it doesn't run any application code.

    +
    +
    ProductCard · one saved entry
    +
    Function output
    +
    {
    +  "__resolveType": "product-card",
    +  "title": "Summer collection",
    +  "product": { "__resolveType": "CurrentProduct" }
    +}

    The saved entry. Its product input names CurrentProduct, another saved entry.

    +
    +

    CurrentProduct is a saved catalog-product block, so looking it up is a data hit followed by a function hit. A descriptor names a view for the browser to render. A React tree is consumed by an RSC-capable framework. Either way, the CMS returns it untouched.

    +
    + +
    +

    Under the hood

    How remoteLoader stays current

    +

    The rule that matters: a production request never waits for the network. load() returns from memory, immediately, every time. Staying current happens beside requests, not in front of them.

    +

    Request path · synchronous, never touches the network

    +
    +
    Requestclient.list or client.resolve
    +
    load() from memoryNewest fetched release, or the fallback loader's content
    +
    Your blocks runSame revision for the whole request
    +
    +

    Background path · on first use, then every ~5 minutes

    +
    +
    Ask for the hashA few bytes from the Deco API
    +
    CompareSame as memory: done. Same as disk: use disk, no download.
    +
    Fetch, verify, swapWhole revision at once. Any error: keep what's there.
    +
    +
      +
    1. Serve from memory. load() returns the newest release this process has fetched, or, if it hasn't fetched one yet, the fallback loader's content. A cold start serves the content the build shipped with on its very first request.
    2. +
    3. Ask for the hash. In the background, on first use and then every five minutes, update() fetches the current revision hash from the API. That's a few bytes. Each wait is jittered by up to 30 seconds, so a fleet of servers that started together doesn't poll together.
    4. +
    5. Compare. If the hash equals what's in memory, there's nothing to do. If it equals the fallback loader's revision, the disk is current: the loader uses that content and never downloads it. This is the common case right after a deploy.
    6. +
    7. Fetch and swap. Otherwise it fetches that revision, checks that the content hashes to it and that its schema matches the running app, and swaps it in whole. Requests in flight keep the revision they started with.
    8. +
    9. Fail quietly. Any network or validation error leaves memory as it was. Nothing reaches a request.
    10. +
    +

    So production content is eventually consistent, by design, with the polling interval as the bound: a commit is live everywhere within about five minutes, and one process only ever does one tiny request per interval, however many requests it serves. Drafts are the opposite: a preview must show exactly the version Studio asked for, so load(pointer) fetches that version and waits for it, cached by version afterwards. If the draft can't be fetched, it serves published content, never the disk.

    + +
    + +
    +

    Under the hood

    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 instead compiles the entries into a segment trie, once per list, and matches in time proportional to the URL's length, not the catalogue's size.

    +
    /summer                 /:slug/p                /blog/:slug             /blog/archive
    +                                    ┌── root ──┐
    +                                    │           │
    +            ┌───────────────────────┼───────────┼──────────────────┐
    +         "summer"                "blog"                        :slug
    +            │                       │                            │
    +        [SummerPage]     ┌──────────┴──────────┐                "p"
    +                     "archive"                :slug               │
    +                         │                      │           [ProductPage]
    +                    [BlogArchive]           [Post …]
    +
      +
    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. 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 request with the same list skips this entirely.
    2. +
    3. Reject ambiguity while building. Two entries reaching the same leaf, or two parameter children under one node (/:slug/p and /:id/p), throw with both names in the message. The CLI runs the same build at schema time, so this surfaces in CI, not in production.
    4. +
    5. 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. If a branch dead-ends, backtrack once to the parameter child of the last node that had one. Backtracking is bounded by the URL's depth, so a lookup is O(segments), typically four or five steps, regardless of how many entries exist.
    6. +
    7. Precedence falls out of the walk order. Trying the literal child before the parameter child at every node is exactly "exact paths win over templates", per segment rather than per path: /blog/archive beats /blog/:slug for /blog/archive, and /blog/:slug still serves /blog/hello-world. No sorting, no regex.
    8. +
    9. Redirects are a second trie, checked first, with the same rules. A hit fills the parameters into to and returns { kind: "redirect" }.
    10. +
    +

    Before matching, the URL is normalised: percent-decoded, query and fragment dropped, trailing slash removed except for /. Parameters capture one segment and never contain a slash. That's the whole algorithm; it's the same shape as the routers in Fastify and Hono, and it's about a hundred lines.

    + +
    + +
    +

    Under the hood

    Deco Studio compatibility

    +

    What Studio reads

    +

    Studio never calls the SDK. It reads two things: the schema the CLI wrote, served as /live/_meta, and the content files. So everything Studio knows about your site is inferred from your types, and the CLI does the inferring. It groups every key into a manifest by what the type says:

    +
    + + + + + + + + + +
    Manifest groupWhat lands thereStudio uses it for
    sectionsBlock functions that return JSX or a render descriptorThe section catalog when composing a page
    matchersBlock functions that return booleanThe rule picker on a variant
    loadersEvery other block functionData fields that can point at a function
    pagesContent types that extend RouteThe page list, "new page", the URL field
    redirectsRedirectThe redirects screen
    contentEvery other Content typePlain entries: navigation, settings, posts
    +
    .deco/schema.json, abridged
    {
    +  "manifest": {
    +    "blocks": {
    +      "sections": { "hero": { "$ref": "#/definitions/hero" } },
    +      "matchers": { "always": { "$ref": "…" }, "never": { "$ref": "…" }, "date": { "$ref": "…" } },
    +      "loaders":  { "seo": { "$ref": "…" }, "multivariate": { "$ref": "…" } },
    +      "pages":    { "page": { "$ref": "…" } },
    +      "redirects": { "redirect": { "$ref": "…" } }
    +    }
    +  },
    +  "definitions": { "hero": { "type": "object", "properties": { "title": { "type": "string", "title": "Heading" } } } }
    +}
    +

    Three conventions follow from this, and they're the ones the rest of the doc already uses. A key is a module type if it's in the manifest and a saved entry if it isn't, so short names like hero are fine. A variant is { "variants": [{ "rule", "value" }] } with the default first. A redirect is a flat { from, to, permanent } entry.

    + +

    Studio compatibility today

    +

    Today's Studio predates this design and hardcodes a few of the things above: it tells modules from saved entries by whether the key has a file extension, and it looks for pages, the default and hidden rules, and variant wrappers by their old module paths (website/pages/Page.tsx, website/matchers/always.ts, website/flags/multivariate.ts, …). Until it reads the manifest instead, the CLI bridges the gap: for every key in a framework-known group it also emits the legacy path as an alias, and content Studio saves under a legacy name resolves through the same alias mechanism your own renames use. Your code uses page and always; the manifest carries both spellings; nothing in the runtime changes.

    +

    Two gaps the bridge doesn't cover, both Studio-side and both small: redirects created in Studio are saved in the old nested shape, which matchRoute accepts as well; and Studio's module-or-entry check needs to become a manifest lookup before a site can drop the aliases. Both are tracked against Studio, not against sites.

    + +
    + +
    +

    Under the hood

    Design decisions

    +

    Each of these was argued over. The one-line version, and why it won.

    +
    + + + + + + + + + + + + + + +
    DecisionWhy
    The CMS is 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 job. Request state comes from the runtime's own context, where it already lives.
    Git is the source of truth. Publishing is committing; the API serves commits as releases.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.
    One registry rule. A name is data or code; data merges and re-applies, code resolves inputs then runs; outputs are opaque.Saved entries become presets rather than a second concept, identity functions can't loop, and the only recursion is over data, where a cycle check ends it.
    The CMS at module scope, a client per request, pinned to one revision.Everything a response renders agrees with itself, sections can stream as separate calls, and the next request sees the next revision. Clients are cheap because every cache lives in the loader.
    Loaders are one interface: load(pointer?) and update().Production, drafts, and staying current are three uses of one method pair, so localLoader and remoteLoader are interchangeable and a custom loader is a few lines.
    A production request never waits for the network.Serve from memory, poll for a hash in the background, fall back to the content the build shipped with. Eventual consistency for visitors, strict consistency for drafts.
    The CMS doesn't route. matchRoute 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.
    Data-only types live in Content, not the block map. No built-in entries.A map entry means "run this". Pages, posts, and redirects have nothing to run; they need a schema, which is the CLI's job. Nothing ships that a site couldn't write in one line.
    Lazy loading belongs to the framework.What's heavy is the component, and React and Next already load components lazily. The block map stays a plain object the CLI can read.
    Three matchers ship; the rest is app code.always, never, and date depend on nothing but the clock. A device or cookie matcher is an opinion about the request, and the app owns those.
    Short type names, aliases for legacy paths.A type name is a column name, not a file path. Existing content keeps working through aliases while new code uses names that survive a refactor.
    +
    + +
    + + diff --git a/site/src/dark-tokens.css b/site/src/dark-tokens.css new file mode 100644 index 00000000..9a97de42 --- /dev/null +++ b/site/src/dark-tokens.css @@ -0,0 +1,82 @@ +--bg: #1A1714; +--bg-subtle: #1E1A17; +--bg-warm: #201C19; +--surface: #221E1B; +--muted: #2A2522; +--hover: #322C28; +--fg: #F4F2EE; +--fg-body: #DCD7D0; +--muted-fg: #B9B4AB; +--faint: #9A948A; +--quiet: #9A948A; +--num-faint: #9A948A; +--ol-num: #9A948A; +--who-fg: #BCD43C; +--mark-bg: #D0EC1A; +--mark-fg: #07401A; +--step-on: #D0EC1A; +--stack-fg: rgba(244, 242, 238, .64); +--footer-bg: #1A1714; +--border: rgba(255, 255, 255, .08); +--border-strong: rgba(255, 255, 255, .16); +--hairline: rgba(255, 255, 255, .07); +--eyebrow: #BCD43C; +--link: #D0EC1A; +--link-line: rgba(208, 236, 26, .42); +--accent-text: #D0EC1A; +--nav-active-bg: #16361D; +--nav-active-fg: #D0EC1A; +--nav-bar: #D0EC1A; +--pill-on-bg: #0B4A20; +--pill-on-fg: #D0EC1A; +--pill-on-ring: rgba(208, 236, 26, .28); +--ring: #D0EC1A; +--indicator-ring: transparent; +--tint: rgba(208, 236, 26, .1); +--tint-fg: #E4F58A; +--code-bg: #211D1A; +--code-head: #26221F; +--code-border: rgba(255, 255, 255, .07); +--code-inline: #2E2926; +--code-fg: #E8E3DC; +--code-sel: rgba(208, 236, 26, .28); +--note-bg: #211D1A; +--warn-bg: #2A2314; +--warn-border: #54451D; +--warn-icon: #F2C14E; +--preview-bg: #212914; +--preview-border: rgba(208, 236, 26, .22); +--del-bg: rgba(255, 110, 90, .12); +--del-fg: #FFA08C; +--add-bg: rgba(208, 236, 26, .12); +--th-bg: #211D1A; +--st-bg: #26221F; +--st-fg: #F4F2EE; +--st-muted: #B9B4AB; +--st-border: rgba(255, 255, 255, .08); +--st-field: #211D1A; +--st-track: #453E38; +--st-fill: #D0EC1A; +--win-bg: #211D1A; +--win-panel: #26221F; +--win-bar: #24201D; +--win-dot: #47413B; +--shadow-sm: 0 1px 2px rgba(0, 0, 0, .3); +--shadow-md: 0 1px 2px rgba(0, 0, 0, .3), 0 12px 32px -16px rgba(0, 0, 0, .6); +--shadow-win: 0 50px 110px -36px rgba(0, 0, 0, .75); +--shadow-lg: 0 0 0 1px rgba(255, 255, 255, .06), 0 24px 64px -16px rgba(0, 0, 0, .7); +--syn-comment: #958E86; +--syn-keyword: #A595FF; +--syn-string: #C9E45A; +--syn-type: #8FB8E8; +--syn-function: #8FB8E8; +--syn-number: #F2C14E; +--syn-property: #E8E3DC; +--syn-punctuation: #9C958D; +--syn-operator: #B8B1A9; +--syn-tag: #FF9A85; +--wm-light: none; +--wm-dark: inline-block; +--icon-sun: block; +--icon-moon: none; +color-scheme: dark; diff --git a/site/src/landing.html b/site/src/landing.html new file mode 100644 index 00000000..39332954 --- /dev/null +++ b/site/src/landing.html @@ -0,0 +1,358 @@ +
    + +
    + +
    + Next major · preview — proposed API, not yet released +

    Headless for developers.
    Editable for humans.
    Native for AI.

    +

    Deco Blocks is the AI-native headless CMS. Developers write the functions, marketers edit the content and settings those functions use in Deco Studio's visual editor, AI agents edit the same content as files, and every change lands in Git.

    +
    + Start building + See how it works + +
    +
    + +
    +
    +
    Deco Blocks · my-store — Experiments
    +
    + + + + + + + +
    +
    +
    + + + + +
    +
    +

    The Quickstart, end to end. Studio saves it, or an agent edits it: either way it's a commit, and the function never changes. Drag a slider, then Save.

    + Follow the quickstart{{icon:arrow-right}} +
    +
    +
    + +
    +
    +

    Runs on your stack

    +
      +
    • {{mark:nextjs}}Next.js
    • +
    • {{mark:tanstack}}TanStack Start
    • +
    • {{mark:cloudflare}}Cloudflare Workers
    • +
    • {{mark:react}}React Native
    • +
    • {{mark:node}}Node.js
    • +
    • {{mark:git}}Any Git repo
    • +
    +
    +
    + +
    +
    +
    +

    One content model

    +

    Every change is a commit, whoever makes it.

    +

    Launch a landing page at /summer-sale, schedule a campaign banner, ramp an experiment to 25%: all from a form, all reviewable, none of it a code change.

    +
    +
    +
    + + 01 +

    Headless for developers

    +

    Write a function, type its inputs, and it's a block. Deco Blocks 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.

    +
    +
    + + 02 +

    Editable for humans

    +

    Deco Studio, the visual editor in the hosted Deco CMS, turns your types into forms and live previews. Marketers and editors change pages, campaigns, and settings without a developer, and every change is a commit you can review and roll back.

    +
    +
    + + 03 +

    Native for AI

    +

    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.

    +
    +
    +
    +
    + +
    +
    +
    + +
    +

    Nothing extra to run

    +

    Your site ships with its content. If the hosted CMS fails, your site doesn't.

    +

    Your pages, banners and settings are files in your own repository, and every deploy carries them alongside the code. The content your visitors see is already inside your site, even when you publish through the optional hosted Deco CMS.

    +
    +
    +
    +
    store.example.com — status
    +
    +
    +

    Every deploy ships

    +
    +
    {{icon:code}}Your codeDesign, features
    + +
    {{icon:file}}Your contentPages, banners, settings
    +
    +

    Packed together, tested together.

    +
    +
      +
    • Your siteServing
    • +
    • Hosted Deco CMSUnreachableConnected
    • +
    • Visitors{{icon:check}}No errors
    • +
    +
    +
    +

    New changes go live once it's back.Publishing works as usual.

    + +
    +
    +
      +
    • {{icon:check}}No content servers to runNo content database or content server to host, scale or keep online.
    • +
    • {{icon:check}}Never slowed by the CMSEvery page your visitors see is built from content your site already holds, so a slow CMS can't slow your site down.
    • +
    • {{icon:check}}Stays up through outagesIf the hosted Deco CMS can't be reached, your site keeps serving the content it already has, with no error pages. A server that starts during the outage uses the content from your last deploy; new changes go live once the CMS is back.
    • +
    + For developers: how content loads and falls back{{icon:arrow-right}} +
    +
    +
    +
    + +
    +
    +
    +

    Hosted Deco CMS · optional

    +

    Publish in minutes, not on the next deploy.

    +

    The hosted Deco CMS adds Deco Studio, the visual editor, and publishing without a redeploy: each change reaches every visitor within about five minutes, and editors preview drafts on your real pages first. Without it, developers and agents edit the files, and content goes live with your next deploy.

    +
    + +
      +
    • Not tied to code releasesContent needs no build and no deploy, so editors publish when a campaign is ready, not when the next code release goes out.
    • +
    • Preview on the real siteEditors open drafts on your actual pages before anything goes live, and only people Studio lets in can see them.
    • +
    • Never ahead of your codeIf a change needs something the live site doesn't support yet, the site keeps showing the previous version until the code catches up.
    • +
    • Undo any changeEvery publish is saved in your repository's history, with who changed what and when, so going back is one step.
    • +
    + For developers: publishing and releases{{icon:arrow-right}} +
    +
    + +
    +
    +

    Three steps, no ticket

    +
    +
    +

    Your function stays. Its inputs move into a file.

    +

    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.

    +
    + + + +
    +
    +
    +
    Deco Blocks · my-store — setup
    + +
    +
    +
    // config/experiments.ts: the function you already have
    +export default function experiments(input: Experiments) {
    +  return { newCheckout: roll(input.newCheckout), /* … */ };
    +}
    +
    +// cms.index.ts: give it a name in a block map
    +import type { Blocks } from "@decocms/blocks";
    +import experiments from "./config/experiments";
    +
    +export default { experiments } satisfies Blocks;
    +

    {{icon:check}}The key experiments is now a block type content can refer to.

    +
    +
    +
    npx deco schema --entry ./cms.index.ts --out ./.deco/schema.json
    +
    +
    {{symbol}}Experimentsexperiments
    +
    New checkout flow100–100
    +
    Sticky header500–100
    +
    Free shipping banner00–100
    +
    +

    {{icon:check}}Studio shows each experiment as a number field clamped to 0–100.

    +
    +
    +
    import { createCMS } from "@decocms/blocks";
    +import blocks from "../../cms.index";
    +
    +// Production (with a token) reads the hosted Deco CMS;
    +// development reads the files in .deco.
    +export const cms = createCMS({
    +  loader: { token: process.env.DECO_SITE_TOKEN },
    +  blocks,
    +});
    +

    {{icon:check}}With the hosted Deco CMS (the token above), a commit is live everywhere within about five minutes, no deploy. Without it, content ships with each deploy.

    +
    +
    +
    +
    +
    +
    + + + +
    +
    +

    Start with one function. Ship it from Git.

    + +

    The quickstart runs in plain Node, no framework. Or read the API reference · Under the hood.

    +
    +
    + + +
    diff --git a/site/src/roadmap.css b/site/src/roadmap.css new file mode 100644 index 00000000..96378ee8 --- /dev/null +++ b/site/src/roadmap.css @@ -0,0 +1,311 @@ +/* ========================================================================== + Roadmap page (the next major's to-do list, rendered from data/roadmap.json by gen_roadmap.py). + Classes are gx-* (and rm-* for the to-do parts); statuses are data-v codes: + g to build · p to finish · a site code · c done · n goes away. + Done is the only status in lime/forest, and nothing else on this page uses + those colours: plan rows, the filter's pressed state and the target flash + are neutral. Every to-do is open, so every box is drawn unchecked. + ========================================================================== */ +:root { + --gx-g: #D9432F; --gx-g-bg: #FDECE8; --gx-g-fg: #9E2B18; + --gx-p: #E0A106; --gx-p-bg: #FDF3D6; --gx-p-fg: #7A5200; + --gx-a: #2F7BD4; --gx-a-bg: #E7F0FB; --gx-a-fg: #1D5796; + --gx-c: #8CAA25; --gx-c-bg: #EFF6CC; --gx-c-fg: #07401A; + --gx-n: #8B7BF0; --gx-n-bg: #EFECFE; --gx-n-fg: #4A3AA7; + --gx-now-bg: #9E2B18; --gx-now-fg: #FFFFFF; + --gx-track: #EFEDEA; +} +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --gx-g: #F07A64; --gx-g-bg: rgba(240, 122, 100, .15); --gx-g-fg: #FFAB98; + --gx-p: #E9B949; --gx-p-bg: rgba(242, 193, 78, .14); --gx-p-fg: #F5CD6E; + --gx-a: #6FA8E8; --gx-a-bg: rgba(111, 168, 232, .15); --gx-a-fg: #A9CBF2; + --gx-c: #C3DD1F; --gx-c-bg: rgba(208, 236, 26, .12); --gx-c-fg: #D9F05A; + --gx-n: #A595FF; --gx-n-bg: rgba(165, 149, 255, .16); --gx-n-fg: #C8BEFF; + --gx-now-bg: #FFAB98; --gx-now-fg: #2A120C; + --gx-track: rgba(255, 255, 255, .07); + } +} +:root[data-theme="dark"] { + --gx-g: #F07A64; --gx-g-bg: rgba(240, 122, 100, .15); --gx-g-fg: #FFAB98; + --gx-p: #E9B949; --gx-p-bg: rgba(242, 193, 78, .14); --gx-p-fg: #F5CD6E; + --gx-a: #6FA8E8; --gx-a-bg: rgba(111, 168, 232, .15); --gx-a-fg: #A9CBF2; + --gx-c: #C3DD1F; --gx-c-bg: rgba(208, 236, 26, .12); --gx-c-fg: #D9F05A; + --gx-n: #A595FF; --gx-n-bg: rgba(165, 149, 255, .16); --gx-n-fg: #C8BEFF; + --gx-now-bg: #FFAB98; --gx-now-fg: #2A120C; + --gx-track: rgba(255, 255, 255, .07); +} + +/* Anything carrying a verdict exposes its colours to its children. */ +.gx-sec [data-v="g"] { --vc: var(--gx-g); --vbg: var(--gx-g-bg); --vfg: var(--gx-g-fg); } +.gx-sec [data-v="p"] { --vc: var(--gx-p); --vbg: var(--gx-p-bg); --vfg: var(--gx-p-fg); } +.gx-sec [data-v="a"] { --vc: var(--gx-a); --vbg: var(--gx-a-bg); --vfg: var(--gx-a-fg); } +.gx-sec [data-v="c"] { --vc: var(--gx-c); --vbg: var(--gx-c-bg); --vfg: var(--gx-c-fg); } +.gx-sec [data-v="n"] { --vc: var(--gx-n); --vbg: var(--gx-n-bg); --vfg: var(--gx-n-fg); } + +.gx-sec { container-type: inline-size; } +.gx-sec ul.gx-tiles, .gx-sec ul.gx-fx { list-style: none; margin-left: 0; padding: 0; max-width: none; } +.gx-sec :is(.gx-tiles, .gx-fx) > li { margin: 0; padding-left: 0; } +.gx-sec :is(.gx-tiles, .gx-fx) > li::marker { content: none; } +.gx-sec .gx-note { font-size: 14px; line-height: 22px; color: var(--muted-fg); margin: 20px 0 12px; } +.gx-sec .gx-meta-k, .gx-sec .gx-wf dt { + font-size: 12px; line-height: 18px; font-weight: 400; letter-spacing: .04em; text-transform: uppercase; color: var(--eyebrow); +} + +/* Verdict pill and legend */ +.gx-vp { + display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 9px 0 8px; flex: none; + border-radius: 999px; background: var(--vbg); color: var(--vfg); + font-size: 12px; line-height: 16px; font-weight: 500; letter-spacing: .01em; white-space: nowrap; +} +.gx-vp::before, .gx-lg::before, .gx-fg button i { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--vc); flex: none; } +.gx-lg { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } +.gx-lg b { font-weight: 500; color: var(--fg); font-variant-numeric: tabular-nums; } +.gx-key { display: inline-flex; flex-wrap: wrap; gap: 2px 14px; margin-left: 4px; font-size: 13.5px; } + +/* Feature chips (links to rows of the feature index; filled in by the page script) */ +.gx-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 16px 0 0; } +.gx-chips:empty { display: none; } +.gx-chips::before { content: "Features"; margin-right: 6px; font-size: 12px; line-height: 18px; letter-spacing: .04em; text-transform: uppercase; color: var(--eyebrow); } +.gx-sec .gx-chips a:not(.heading-anchor) { + display: inline-flex; align-items: center; gap: 7px; min-height: 28px; max-width: 100%; padding: 4px 12px 4px 10px; + border: 1px solid var(--border); border-radius: 999px; background: var(--surface); + color: var(--fg-body); font-size: 13px; line-height: 18px; text-decoration: none; + transition: border-color .25s ease, color .25s ease, background-color .25s ease; +} +.gx-sec .gx-chips a:not(.heading-anchor)::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--vc, var(--faint)); flex: none; } +.gx-sec .gx-chips a:not(.heading-anchor):hover { border-color: var(--border-strong); color: var(--fg); background: var(--bg-subtle); } +/* The to-build marker is a diamond, every other status's a dot, so red and amber never differ by colour alone. */ +.gx-sec :is(.gx-chips a[data-v="g"]:not(.heading-anchor), .gx-lg[data-v="g"], [data-v="g"] .gx-vp, .gx-vp[data-v="g"])::before, .gx-fg i[data-v="g"] { border-radius: 1px; transform: rotate(45deg) scale(.9); } + +/* Numbered headings: the number is a quiet tabular index, in the article and in the rail. In the + article the number and the title sit side by side, so a wrapped title hangs under itself. */ +.gx-n { display: inline-block; min-width: 1.6em; margin-right: .35em; font-size: .56em; line-height: 1; vertical-align: .22em; font-variant-numeric: tabular-nums; letter-spacing: 0; color: var(--eyebrow); } +.gx-sec h3:has(> .gx-n) { display: flex; align-items: baseline; } +.gx-sec h3 > .gx-n { flex: none; margin-right: .6em; position: relative; top: -.39em; } +#rail-list .gx-n, .toc-inline .gx-n { min-width: 22px; margin: 0; font-size: 11.5px; vertical-align: 0; text-indent: 0; color: var(--quiet); } +#rail-list a:has(> .gx-n) { padding-left: 38px; text-indent: -22px; } +.doc-section .toc-inline li a:has(> .gx-n) { padding-left: 22px; text-indent: -22px; } + +/* To-dos. The unchecked box is drawn here (decorative); each item's first child, .rm-sr, says + "To do" to screen readers. The same box marks the overview's blocker list, its two to-do + callouts, and (small) the count in each section's eyebrow. */ +.rm-sr { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } +.gx-sec .rm-list > li > h3::before, .gx-sec ol.gx-toplist > li::after, .gx-sec .rm-do::before, .rm-count::before { + content: ""; box-sizing: border-box; width: 18px; height: 18px; flex: none; + border: 1.5px solid var(--quiet); border-radius: 5px; background: var(--surface); + -webkit-mask: none; mask: none; +} +/* One item: box, (number,) title; everything below hangs under the title's column. A note with + nothing to do (.rm-note) gets a short rule in the box's place instead of a box. */ +.gx-sec .rm-list { list-style: none; max-width: none; margin: 0; padding: 0; border: 0; } +.gx-sec .rm-list > li { position: relative; margin: 48px 0 0; padding: 30px 0 0 34px; border-top: 1px solid var(--hairline); } +.gx-sec .rm-list > li::before { content: none; } +.gx-sec ul.rm-list > li::marker { content: none; } +.gx-sec .rm-list > li > h3 { display: flex; align-items: baseline; margin: 0 0 12px -34px; font-size: 23px; line-height: 1.28; } +.gx-sec .rm-list > li > h3 > span:not(.gx-n) { min-width: 0; } +.gx-sec .rm-list > li > h3::before { align-self: flex-start; margin: calc(.5lh - 9px) 16px 0 0; } +.gx-sec .rm-list > li.rm-note > h3::before { border: 0; border-radius: 0; background: linear-gradient(var(--quiet), var(--quiet)) center / 10px 1.5px no-repeat; } +/* Each section's count sits beside its eyebrow. */ +.gx-sec > .eyebrow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; } +.rm-count { + display: inline-flex; align-items: center; gap: 7px; height: 22px; padding: 0 10px 0 8px; border-radius: 999px; + box-shadow: inset 0 0 0 1px var(--border-strong); color: var(--muted-fg); + font-size: 12px; line-height: 16px; letter-spacing: .01em; text-transform: none; font-variant-numeric: tabular-nums; white-space: nowrap; +} +.rm-count::before { width: 10px; height: 10px; border-width: 1.25px; border-radius: 3px; } +.gx-sec .gx-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; margin: 0 0 12px; font-size: 13px; line-height: 20px; color: var(--muted-fg); } +.gx-sec .gx-meta + p { margin-top: 0; } +.gx-sec .gx-meta a { font-size: 13px; } +.gx-meta-n { color: var(--fg-body); } +.gx-st { color: var(--muted-fg); } +.gx-meta .gx-st::before { content: "Sites"; margin-right: 8px; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--eyebrow); } + +/* Overview: the two to-do callouts, one shape: the box in the icon's place, an imperative title + that links to the item, then the text. "Fix the docs" keeps the warning colours; the fix-now + item is neutral, with the site plans' "Fix now" pill leading its text. Same box model as .callout. */ +.gx-sec .rm-do::before { position: absolute; left: 20px; top: 19px; } +.rm-do-t { display: block; width: fit-content; margin-bottom: 4px; font-weight: 500; } +.gx-sec .gx-now { position: relative; margin: 28px 0; padding: 16px 22px 16px 50px; border-radius: 14px; background: var(--bg-subtle); border: 1px solid var(--border); } +.gx-sec .gx-now p { margin: 0; max-width: none; font-size: 15px; line-height: 25px; } +.gx-now .gx-kind { margin-right: 8px; vertical-align: 1px; } + +/* Overview: verdict tiles (the legend and the totals in one) */ +.gx-sec .gx-tiles { + display: grid; grid-template-columns: minmax(0, 1fr); gap: 1px; margin: 24px 0 8px; + background: var(--border); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; +} +.gx-sec .gx-tiles > li { display: grid; grid-template-columns: 64px minmax(0, 1fr); column-gap: 12px; align-items: center; padding: 14px 18px; background: var(--bg); } +.gx-tile-n { font-size: 34px; line-height: 1; font-weight: 300; letter-spacing: -.03em; font-variant-numeric: tabular-nums; color: var(--fg); } +.gx-tiles .gx-vp { justify-self: start; } +.gx-tiles > li[data-v="c"] .gx-tile-n { color: var(--quiet); } +/* Each tile's short definition, in place of a separate legend. */ +.gx-tile-d { grid-column: 2; margin-top: 6px; font-size: 13px; line-height: 18px; color: var(--muted-fg); } +@container (min-width: 640px) { + .gx-sec .gx-tiles { grid-template-columns: repeat(5, minmax(0, 1fr)); } + .gx-sec .gx-tiles > li { display: flex; flex-direction: column; align-items: flex-start; gap: 14px; padding: 20px 16px 20px; } + .gx-tile-n { font-size: 44px; } + .gx-tile-d { margin-top: -4px; } +} + +/* Verdict bars, overall and per site */ +.gx-bars { display: grid; gap: 20px; margin: 28px 0 32px; } +.gx-barrow { display: grid; grid-template-columns: minmax(0, 1fr); row-gap: 8px; } +.gx-sec .gx-bar-h { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 2px 12px; margin: 0; font-size: 14px; line-height: 20px; color: var(--fg); } +.gx-bar-h b { font-weight: 500; } +.gx-sec .gx-bar-h a:not(.heading-anchor) { font-size: 14px; } +.gx-bar-h span { font-size: 12.5px; color: var(--muted-fg); } +.gx-bar { display: flex; gap: 2px; height: 12px; border-radius: 999px; overflow: hidden; background: var(--gx-track); } +.gx-bar i { flex: var(--n) 0 0; min-width: 4px; background: var(--vc); } +.gx-barrow:first-child .gx-bar { height: 16px; } +.gx-sec .gx-bar-l { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; margin: 0; font-size: 12.5px; line-height: 20px; color: var(--muted-fg); } +.gx-ow { margin-left: auto; } +.gx-sec .gx-bar-d { margin: 2px 0 0; max-width: none; font-size: 14px; line-height: 1.6; color: var(--fg-body); } +.gx-bar-d > span { color: var(--muted-fg); } +.gx-bar-d code { font-size: 12.5px; } +.gx-ow b { font-weight: 500; color: var(--fg-body); } +@container (min-width: 640px) { + .gx-barrow { grid-template-columns: 200px minmax(0, 1fr); grid-template-areas: "h bar" "h leg" "h d"; column-gap: 24px; } + .gx-bar-d { grid-area: d; } + .gx-bar-h { grid-area: h; flex-direction: column; justify-content: flex-start; align-self: start; } + .gx-bar { grid-area: bar; align-self: center; margin-top: 4px; } + .gx-bar-l { grid-area: leg; } +} + +/* The status legend on Feature readiness (final copy): one status per line, label (with its marker) then meaning */ +.gx-sec dl.rm-legend { display: grid; gap: 4px; margin: 20px 0 0; font-size: 14px; line-height: 22px; } +.rm-legend > div { display: grid; grid-template-columns: 7.5rem minmax(0, 1fr); column-gap: 12px; } +.rm-legend dt { margin: 0; } +.rm-legend dd { margin: 0; color: var(--muted-fg); } +.rm-legend .gx-lg { display: inline-flex; font-weight: 500; color: var(--fg); } +@container (max-width: 459px) { + .rm-legend > div { grid-template-columns: minmax(0, 1fr); } + .gx-sec dl.rm-legend { gap: 8px; } +} + +/* Overview: the ten release blockers (numbered hairline rows from the docs'
      , each with its box) */ +.gx-sec ol.gx-toplist > li { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 16px; padding-left: 78px; } +.gx-sec ol.gx-toplist > li::before { left: 36px; } +.gx-sec ol.gx-toplist > li::after { position: absolute; left: 2px; top: 19px; } +.gx-sec .gx-toplist a:not(.heading-anchor) { font-weight: 500; } +.gx-tl-s { flex: none; font-size: 12.5px; line-height: 20px; color: var(--muted-fg); font-variant-numeric: tabular-nums; } + +/* A to-do's detail: Today (where it stands now) on the plain surface, then Plan and the link + rows (Delivered by, In these docs, Part of blocker) together on a neutral fill. No overflow + clipping, so focus rings on the links inside stay whole; the rows round their own corners. */ +.gx-sec dl.gx-wf { margin: 18px 0 0; border: 1px solid var(--border); border-radius: 14px; background: var(--muted); } +.gx-wf > div { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; padding: 16px 18px; } +.gx-wf > div:first-child { border-radius: 13px 13px 0 0; } +.gx-wf > div:last-child { border-radius: 0 0 13px 13px; } +.gx-wf > div:only-child { border-radius: 13px; } +.gx-wf > .rm-t { background: var(--surface); } +.gx-wf > .rm-t + div { border-top: 1px solid var(--hairline); } +.gx-wf > div:not(.rm-t) + div { padding-top: 0; } +.gx-sec .gx-wf dt { font-size: 12px; line-height: 18px; } +.gx-sec .gx-wf dd { margin: 0; font-size: 15px; line-height: 1.65; color: var(--fg-body); } +.gx-sec .gx-wf dd > :is(p, ul) { margin: 0; max-width: none; font-size: inherit; line-height: inherit; } +.gx-sec .gx-wf dd > * + * { margin-top: 10px; } +.gx-sec .gx-wf dd li { font-size: inherit; line-height: inherit; } +@container (min-width: 600px) { + .gx-wf > div { grid-template-columns: 124px minmax(0, 1fr); column-gap: 20px; padding: 16px 20px; } + .gx-wf > div:not(.rm-t) + div { padding-top: 0; } + .gx-sec .gx-wf dt { line-height: 25px; } +} + +/* Site plans */ +.gx-kind { display: inline-flex; align-items: center; height: 22px; padding: 0 10px; border-radius: 999px; background: var(--muted); color: var(--fg-body); font-size: 12px; line-height: 16px; font-weight: 500; letter-spacing: .01em; white-space: nowrap; } +.gx-kind[data-k="now"] { background: var(--gx-now-bg); color: var(--gx-now-fg); } +.gx-kind[data-k="pre"] { background: var(--gx-a-bg); color: var(--gx-a-fg); } +.gx-kind[data-k="blocker"] { background: var(--gx-g-bg); color: var(--gx-g-fg); } +.gx-kind[data-k="content"] { background: var(--gx-p-bg); color: var(--gx-p-fg); } +.gx-kind[data-k="fix"] { background: transparent; box-shadow: inset 0 0 0 1px var(--border-strong); color: var(--muted-fg); } +.gx-sec .gx-sitedesc { margin: 0 0 8px; font-size: 15px; line-height: 1.6; color: var(--muted-fg); } +.gx-sitedesc .gx-meta-k { margin-right: 10px; } + +/* Links in a run ("Delivered by", "In these docs", "Part of blocker", "Addressed by"): set apart by + space alone, since a separator glyph either gets underlined or can start a wrapped line. A link to + a site step carries its site and number beside it; a run may end with a plain note. */ +.gx-sec :is(.gx-wf dd.rm-links, .gx-fx-d, .gx-fx-c) > :is(a, .rm-sref):not(:last-child) { margin-right: .85em; } +.rm-ref { font-size: 13.5px; color: var(--muted-fg); } +.rm-dnote { display: block; margin-top: 4px; font-size: 14px; color: var(--muted-fg); } + +/* Feature index: filter */ +.gx-filter { display: flex; flex-direction: column; gap: 10px; margin: 28px 0 8px; padding: 14px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-subtle); } +.gx-fg { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } +.gx-fg + .gx-fg { padding-top: 10px; border-top: 1px solid var(--hairline); } +.gx-fl { min-width: 56px; margin-right: 4px; font-size: 12px; line-height: 18px; letter-spacing: .04em; text-transform: uppercase; color: var(--eyebrow); } +.gx-fg button { + display: inline-flex; align-items: center; gap: 7px; height: 32px; padding: 0 13px; + border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--muted-fg); + font: inherit; font-size: 13px; line-height: 18px; cursor: pointer; + transition: color .25s ease, border-color .25s ease, background-color .25s ease, transform .25s var(--ease); +} +.gx-fg button span { color: var(--quiet); font-variant-numeric: tabular-nums; } +.gx-fg button:hover { color: var(--fg); border-color: var(--border-strong); } +.gx-fg button:active { transform: scale(.97); } +/* Pressed is neutral ink, not the site's forest/lime pill: on this page lime/forest means Done. */ +.gx-fg button[aria-pressed="true"] { background: var(--fg); border-color: transparent; color: var(--bg); } +.gx-fg button[aria-pressed="true"] span { color: inherit; opacity: .8; } +.gx-sec .gx-fcount { margin: 0; font-size: 13px; line-height: 20px; color: var(--muted-fg); } + +/* Feature index: categories and rows */ +.gx-index h3 { font-size: 25px; margin-top: 56px; } +.gx-sec .gx-catd { margin: 0 0 16px; font-size: 14px; line-height: 22px; color: var(--muted-fg); } +.gx-catc { display: flex; flex-wrap: wrap; gap: 2px 14px; margin-top: 6px; font-size: 12.5px; } +/* ul. outranks the list reset above (margin-left: 0), so rows bleed evenly and their text lines up with the column. */ +.gx-sec ul.gx-fx { margin: 0 -12px; border-top: 1px solid var(--hairline); } +.gx-sec .gx-fx > li { + display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; padding: 18px 12px 20px; + border-bottom: 1px solid var(--hairline); color: var(--fg-body); transition: background-color .3s ease; +} +.gx-sec .gx-fx > li + li { margin-top: 0; } +.gx-sec .gx-fx h4 { margin: 0; font-size: 16px; line-height: 23px; font-weight: 500; letter-spacing: -.01em; color: var(--fg); } +.gx-sec .gx-fx p { margin: 0; max-width: none; } +.gx-sec .gx-fx .gx-fx-m { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 12.5px; line-height: 20px; color: var(--muted-fg); } +.gx-tg { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--border-strong); font-size: 11.5px; font-style: normal; line-height: 16px; color: var(--muted-fg); white-space: nowrap; } +.gx-sec .gx-fx > li > div > p:first-child { font-size: 15px; line-height: 1.62; color: var(--fg-body); } +.gx-sec .gx-fx > li > div > p:first-child code { font-size: 12.5px; } +/* Where these docs cover the feature, and what addresses it; the labels are CSS so each row stays small. */ +.gx-sec .gx-fx :is(.gx-fx-d, .gx-fx-c) { margin-top: 8px; font-size: 13px; line-height: 21px; color: var(--muted-fg); } +.gx-sec .gx-fx :is(.gx-fx-d, .gx-fx-c) a:not(.heading-anchor) { font-size: 13px; } +.gx-fx-d::before, .gx-fx-c::before { content: "In these docs"; margin-right: 10px; font-size: 11.5px; letter-spacing: .04em; text-transform: uppercase; color: var(--eyebrow); } +.gx-fx-c::before { content: "Addressed by"; } +.gx-sec .gx-fx .gx-fx-c { margin-top: 2px; } +@container (min-width: 640px) { + .gx-sec .gx-fx > li { grid-template-columns: minmax(0, 14.5rem) minmax(0, 1fr); grid-template-rows: auto 1fr; grid-template-areas: "n s" "m s"; column-gap: 28px; } + .gx-sec .gx-fx h4 { grid-area: n; } + .gx-sec .gx-fx .gx-fx-m { grid-area: m; align-self: start; } + .gx-sec .gx-fx > li > div { grid-area: s; align-self: start; } +} + +@media (max-width: 767px) { + .gx-sec .rm-list > li { margin-top: 40px; padding: 26px 0 0 30px; } + .gx-sec .rm-list > li > h3 { margin-left: -30px; font-size: 21px; } + .gx-sec .rm-list > li > h3::before { margin-right: 12px; } +} +@media (max-width: 559px) { + /* The callouts' phone padding (style.css), for both to-do callouts and their boxes. */ + .gx-sec .gx-now { padding: 14px 16px 14px 44px; } + .gx-sec .rm-do::before { left: 15px; top: 17px; } + .gx-index h3 { font-size: 22px; margin-top: 44px; } + .gx-sec ul.gx-fx { margin: 0 -8px; } + .gx-sec .gx-fx > li { padding: 16px 8px 18px; } + .gx-filter { padding: 12px; } + .gx-fg button { height: 30px; padding: 0 11px; } +} + +@media print { + :root, :root:not([data-theme="light"]), :root[data-theme="dark"] { + --gx-g: #D9432F; --gx-g-bg: #FDECE8; --gx-g-fg: #9E2B18; + --gx-p: #E0A106; --gx-p-bg: #FDF3D6; --gx-p-fg: #7A5200; + --gx-a: #2F7BD4; --gx-a-bg: #E7F0FB; --gx-a-fg: #1D5796; + --gx-c: #8CAA25; --gx-c-bg: #EFF6CC; --gx-c-fg: #07401A; + --gx-n: #8B7BF0; --gx-n-bg: #EFECFE; --gx-n-fg: #4A3AA7; + --gx-now-bg: #9E2B18; --gx-now-fg: #FFFFFF; + --gx-track: #EFEDEA; + } + .gx-filter { display: none !important; } + .gx-fx > li, .gx-wf, .gx-tiles, .gx-barrow, .gx-now { break-inside: avoid; } + .gx-sec .rm-list > li > h3 { break-after: avoid; } + .gx-vp, .gx-kind, .gx-bar i, .gx-wf, .gx-wf > div { -webkit-print-color-adjust: exact; print-color-adjust: exact; } +} diff --git a/site/src/shell.html b/site/src/shell.html new file mode 100644 index 00000000..911154e9 --- /dev/null +++ b/site/src/shell.html @@ -0,0 +1,144 @@ + + + + + + + + + Deco Blocks — Documentation + + + + + + + + + + + + + {{cobogo}} + + + + + +
      + + +
      +
      +

      Deco Blocks documentation

      +
      + +
      + + +
      +
      +
      + {{icon:list}}On this page{{icon:chevron-down}} +
        +
        +
        +
        +{{landing}} +
        +
        +{{docs}} +
        +
        +{{internals}} +
        +
        +{{roadmap}} +
        + +
        + Deco Blocks · Next major + +
        +
        + + +
        + + {{site_footer}} + + +
        {{icon:search}}
        +
        +

        +
        ↑↓ to move↵ to openSearches all documentation, including code examples.
        +
        + +

        + +{{prism}} + + + diff --git a/site/src/style.css b/site/src/style.css new file mode 100644 index 00000000..1e973a7c --- /dev/null +++ b/site/src/style.css @@ -0,0 +1,1532 @@ +/* ========================================================================== + Deco Blocks docs + landing, in the decocms.com visual language: + Switzer (light display, tight tracking), forest + lime, warm paper and + hairlines, pill buttons, olive uppercase eyebrows, cobogó texture, + browser-window mocks, gentle entrance motion. + ========================================================================== */ +:root { + --font-sans: "Switzer", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --ease: cubic-bezier(.25, 1, .5, 1); + --ease-slow: cubic-bezier(.16, 1, .3, 1); + + /* decocms.com brand */ + --lime: #D0EC1A; + --lime-2: #DCEF63; + --lime-3: #C3DD1F; + --forest: #07401A; + --olive: #5E7500; + --olive-ink: #455600; + --olive-ring: #8CAA25; + --brand: #D0EC1A; + --brand-hover: #DCEF63; + --brand-ink: #07401A; + --band-fg: #FFFFFF; + --band-muted: rgba(255, 255, 255, .76); + --band-subtle: rgba(255, 255, 255, .62); + --band-line: rgba(255, 255, 255, .14); + --band-fill: rgba(255, 255, 255, .07); + --purple: #A595FF; + --yellow: #FFC116; + + /* Paper, ink, hairlines */ + --bg: #FFFFFF; + --bg-subtle: #FAFAF9; + --bg-warm: #F9F6F2; + --surface: #FFFFFF; + --muted: #F4F2EF; + --hover: #EFEAE2; + --fg: #282524; + --fg-body: #3B3633; + --muted-fg: #6E6863; + --faint: #8C8680; + --quiet: #78726E; + --num-faint: #78726E; /* the site's faint index, lifted to 4.7:1 so it reads as text */ + --ol-num: #6E6863; /* docs step numbers: readable (5.3:1), still quieter than body */ + --who-fg: #455600; /* speaker tag in the agent chat mock */ + --mark-bg: rgba(208, 236, 26, .6); /* search-result match highlight */ + --mark-fg: #282524; + --hdr-pill: rgba(24, 21, 19, .8); /* the landing's scrolled header pill */ + --step-on: #07401A; + --stack-fg: rgba(40, 37, 36, .64); + --footer-bg: #FAF9F7; + --border: rgba(40, 37, 36, .10); + --border-strong: rgba(40, 37, 36, .18); + --hairline: rgba(40, 37, 36, .09); + --eyebrow: #5E7500; + --link: #07401A; + --link-line: #8CAA25; + --accent-text: #5E7500; + --nav-active-bg: #EFF6CC; + --nav-active-fg: #07401A; + --nav-bar: #07401A; + --pill-on-bg: #07401A; + --pill-on-fg: #D0EC1A; + --pill-on-ring: transparent; + --ring: #5E7500; + --indicator-ring: rgba(7, 64, 26, .35); + --tint: #EFF6CC; + --tint-fg: #07401A; + + --code-bg: #FAFAF9; + --code-inline: #F2F0ED; + --code-head: #F5F3F0; + --code-border: rgba(40, 37, 36, .09); + --code-fg: #2E2A27; + --code-sel: rgba(208, 236, 26, .5); + + --note-bg: #FAFAF9; + --warn-bg: #FFF8E6; + --warn-border: #F1DC9E; + --warn-icon: #8F6600; + --preview-bg: #EFF6CC; + --preview-border: rgba(140, 170, 37, .38); + --del-bg: rgba(214, 64, 40, .08); + --del-fg: #A3341F; + --add-bg: #EFF6CC; + --th-bg: #FAFAF9; + + --st-bg: #FFFFFF; + --st-fg: #282524; + --st-muted: #6E6863; + --st-border: rgba(40, 37, 36, .09); + --st-field: #FAFAF9; + --st-track: #E7E5E4; + --st-fill: #07401A; + + --win-bg: #F9F6F2; + --win-panel: #FFFFFF; + --win-bar: #FAFAF9; + --win-dot: #E7E5E4; + + --shadow-sm: 0 1px 2px rgba(40, 37, 36, .05); + --shadow-md: 0 1px 2px rgba(40, 37, 36, .04), 0 12px 32px -16px rgba(40, 37, 36, .2); + --shadow-win: 0 50px 110px -36px rgba(14, 26, 16, .35); + --shadow-lg: 0 0 0 1px rgba(40, 37, 36, .06), 0 24px 64px -16px rgba(40, 37, 36, .32); + + /* The site's tri-colour numerals (olive, purple, blue) on warm ink */ + --syn-comment: #78726E; + --syn-keyword: #5A4AC7; + --syn-string: #5E7500; + --syn-type: #31699F; + --syn-function: #31699F; + --syn-number: #9A5B00; + --syn-property: #282524; + --syn-punctuation: #78726E; + --syn-operator: #78726E; + --syn-tag: #A3341F; + + --wm-light: inline-block; + --wm-dark: none; + --icon-sun: none; + --icon-moon: block; + + --header-h: 64px; + --hdr-max: 1440px; + --radius: 12px; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { +{{dark_tokens}} + } +} +:root[data-theme="dark"] { +{{dark_tokens}} +} + +/* ---------- Base ---------- */ +.sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } +*, *::before, *::after { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; scroll-padding-top: calc(var(--header-h) + 24px); } +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + font-size: 16px; + font-weight: 400; + line-height: 1.7; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; + overflow-x: hidden; +} +@supports (overflow: clip) { body { overflow-x: clip; } } +img, svg { display: block; } +button, input, select, textarea { font: inherit; color: inherit; } +button { cursor: pointer; } +button, a, input { touch-action: manipulation; } +a { color: inherit; } +[hidden] { display: none !important; } +::selection { background: var(--code-sel); color: var(--fg); } +:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; } +.mono { font-family: var(--font-mono); } +.icon { width: 16px; height: 16px; flex: none; } +.svg-defs { position: absolute; width: 0; height: 0; overflow: hidden; pointer-events: none; } +.skip { position: fixed; left: 16px; top: -64px; z-index: 100; height: 40px; display: inline-flex; align-items: center; padding: 0 18px; border-radius: 999px; background: var(--brand); color: var(--brand-ink); font-weight: 500; font-size: 14px; text-decoration: none; transition: top .2s var(--ease); } +.skip:focus { top: 12px; } + +/* ---------- Header ---------- */ +.site-header { + position: sticky; top: 0; z-index: 50; + height: var(--header-h); + background: color-mix(in oklab, var(--bg) 86%, transparent); + -webkit-backdrop-filter: saturate(1.4) blur(16px); + backdrop-filter: saturate(1.4) blur(16px); + border-bottom: 1px solid var(--hairline); + transition: background-color .5s var(--ease), border-color .5s var(--ease); +} +body[data-layout="landing"] .site-header { position: fixed; left: 0; right: 0; } +body[data-layout="landing"] { --hdr-max: 1360px; } +.hdr-inner { position: relative; height: 100%; max-width: var(--hdr-max); margin: 0 auto; padding: 0 40px; display: flex; align-items: center; gap: 8px; } +body[data-layout="docs"] .hdr-inner { padding: 0 24px; } +.logo { display: inline-flex; align-items: center; gap: 8px; height: 40px; color: var(--fg); text-decoration: none; border-radius: 8px; flex: none; } +.logo:hover .wm { opacity: .8; } +.logo .wm { height: 22px; width: auto; transition: opacity .3s ease; } +.logo .wm-light { display: var(--wm-light); } +.logo .wm-dark { display: var(--wm-dark); } +.logo .slash { width: 12px; height: 24px; color: var(--border-strong); } +.logo .product { font-size: 15px; font-weight: 500; letter-spacing: -.01em; line-height: 24px; } +.tabs { display: flex; align-items: center; gap: 4px; margin-left: 28px; } +.tabs a, .drawer-tabs a { + height: 34px; padding: 0 14px; display: inline-flex; align-items: center; + border-radius: 999px; font-size: 14px; font-weight: 400; line-height: 20px; + color: var(--muted-fg); text-decoration: none; white-space: nowrap; + transition: color .35s ease, background-color .35s ease; +} +.tabs a:hover, .drawer-tabs a:hover { color: var(--fg); } +.tabs a[aria-current="page"], .drawer-tabs a[aria-current="page"] { background: var(--pill-on-bg); color: var(--pill-on-fg); box-shadow: inset 0 0 0 1px var(--pill-on-ring); } +.hdr-actions { margin-left: auto; display: flex; align-items: center; gap: 4px; } +.search-trigger { + width: 240px; height: 38px; margin-right: 8px; + display: flex; align-items: center; gap: 10px; padding: 0 6px 0 14px; + border: 1px solid var(--border); border-radius: 999px; background: var(--bg-subtle); + color: var(--muted-fg); font-size: 14px; line-height: 20px; text-align: left; + transition: border-color .3s ease, color .3s ease, background-color .3s ease; +} +.search-trigger:hover { border-color: var(--border-strong); color: var(--fg); } +.search-trigger .st-label { flex: 1; } +kbd { font-family: var(--font-mono); font-size: 11px; font-weight: 500; line-height: 16px; } +.search-trigger kbd { height: 26px; display: inline-flex; align-items: center; padding: 0 9px; border-radius: 999px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--border); color: var(--muted-fg); } +.icon-btn { + width: 38px; height: 38px; display: inline-grid; place-items: center; flex: none; + border: 0; border-radius: 999px; background: transparent; + color: var(--muted-fg); text-decoration: none; transition: color .3s ease, background-color .3s ease; +} +.icon-btn:hover { color: var(--fg); background: var(--muted); } +.icon-btn .icon { width: 18px; height: 18px; } +.theme-btn .i-sun { display: var(--icon-sun); } +.theme-btn .i-moon { display: var(--icon-moon); } +.menu-btn { display: none; } +.hdr-cta { display: none; height: 40px; margin-left: 0; padding: 0 24px; align-items: center; border-radius: 999px; background: var(--pill-on-bg); color: var(--pill-on-fg); font-size: 14px; font-weight: 500; text-decoration: none; white-space: nowrap; transition: opacity .35s ease, transform .25s var(--ease), background-color .35s ease, color .35s ease; } +.hdr-cta:hover { opacity: .9; } +.hdr-cta:active { transform: scale(.97); } +.gh-label { display: none; } +body[data-layout="landing"] .hdr-cta { display: inline-flex; } +/* Landing: the site's sparse right cluster. Search is an icon (⌘K in its tooltip), GitHub a text link. */ +@media (min-width: 900px) { + body[data-layout="landing"] .hdr-actions { gap: 24px; } + body[data-layout="landing"] .hdr-actions .icon-btn, + body[data-layout="landing"] .search-trigger { margin: 0 -10px; } + body[data-layout="landing"] .search-trigger { width: 38px; height: 38px; padding: 0; justify-content: center; border-color: transparent; background: transparent; } + body[data-layout="landing"] .search-trigger .st-label, body[data-layout="landing"] .search-trigger kbd { display: none; } + body[data-layout="landing"] .search-trigger .icon { width: 18px; height: 18px; } + body[data-layout="landing"] .hdr-actions .gh-link { width: auto; height: 40px; margin: 0; padding: 0; border-radius: 4px; font-size: 14px; line-height: 20px; } + body[data-layout="landing"] .gh-link .icon { display: none; } + body[data-layout="landing"] .gh-link .gh-label { display: inline; } +} +/* Just above the breakpoint, the in-flow tabs and the right cluster need a little room from each other. */ +@media (min-width: 860px) and (max-width: 979px) { + body[data-layout="landing"] .tabs { margin-left: 16px; } +} +@media (min-width: 900px) and (max-width: 979px) { + body[data-layout="landing"] .hdr-actions { gap: 16px; } +} +@media (min-width: 1100px) { + body[data-layout="landing"] .tabs { position: absolute; left: 50%; transform: translateX(-50%); margin: 0; } +} + +/* Header over the forest hero: transparent, lime wordmark, white nav, white pill */ +.site-header.on-band { background: transparent; border-bottom-color: transparent; -webkit-backdrop-filter: none; backdrop-filter: none; } +/* Landing: the site's floating header. Transparent at the top of the band, a dark + translucent pill (blur, soft wide shadow) once the page scrolls. */ +body[data-layout="landing"] .site-header { + top: 0; height: auto; padding: 24px 40px 0; pointer-events: none; + background: transparent; border-bottom: 0; -webkit-backdrop-filter: none; backdrop-filter: none; +} +body[data-layout="landing"] .hdr-inner { + pointer-events: auto; height: 72px; max-width: 1360px; padding: 0 16px 0 28px; border-radius: 999px; + transition: background-color .5s var(--ease), box-shadow .5s var(--ease), height .45s var(--ease); +} +body[data-layout="landing"] .site-header.is-scrolled .hdr-inner { + height: 60px; background: var(--hdr-pill); box-shadow: 0 24px 60px rgba(0, 0, 0, .3); + -webkit-backdrop-filter: blur(60px) saturate(1.2); backdrop-filter: blur(60px) saturate(1.2); +} +.site-header.on-band .logo, .site-header.on-band .icon-btn { color: rgba(255, 255, 255, .86); } +.site-header.on-band .logo .wm-light { display: none; } +.site-header.on-band .logo .wm-dark { display: inline-block; } +.site-header.on-band .logo .slash { color: rgba(255, 255, 255, .3); } +.site-header.on-band .logo .product { color: #fff; } +.site-header.on-band .tabs a { color: rgba(255, 255, 255, .82); } +.site-header.on-band .tabs a:hover { color: #fff; } +/* The current tab is marked by more than colour: a soft white pill, like the site's segmented tabs. */ +.site-header.on-band .tabs a[aria-current="page"] { color: #fff; background: rgba(255, 255, 255, .14); box-shadow: none; } +.site-header.on-band .icon-btn:hover { color: #fff; background: rgba(255, 255, 255, .1); } +.site-header.on-band .search-trigger { background: transparent; border-color: transparent; color: rgba(255, 255, 255, .86); } +.site-header.on-band .search-trigger:hover { background: rgba(255, 255, 255, .1); color: #fff; } +.site-header.on-band .gh-link { color: rgba(255, 255, 255, .82); } +.site-header.on-band .gh-link:hover { color: #fff; background: none; } +.site-header.on-band .search-trigger kbd { background: rgba(255, 255, 255, .1); box-shadow: none; color: rgba(255, 255, 255, .8); } +.site-header.on-band .hdr-cta { background: #fff; color: #282524; } +.site-header.on-band .hdr-cta:hover { background: #EEF1F2; opacity: 1; } +.site-header.on-band :focus-visible { outline-color: var(--brand); } + +/* ---------- Layout shell ---------- */ +.shell { position: relative; } +body[data-layout="docs"] .shell { + display: grid; + grid-template-columns: 280px minmax(0, 1fr) 248px; + max-width: var(--hdr-max); + margin: 0 auto; +} +body[data-layout="landing"] .rail, +body[data-layout="landing"] .doc-top, +body[data-layout="landing"] .pager, +body[data-layout="landing"] .doc-footer { display: none; } +main { display: block; min-width: 0; outline: none; } +body[data-layout="docs"] main { padding: 40px 56px 96px; } +body[data-layout="docs"] main > * { max-width: 768px; margin-left: auto; margin-right: auto; } +.js .page { display: none; } +.js .page.active { display: block; } +.js .page > section:not(.is-current) { display: none; } +body[data-layout="docs"] .page > section.is-current { animation: article-in .6s var(--ease-slow) both; } +@keyframes article-in { from { opacity: 0; transform: translateY(10px); } } +/* Jumping to a whole section always lands at the top of the article (breadcrumb visible). */ +main .page > section { scroll-margin-top: 400vh; } +/* Roadmap page: whatever its filter hides stays hidden, whatever display a row's own styles set, and + a feature row reached by a deep link flashes once so the eye finds it in the list. The flash is + neutral, not the lime tint: on that page lime/forest means "Done". */ +main .page[data-page="roadmap"] [hidden] { display: none !important; } +:where(main [id^="roadmap-f-"]):target { animation: roadmap-target 2.4s var(--ease); } +@keyframes roadmap-target { from, 35% { background-color: var(--hover); } } + +/* ---------- Sidebar ---------- */ +.sidebar { + position: sticky; top: var(--header-h); + height: calc(100vh - var(--header-h)); + height: calc(100dvh - var(--header-h)); + display: flex; flex-direction: column; + border-right: 1px solid var(--hairline); + overflow: hidden; +} +@media (min-width: 900px) { body[data-layout="landing"] .sidebar { display: none; } } +body[data-layout="docs"] .sidebar { background: var(--bg-subtle); } +#toc { flex: 1; overflow-y: auto; padding: 28px 16px 24px 20px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; overscroll-behavior: contain; scroll-padding: 32px 0 56px; --toc-t: 0px; --toc-b: 0px; } +/* The nav fades where it runs under the footer or the top edge, instead of slicing a row in half. */ +#toc.more-above { --toc-t: 28px; } +#toc.more-below { --toc-b: 48px; } +#toc:is(.more-above, .more-below) { + -webkit-mask-image: linear-gradient(to bottom, transparent, #000 var(--toc-t), #000 calc(100% - var(--toc-b)), transparent); + mask-image: linear-gradient(to bottom, transparent, #000 var(--toc-t), #000 calc(100% - var(--toc-b)), transparent); +} +.nav-group + .nav-group { margin-top: 26px; } +.nav-label { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; padding: 0 14px; font-size: 13px; line-height: 18px; font-weight: 400; letter-spacing: -.005em; text-transform: uppercase; color: var(--eyebrow); } +.nav-label .icon { display: none; } +.nav-list { list-style: none; margin: 0; padding: 0; } +.nav-list li { margin: 1px 0; } +#toc .nav-list a { + position: relative; display: flex; align-items: center; gap: 10px; min-height: 34px; padding: 6px 14px; + border-radius: 999px; font-size: 14px; line-height: 20px; color: var(--muted-fg); text-decoration: none; + transition: color .25s ease, background-color .25s ease; +} +#toc .nav-list a:hover { color: var(--fg); background: var(--hover); } +#toc .nav-list a.active { color: var(--nav-active-fg); background: var(--nav-active-bg); font-weight: 500; } +#toc .nav-list a.active::after { content: ""; width: 6px; height: 6px; margin-left: auto; border-radius: 50%; background: var(--nav-bar); flex: none; } +#toc .nav-list a:focus-visible { outline-offset: -2px; } +.sidebar-foot { flex: none; padding: 16px 24px 24px; border-top: 1px solid var(--hairline); } +.sidebar-foot p { margin: 8px 0 0; font-size: 12px; line-height: 17px; color: var(--muted-fg); } +.sf-badge, .np-badge { display: inline-flex; align-items: center; gap: 7px; height: 24px; padding: 0 11px 0 9px; border-radius: 999px; background: var(--brand); color: var(--brand-ink); font-size: 11px; line-height: 16px; font-weight: 500; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; } +.sf-badge .badge-dot, .bp-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--brand-ink); box-shadow: none; display: inline-block; flex: none; } +.badge-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--brand); flex: none; display: inline-block; } +/* The note's way to the Roadmap page, styled like an inline doc link; hidden on that page itself. */ +.sidebar-foot .sf-link { + color: var(--link); font-weight: 500; white-space: nowrap; text-decoration-line: underline; text-decoration-thickness: 1px; + text-decoration-color: var(--link-line); text-underline-offset: 3px; transition: text-decoration-color .25s ease; +} +.sidebar-foot .sf-link:hover { text-decoration-color: currentColor; } +body[data-page="roadmap"] .sidebar-foot .sf-link { display: none; } +.drawer-head, .drawer-tabs, .drawer-tools { display: none; } +/* The four page tabs in the drawer: a little tighter than the header's, so they share one row on + phones from 360px up (they wrap only on the narrowest screens). The pills' own padding spaces them. */ +.drawer-tabs { flex-wrap: wrap; gap: 4px 0; padding: 12px 8px; border-bottom: 1px solid var(--hairline); flex: none; } +.drawer-tabs a { padding: 0 10px; } +/* Mobile landing drawer: search, GitHub and theme move here from the header */ +.drawer-tools { gap: 8px; padding: 16px; border-bottom: 1px solid var(--hairline); flex: none; } +.drawer-search { height: 44px; display: flex; align-items: center; gap: 10px; padding: 0 16px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--muted-fg); font-size: 15px; line-height: 20px; text-align: left; transition: border-color .3s ease, color .3s ease; } +.drawer-search:hover { border-color: var(--border-strong); color: var(--fg); } +.drawer-search .icon { width: 17px; height: 17px; } +.drawer-row { display: flex; gap: 8px; } +.drawer-link { flex: 1; min-width: 0; height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--fg); font-size: 14px; line-height: 20px; text-decoration: none; white-space: nowrap; transition: background-color .3s ease, border-color .3s ease; } +.drawer-link:hover { background: var(--hover); border-color: transparent; } +.drawer-link .icon { width: 16px; height: 16px; } + +/* ---------- Article chrome ---------- */ +.doc-top { margin-bottom: 36px; } +.doc-top-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 32px; } +.page-tools { display: flex; align-items: center; gap: 6px; flex: none; } +.tool-btn { height: 32px; display: inline-flex; align-items: center; gap: 7px; padding: 0 14px 0 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--muted-fg); font-size: 13px; line-height: 16px; font-weight: 400; transition: color .3s ease, border-color .3s ease, background-color .3s ease, transform .25s var(--ease); } +.tool-btn:hover { color: var(--fg); border-color: var(--border-strong); } +.tool-btn:active { transform: scale(.97); } +.tool-btn .icon { width: 14px; height: 14px; } +.breadcrumb ol { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 0; list-style: none; margin: 0; padding: 0; font-size: 13px; line-height: 20px; color: var(--muted-fg); } +.breadcrumb li { display: inline-flex; align-items: center; } +.breadcrumb li + li::before { content: ""; width: 12px; height: 12px; margin: 0 8px; background: var(--faint); -webkit-mask: var(--chev) center / 12px no-repeat; mask: var(--chev) center / 12px no-repeat; flex: none; } +.breadcrumb a { color: var(--muted-fg); text-decoration: none; border-radius: 4px; transition: color .25s ease; } +.breadcrumb a:hover { color: var(--fg); } +.breadcrumb [aria-current] { color: var(--fg); } +:root { --chev: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E"); } +.toc-inline { display: none; margin: 28px 0 8px; padding: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-subtle); } +.toc-inline summary { list-style: none; display: flex; align-items: center; gap: 10px; height: 46px; padding: 0 16px; font-size: 13px; font-weight: 400; letter-spacing: -.005em; text-transform: uppercase; color: var(--eyebrow); cursor: pointer; border-radius: 16px; } +.toc-inline summary::-webkit-details-marker { display: none; } +.toc-inline summary span { flex: 1; } +.toc-inline summary .i-chevron-down { transition: transform .3s var(--ease); color: var(--muted-fg); } +.toc-inline[open] summary .i-chevron-down { transform: rotate(180deg); } +.toc-inline summary .i-list { color: var(--eyebrow); } +.toc-inline ul { list-style: none; margin: 0; padding: 4px 16px 12px 42px; border-top: 1px solid var(--hairline); } +.doc-section .toc-inline ul { max-width: none; margin: 0; } +.doc-section .toc-inline li { margin: 0; padding: 0; border: 0; } +.doc-section .toc-inline li::before { content: none; } +.doc-section .toc-inline li::marker { content: none; } +.doc-section .toc-inline li a { display: block; padding: 7px 0; font-size: 14px; line-height: 20px; font-weight: 400; color: var(--muted-fg); text-decoration: none; } +.doc-section .toc-inline li a:hover { color: var(--fg); } +.doc-section .toc-inline li.is-top a { color: var(--fg); } + +.pager { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 72px; padding-top: 32px; border-top: 1px solid var(--hairline); } +.pager a { display: flex; flex-direction: column; gap: 6px; min-width: 0; padding: 20px 24px 22px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); text-decoration: none; color: var(--fg); transition: border-color .4s var(--ease), box-shadow .5s var(--ease), transform .5s var(--ease); } +.pager a:hover { border-color: var(--border-strong); transform: translateY(-3px); box-shadow: 0 24px 48px -24px rgba(40, 37, 36, .28); } +.pager .pg-dir { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; line-height: 16px; letter-spacing: -.005em; text-transform: uppercase; color: var(--eyebrow); } +.pager .pg-dir .icon { width: 14px; height: 14px; } +.pager .pg-title { font-size: 21px; line-height: 28px; font-weight: 400; letter-spacing: -.015em; } +.pager .pg-sub { font-size: 13px; line-height: 20px; color: var(--muted-fg); } +.pager .pg-next { grid-column: 2; align-items: flex-end; text-align: right; } +.doc-footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 48px; font-size: 13px; line-height: 20px; color: var(--muted-fg); } +.back-top { display: inline-flex; align-items: center; gap: 6px; border: 0; background: none; padding: 4px 0; color: var(--muted-fg); font-size: 13px; transition: color .25s ease; } +.back-top:hover { color: var(--fg); } +.back-top .icon { width: 14px; height: 14px; } + +/* ---------- Right rail ---------- */ +.rail { position: sticky; top: var(--header-h); align-self: start; max-height: calc(100vh - var(--header-h)); overflow-y: auto; padding: 44px 24px 32px 8px; scrollbar-width: none; display: flex; flex-direction: column; } +/* A long outline scrolls on its own (the spy keeps the current entry in view), so the page tools below stay on screen. */ +.rail-nav { min-height: 0; overflow-y: auto; overscroll-behavior: contain; margin-left: -8px; padding-left: 8px; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } +.rail-foot { flex: none; } +.rail-title { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 8px; margin: 0; padding-bottom: 14px; background: var(--bg); font-size: 13px; line-height: 16px; font-weight: 400; letter-spacing: -.005em; text-transform: uppercase; color: var(--eyebrow); } +.rail-title .icon { width: 14px; height: 14px; } +.rail-track { position: relative; border-left: 1px solid var(--hairline); } +#rail-list { list-style: none; margin: 0; padding: 0; } +#rail-list a { display: block; padding: 6px 0 6px 16px; font-size: 13px; line-height: 19px; color: var(--muted-fg); text-decoration: none; transition: color .25s ease; } +#rail-list a:hover { color: var(--fg); } +#rail-list a.active { color: var(--nav-active-fg); font-weight: 500; } +#rail-list a code, .toc-inline a code { font-size: 12px; padding: 0; background: none; border: 0; color: inherit; font-weight: inherit; } +.rail-indicator { position: absolute; left: -2px; top: 0; width: 3px; height: 0; border-radius: 3px; background: var(--brand); box-shadow: 0 0 0 1px var(--indicator-ring); opacity: 0; transition: transform .45s var(--ease), height .45s var(--ease), opacity .3s; } +.rail-indicator.on { opacity: 1; } +.rail-nav[hidden] + .rail-foot { margin-top: 0; padding-top: 0; border-top: 0; } +.rail-foot { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--hairline); display: grid; gap: 2px; } +.rail-action { display: inline-flex; align-items: center; gap: 9px; height: 30px; padding: 0; border: 0; background: none; font-size: 13px; line-height: 20px; color: var(--muted-fg); text-decoration: none; text-align: left; transition: color .25s ease; } +.rail-action:hover { color: var(--fg); } +.rail-action .icon { width: 14px; height: 14px; } + +/* ========================================================================== + Doc content (frozen sections) + ========================================================================== */ +.doc-section { position: relative; } +.doc-section > .eyebrow, .eyebrow { display: block; margin: 0 0 14px; font-family: var(--font-sans); font-size: 13px; line-height: 18px; font-weight: 400; letter-spacing: -.005em; text-transform: uppercase; color: var(--eyebrow); } +.doc-section h2 { + position: relative; margin: 0 0 20px; color: var(--fg); + font-size: clamp(34px, 3.6vw, 48px); line-height: 1.14; font-weight: 400; letter-spacing: -.02em; text-wrap: balance; +} +.doc-section h2:focus { outline: none; } +.doc-section > h2 + p { font-size: 19px; line-height: 1.6; color: var(--muted-fg); letter-spacing: -.005em; margin: 0 0 28px; } +.doc-section > h2 + p strong { color: var(--fg); } +.doc-section h3 { + position: relative; margin: 64px 0 14px; padding-top: 0; color: var(--fg); + font-size: 27px; line-height: 1.22; font-weight: 400; letter-spacing: -.015em; text-wrap: balance; +} +.doc-section h4 { margin: 32px 0 8px; font-size: 17px; line-height: 24px; font-weight: 500; letter-spacing: -.01em; } +.doc-section h2 code, .doc-section h3 code { font-size: .86em; padding: 0; background: none; border: 0; color: inherit; font-weight: 400; letter-spacing: -.03em; } +.doc-section p, .doc-section li { color: var(--fg-body); font-size: 16px; line-height: 1.72; } +.doc-section p { margin: 16px 0; } +.doc-section > p, .doc-section > ul, .doc-section > ol { max-width: 70ch; } +.doc-section ul, .doc-section ol { margin: 18px 0; padding-left: 22px; } +.doc-section li { padding-left: 6px; } +.doc-section li + li { margin-top: 8px; } +.doc-section ul > li::marker { color: var(--olive-ring); content: "– "; } +/* Ordered lists: the site's numbered hairline rows */ +.doc-section ol { list-style: none; padding-left: 0; counter-reset: n; border-bottom: 1px solid var(--hairline); } +.doc-section ol > li { position: relative; counter-increment: n; margin: 0; padding: 14px 0 14px 44px; border-top: 1px solid var(--hairline); } +.doc-section ol > li + li { margin-top: 0; } +.doc-section ol > li::before { content: counter(n, decimal-leading-zero); position: absolute; left: 2px; top: 16px; font-size: 13px; line-height: 24px; font-variant-numeric: tabular-nums; color: var(--ol-num); } +.doc-section strong { color: var(--fg); font-weight: 500; } +.doc-section em { font-style: italic; } +.doc-section a:not(.heading-anchor) { + color: var(--link); font-weight: 400; text-decoration-line: underline; text-decoration-thickness: 1px; + text-decoration-color: var(--link-line); text-underline-offset: 3px; transition: text-decoration-color .25s ease, color .25s ease; + border-radius: 2px; +} +.doc-section a:not(.heading-anchor):hover { text-decoration-color: currentColor; } +.doc-section a code { color: inherit; } +.doc-section a:not(.heading-anchor):has(> code) { text-underline-offset: 4px; } +.muted { color: var(--muted-fg) !important; } +.small { font-size: 14px !important; line-height: 22px !important; } +.doc-section p.small.muted { margin-top: 32px; } + +/* Heading anchors (injected) */ +.heading-anchor { position: absolute; left: -30px; top: 50%; width: 22px; height: 22px; margin-top: -11px; display: grid; place-items: center; border-radius: 999px; color: var(--faint); opacity: 0; transition: opacity .25s ease, color .25s ease; text-decoration: none; } +.heading-anchor .icon { width: 14px; height: 14px; } +h2:hover > .heading-anchor, h3:hover > .heading-anchor, .heading-anchor:focus-visible { opacity: 1; } +.heading-anchor:hover { color: var(--eyebrow); } + +/* Inline code */ +code, pre, kbd, .mono { font-family: var(--font-mono); font-variant-ligatures: none; font-feature-settings: "liga" 0, "calt" 0; } +:not(pre) > code { + font-size: .84em; font-weight: 400; line-height: 1; padding: .24em .44em; border-radius: 6px; + background: var(--code-inline); color: var(--fg); overflow-wrap: anywhere; + -webkit-box-decoration-break: clone; box-decoration-break: clone; +} +:not(pre) > code.is-short { white-space: nowrap; } + +/* Code panels */ +pre { margin: 0; font-family: var(--font-mono); font-size: 13px; line-height: 22px; tab-size: 2; font-variant-ligatures: none; color: var(--code-fg); } +pre code { font: inherit; font-variant-ligatures: none; background: none; border: 0; padding: 0; color: inherit; white-space: pre; overflow-wrap: normal; } +.code-example { margin: 28px 0; min-width: 0; } +.code-example > figcaption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; } +.code-block { margin: 28px 0; } +.code-panel { position: relative; margin: 28px 0; border: 1px solid var(--code-border); border-radius: 14px; background: var(--code-bg); overflow: hidden; } +.code-example > .code-panel { margin: 0; } +.code-panel > pre { margin: 0; } +.code-panel pre { padding: 18px 22px; overflow: auto; max-height: 880px; background: transparent; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } +.code-panel pre:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; border-radius: 0 0 13px 13px; } +/* Scroll hints: the edge a block continues past fades out (JS sets the classes while there is + more to scroll that way). The mask sits on the scroller's box, so it stays put as code scrolls. */ +.code-panel pre { --fade-l: 0px; --fade-r: 0px; --fade-b: 0px; } +.code-panel pre.fade-l { --fade-l: 40px; } +.code-panel pre.fade-r { --fade-r: 72px; } +.code-panel pre.fade-b { --fade-b: 56px; } +.code-panel pre:is(.fade-l, .fade-r, .fade-b):not(:focus-visible) { + -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--fade-l), #000 calc(100% - var(--fade-r)), transparent), linear-gradient(to top, transparent, #000 var(--fade-b)); + mask-image: linear-gradient(to right, transparent, #000 var(--fade-l), #000 calc(100% - var(--fade-r)), transparent), linear-gradient(to top, transparent, #000 var(--fade-b)); + -webkit-mask-composite: source-in; mask-composite: intersect; +} +.code-head { display: flex; align-items: center; gap: 9px; height: 44px; padding: 0 8px 0 16px; border-bottom: 1px solid var(--code-border); background: var(--code-head); font-family: var(--font-mono); font-size: 12.5px; line-height: 16px; color: var(--muted-fg); } +.code-head > .icon { width: 14px; height: 14px; color: var(--eyebrow); } +.code-file { flex: 1; color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.code-head.is-lang .code-file { font-family: var(--font-sans); font-size: 13px; font-weight: 400; letter-spacing: -.005em; text-transform: uppercase; color: var(--eyebrow); } +/* A caption that describes the block rather than naming a file reads as text, not as a path; + a path with a note ("cms.index.tsx (Next.js)") keeps the path in mono and the note in text. */ +.code-head.is-desc .code-file { font-family: var(--font-sans); font-size: 13.5px; letter-spacing: -.005em; } +.code-file .cf-note { font-family: var(--font-sans); font-size: 13px; letter-spacing: -.005em; color: var(--muted-fg); } +.code-lang { flex: none; height: 22px; display: inline-flex; align-items: center; padding: 0 9px; border-radius: 999px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--code-border); font: 400 11px/16px var(--font-sans); letter-spacing: .02em; color: var(--muted-fg); } +.copy-button { + height: 30px; min-width: 30px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; flex: none; padding: 0 8px; + border: 1px solid transparent; border-radius: 999px; background: transparent; color: var(--muted-fg); + font-family: var(--font-sans); font-size: 12.5px; line-height: 16px; font-weight: 400; + transition: color .25s ease, background-color .25s ease, border-color .25s ease, transform .25s var(--ease); +} +.copy-button.has-label { padding: 0 12px 0 10px; } +.copy-button:hover { color: var(--fg); background: var(--surface); border-color: var(--code-border); } +.copy-button:active { transform: scale(.96); } +.copy-button .icon { width: 14px; height: 14px; } +.copy-button .i-check { display: none; } +.copy-button.copied { color: var(--tint-fg); background: var(--tint); border-color: transparent; } +.copy-button.copied .i-check { display: block; } +.copy-button.copied .i-copy { display: none; } +.code-panel.is-oneline { display: flex; align-items: center; border-radius: 999px; } +.code-panel.is-oneline pre { flex: 1; min-width: 0; padding: 13px 22px; scrollbar-width: none; } +.code-panel.is-oneline .copy-button { margin-right: 7px; } +.code-panel.is-oneline .code-lang { margin-right: 4px; } +.code-panel.is-diagram pre { line-height: 1.3; } +.code-panel.is-cmd pre code::before { content: "$"; margin-right: 12px; color: var(--eyebrow); font-weight: 500; user-select: none; -webkit-user-select: none; } + +/* Wide screens: code, tables and diagrams use the article's side gutters (up to 48px each side, + always leaving 24px to the sidebar and the rail), so fewer lines run past the panel edge. */ +@media (min-width: 1360px) { + body[data-layout="docs"] { + --bleed: clamp(0px, (min(100vw, var(--hdr-max)) - 528px - 768px) / 2 - 24px, 48px); + } + body[data-layout="docs"] .doc-section > .code-panel, + body[data-layout="docs"] .doc-section > .code-example, + body[data-layout="docs"] .doc-section > .table-wrap, + body[data-layout="docs"] .doc-section > .flow, + body[data-layout="docs"] .doc-section > .explorer { + width: auto; margin-left: calc(-1 * var(--bleed)); margin-right: calc(-1 * var(--bleed)); + } +} + +/* Syntax */ +pre .token { background: none; text-shadow: none; } +pre .token.comment, pre .token.prolog, pre .token.doctype, pre .token.cdata { color: var(--syn-comment); font-style: italic; } +pre .token.punctuation { color: var(--syn-punctuation); } +pre .token.keyword, pre .token.atrule, pre .token.important { color: var(--syn-keyword); } +pre .token.string, pre .token.char, pre .token.attr-value, pre .token.inserted { color: var(--syn-string); } +pre .token.class-name, pre .token.builtin, pre .token.namespace, pre .token.constant { color: var(--syn-type); } +pre .token.function, pre .token.function-variable { color: var(--syn-function); } +pre .token.number, pre .token.boolean { color: var(--syn-number); } +pre .token.property, pre .token.literal-property, pre .token.attr-name { color: var(--syn-property); } +pre .token.operator, pre .token.entity, pre .token.url { color: var(--syn-operator); } +pre .token.tag, pre .token.selector, pre .token.deleted { color: var(--syn-tag); } +pre .token.regex, pre .token.symbol { color: var(--syn-number); } +pre .token.bold, pre .token.important { font-weight: 600; } +pre .token.italic { font-style: italic; } +pre .token.tag { display: inline; border: 0; border-radius: 0; padding: 0; background: none; font: inherit; line-height: inherit; white-space: inherit; } +pre .token.tag > .token.tag, pre .token.tag > .token.tag > .token.punctuation { color: var(--syn-tag); } +pre .token.tag .token.class-name { color: var(--syn-type); } +pre .token.tag .token.punctuation { color: var(--syn-punctuation); } +pre .token.plain-text, pre .token.script { color: var(--code-fg); } +pre .token.attr-value, pre .token.attr-value .token.punctuation { color: var(--syn-string); } +pre .token.attr-value > .token.punctuation.attr-equals, pre .token.script-punctuation { color: var(--syn-punctuation); } +pre .token.spread { color: var(--syn-operator); } +pre .token.comment .token.keyword { color: inherit; font-weight: 600; font-style: normal; } + +/* Tables */ +.table-wrap { width: 100%; margin: 28px 0; overflow-x: auto; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); scrollbar-width: thin; } +table { width: 100%; border-collapse: collapse; font-size: 14px; line-height: 22px; } +th { text-align: left; font-size: 12px; line-height: 18px; font-weight: 400; letter-spacing: .04em; text-transform: uppercase; color: var(--eyebrow); background: var(--th-bg); white-space: nowrap; } +th, td { padding: 14px 18px; border-bottom: 1px solid var(--hairline); vertical-align: top; } +thead th { padding-top: 12px; padding-bottom: 12px; } +th code { text-transform: none; letter-spacing: 0; } +tr:last-child td { border-bottom: 0; } +td { color: var(--fg-body); } +td:first-child { color: var(--fg); } +td + td, th + th { padding-left: 20px; } +.doc-section table p, .doc-section table li { font-size: 14px !important; line-height: 22px !important; } +td code, th code { font-size: 12.5px !important; overflow-wrap: normal !important; white-space: nowrap; } +tbody tr { transition: background-color .2s ease; } +tbody tr:hover td { background: color-mix(in oklab, var(--bg-subtle) 70%, transparent); } + +/* Callouts */ +.callout, .intro-note { + position: relative; margin: 28px 0; padding: 16px 22px 16px 50px; + border: 1px solid var(--border); border-radius: 14px; background: var(--note-bg); +} +.callout p, .intro-note p { margin: 0 !important; max-width: none !important; font-size: 15px !important; line-height: 25px !important; color: var(--fg-body); } +.callout::before, .intro-note::before { + content: ""; position: absolute; left: 20px; top: 20px; width: 16px; height: 16px; + background: var(--eyebrow); + -webkit-mask: var(--ico-info) center / 16px no-repeat; mask: var(--ico-info) center / 16px no-repeat; +} +.callout.warning { background: var(--warn-bg); border-color: var(--warn-border); } +.callout.warning::before { background: var(--warn-icon); -webkit-mask-image: var(--ico-warn); mask-image: var(--ico-warn); } +.intro-note { background: var(--preview-bg); border-color: var(--preview-border); } +.intro-note p { color: var(--fg); } +.intro-note::before { -webkit-mask: none; mask: none; width: 10px; height: 10px; left: 23px; top: 23px; border-radius: 50%; background: var(--forest); box-shadow: 0 0 0 4px var(--lime); } +:root[data-theme="dark"] .intro-note::before { background: var(--lime); box-shadow: 0 0 0 4px rgba(208, 236, 26, .2); } +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .intro-note::before { background: var(--lime); box-shadow: 0 0 0 4px rgba(208, 236, 26, .2); } } +:root { + --ico-info: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4M12 8h.01'/%3E%3C/svg%3E"); + --ico-warn: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3'/%3E%3Cpath d='M12 9v4M12 17h.01'/%3E%3C/svg%3E"); +} + +/* Flow diagrams: a stack-strip of hairline cells */ +.flow { + display: grid; grid-template-columns: minmax(0, 1fr) 1px minmax(0, 1fr) 1px minmax(0, 1fr); + margin: 28px 0; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); + counter-reset: flow; overflow: hidden; +} +.flow-node { min-width: 0; padding: 22px 22px 24px; display: flex; flex-direction: column; gap: 4px; counter-increment: flow; } +.flow-node::before { content: counter(flow, decimal-leading-zero); margin-bottom: 14px; font-size: 13px; line-height: 16px; font-variant-numeric: tabular-nums; color: var(--eyebrow); } +.flow-node strong { font-size: 16px; line-height: 22px; font-weight: 500; letter-spacing: -.01em; color: var(--fg); } +.flow-node small { font-size: 14px; line-height: 20px; color: var(--muted-fg); } +.flow-node small code { font-size: 12px; } +.flow-arrow { position: relative; z-index: 0; display: flex; align-items: center; justify-content: center; background: var(--hairline); font-size: 0; color: var(--eyebrow); } +.flow-arrow::before { content: ""; position: absolute; z-index: -1; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); left: 50%; top: 50%; transform: translate(-50%, -50%); } +.flow-arrow::after { content: "→"; font-size: 13px; line-height: 1; font-family: var(--font-sans); } +.doc-section p.small.muted + .flow, .doc-section p.small.muted[style] { margin-top: 32px; } + +/* Explorer (walkthrough widget): the site's browser-window mock */ +.explorer { margin: 36px 0; border: 1px solid var(--hairline); border-radius: 16px; background: var(--surface); overflow: hidden; box-shadow: var(--shadow-win); } +.explorer-top { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--hairline); } +.explorer-top:first-child { background: var(--win-bar); } +.explorer-top strong { display: inline-flex; align-items: center; gap: 0; font-size: 14px; line-height: 20px; font-weight: 500; letter-spacing: -.005em; } +.explorer-top:first-child strong::before { content: ""; width: 10px; height: 10px; margin-right: 52px; border-radius: 50%; background: var(--win-dot); box-shadow: 16px 0 0 var(--win-dot), 32px 0 0 var(--win-dot); flex: none; } +.explorer-top + .explorer-top { background: var(--surface); padding-top: 10px; padding-bottom: 10px; } +.explorer-top .small { font-size: 13px !important; } +.segmented { display: inline-flex; flex-wrap: wrap; gap: 6px; padding: 0; border: 0; background: none; max-width: 100%; } +.segmented button { height: 30px; padding: 0 13px; border: 1px solid var(--border); border-radius: 999px; background: transparent; color: var(--muted-fg); font-family: var(--font-mono); font-size: 12px; line-height: 16px; white-space: nowrap; transition: color .25s ease, background-color .25s ease, border-color .25s ease; } +.segmented button:hover { color: var(--fg); border-color: var(--olive-ring); } +.segmented button[aria-pressed="true"] { background: var(--pill-on-bg); border-color: transparent; color: var(--pill-on-fg); box-shadow: inset 0 0 0 1px var(--pill-on-ring); } +.explorer-body { padding: 18px; } +.trace-steps { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 16px; } +.trace-steps button { height: 32px; padding: 0 14px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--muted-fg); font-size: 13px; line-height: 20px; transition: color .25s ease, border-color .25s ease, background-color .25s ease; } +.trace-steps button:hover:not(:disabled) { color: var(--fg); border-color: var(--olive-ring); } +.trace-steps button[aria-current="step"] { color: var(--pill-on-fg); background: var(--pill-on-bg); border-color: transparent; box-shadow: inset 0 0 0 1px var(--pill-on-ring); } +.trace-steps button:disabled { opacity: .45; cursor: default; } +.explorer pre { position: relative; min-height: 216px; padding: 18px 22px; margin: 0; border: 1px solid var(--code-border); border-radius: 12px; background: var(--code-bg); overflow: auto; } +.explorer pre::after { content: attr(data-lang); position: absolute; top: 12px; right: 12px; height: 22px; display: inline-flex; align-items: center; padding: 0 9px; border-radius: 999px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--code-border); font: 400 11px/16px var(--font-sans); letter-spacing: .02em; color: var(--muted-fg); } +.doc-section .trace-caption { min-height: 56px; margin: 16px 0 !important; font-size: 15px !important; line-height: 24px !important; color: var(--muted-fg) !important; max-width: none; } +.explorer-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 16px; border-top: 1px solid var(--hairline); } +.trace-stat { display: inline-flex; align-items: center; gap: 8px; font: 400 12px/16px var(--font-mono); color: var(--muted-fg); } +.trace-stat::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--brand); box-shadow: 0 0 0 1px var(--indicator-ring); flex: none; } +.primary-button { height: 40px; padding: 0 20px; display: inline-flex; align-items: center; gap: 8px; border: 0; border-radius: 999px; background: var(--brand); color: var(--brand-ink); font-size: 14px; font-weight: 500; line-height: 20px; transition: background-color .25s ease, transform .25s var(--ease); text-decoration: none; } +.primary-button:hover:not(:disabled) { background: var(--brand-hover); } +.primary-button:active:not(:disabled) { transform: scale(.97); } +.primary-button:disabled { opacity: .45; cursor: default; } +.quiet-button { height: 34px; padding: 0 14px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--fg); font-size: 13px; font-weight: 400; text-decoration: none; } +.quiet-button:hover { border-color: var(--border-strong); } + +/* Misc content classes (kept styled for any content that uses them) */ +details { margin: 16px 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 12px 16px; } +summary { cursor: pointer; font-weight: 500; } +blockquote { margin: 24px 0; padding-left: 20px; border-left: 2px solid var(--lime); color: var(--muted-fg); } +.two-column { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.info-card { border: 1px solid var(--border); border-radius: 14px; padding: 20px; background: var(--surface); } + +/* ========================================================================== + Landing + ========================================================================== */ +.landing { --container: 1200px; } +.container { width: 100%; max-width: var(--container); margin: 0 auto; padding: 0 40px; } +.band { position: relative; isolation: isolate; overflow: hidden; color: var(--band-fg); } +.hero.band { + padding: calc(var(--header-h) + 112px) 0 88px; + background: + radial-gradient(800px 600px at 72% 20%, rgba(30, 110, 55, .45), transparent 60%), + linear-gradient(162deg, #1A6030 0%, #0F4A22 48%, #082E14 100%); +} +/* Cobogó: the site's tiled hero texture, calm under the headline */ +.cobogo { position: absolute; inset: 0; z-index: -1; width: 100%; height: 100%; pointer-events: none; + -webkit-mask-image: linear-gradient(to right, #000 0%, #000 17%, transparent 39%, transparent 61%, #000 83%, #000 100%); + mask-image: linear-gradient(to right, #000 0%, #000 17%, transparent 39%, transparent 61%, #000 83%, #000 100%); } +.cobogo .cb-sm { display: none; } +.hero > .cobogo { top: 120px; height: calc(100% - 120px); } +.hero-inner { max-width: var(--container); } + +/* Preview badge: the site's eyebrow pill */ +/* The site's .deco-eyebrow-pill, with a lime dot */ +.badge-preview { + display: inline-flex; align-items: center; gap: 8px; max-width: 100%; margin: 0 0 28px; padding: 6px 14px; + border: 1px solid rgba(255, 255, 255, .28); border-radius: 999px; background: rgba(255, 255, 255, .08); + color: rgba(255, 255, 255, .85); font-size: 11px; line-height: 16px; letter-spacing: .04em; text-transform: uppercase; text-decoration: none; + transition: background-color .35s ease, border-color .35s ease, color .35s ease; +} +.badge-preview:hover { background: rgba(255, 255, 255, .13); border-color: rgba(255, 255, 255, .4); color: #fff; } +.badge-preview .bp-dot { background: var(--lime); } +.bp-key { white-space: nowrap; } +.bp-msg { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* .deco-h1, with the third clause dimmed as on /cms */ +.landing h1 { + margin: 0; max-width: 800px; color: #fff; + font-size: clamp(35px, 4.2vw, 58px); line-height: 1.14; font-weight: 400; letter-spacing: -.02em; + text-wrap: balance; +} +/* The site dims at white/.45; .48 keeps 3:1 over the cobogó (large text). */ +.landing h1 .hl { color: rgba(255, 255, 255, .52); } +.landing h1:focus { outline: none; } +/* The site's lede is white/.55; .66 is the lowest that keeps 4.5:1 over the cobogó at 15px. */ +.landing .lede { margin: 20px 0 0; max-width: 560px; font-size: 15px; line-height: 1.6; color: var(--band-muted); } +.landing .cta { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin: 36px 0 0; } + +/* Pill buttons */ +.btn { + height: 48px; padding: 12px 24px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; + border: 0; border-radius: 999px; font-size: 16px; line-height: 24px; font-weight: 500; letter-spacing: -.005em; + text-decoration: none; white-space: nowrap; transition: transform .25s var(--ease), background-color .25s ease, color .25s ease, box-shadow .25s ease, opacity .25s ease; +} +.btn .icon { width: 16px; height: 16px; transition: transform .35s var(--ease); } +.btn:hover .icon { transform: translateX(3px); } +.btn:active { transform: scale(.97); } +.btn-primary { background: var(--brand); color: var(--brand-ink); } +.btn-primary:hover { background: var(--brand-hover); } +.btn-white { background: #fff; color: #282524; } +.btn-white:hover { background: #EEF1F2; } +.install { + height: 48px; display: inline-flex; align-items: center; gap: 12px; padding: 0 10px 0 20px; + border: 0; border-radius: 999px; background: rgba(255, 255, 255, .2); + color: #fff; font-family: var(--font-mono); font-size: 13.5px; line-height: 20px; white-space: nowrap; + transition: background-color .25s ease, transform .25s var(--ease); +} +.install:hover { background: rgba(255, 255, 255, .3); } +.install:active { transform: scale(.98); } +.install .prompt { color: var(--brand); } +.install-icon { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 999px; color: rgba(255, 255, 255, .72); } +.install:hover .install-icon { color: #fff; background: rgba(255, 255, 255, .1); } +.install-icon .icon { width: 14px; height: 14px; } +.install-icon .i-check, .install.copied .install-icon .i-copy { display: none; } +.install.copied .install-icon .i-check { display: block; color: var(--brand); } + +/* Browser-window frame (the site's product-mock idiom) */ +.win { background: var(--surface); border: 1px solid var(--hairline); box-shadow: var(--shadow-win); overflow: hidden; } +.win-bar { position: relative; display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 16px; border-bottom: 1px solid var(--hairline); background: var(--win-bar); flex: none; } +.win-dots { display: inline-flex; gap: 6px; flex: none; } +.win-dots i { width: 10px; height: 10px; border-radius: 50%; background: var(--win-dot); } +.win-title { position: absolute; left: 88px; right: 88px; text-align: center; font-size: 11.5px; line-height: 16px; color: var(--muted-fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* Hero window: the Quickstart as a Studio-like app window */ +.hero-window { margin-top: 64px; border-radius: 16px; background: var(--win-bg); border-color: rgba(255, 255, 255, .12); box-shadow: 0 0 0 1px rgba(255, 255, 255, .06), 0 50px 120px -40px rgba(0, 0, 0, .6); } +.hero-window .win-bar { background: var(--win-bg); border-bottom-color: var(--hairline); } +/* Sits in the text column, flush with the headline, like the site's mock. */ +.journey { position: relative; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; padding: 10px; } +.jp { + position: relative; min-width: 0; display: flex; flex-direction: column; + border: 1px solid var(--hairline); border-radius: 12px; background: var(--win-panel); +} +.jp-head { display: flex; align-items: center; gap: 8px; height: 42px; padding: 0 14px; border-bottom: 1px solid var(--hairline); } +.jp-step { font-size: 12px; line-height: 16px; font-variant-numeric: tabular-nums; color: var(--eyebrow); } +.jp-title { font-size: 13.5px; line-height: 20px; font-weight: 500; color: var(--fg); letter-spacing: -.005em; white-space: nowrap; } +.jp-meta { margin-left: auto; min-width: 0; font: 400 11px/16px var(--font-mono); color: var(--muted-fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +@media (min-width: 1200px), (max-width: 479px) { .jp-meta .jm-dir { display: none; } } +.jp-pre { flex: 1; margin: 0; padding: 12px 10px 14px 12px; overflow-x: auto; font-size: 10.5px; line-height: 18px; color: var(--code-fg); scrollbar-width: none; } +.jp-pre::-webkit-scrollbar { display: none; } +.jp-pre.has-ln { padding-left: 0; padding-right: 8px; counter-reset: ln; background: linear-gradient(to right, transparent 21px, var(--hairline) 21px, var(--hairline) 22px, transparent 22px) local; } +.jp-pre.has-ln code { display: block; } +.jp-pre .l { display: block; white-space: pre; counter-increment: ln; overflow: hidden; text-overflow: ellipsis; } +.jp-pre .l::before { content: counter(ln); display: inline-block; width: 16px; margin-right: 12px; text-align: right; color: var(--quiet); font-variant-numeric: tabular-nums; user-select: none; -webkit-user-select: none; } +.jp-foot { display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 10px 14px; border-top: 1px solid var(--hairline); font-size: 11.5px; line-height: 16px; color: var(--muted-fg); } +.jp-foot .mono { font-size: 10.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.jp-foot > span:not(.mono) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.jp-foot .icon { width: 14px; height: 14px; color: var(--eyebrow); } +.jp-foot-sep { color: var(--eyebrow); } +.tk-k { color: var(--syn-keyword); } +.tk-t { color: var(--syn-type); } +.tk-p { color: var(--syn-property); } +.tk-s { color: var(--syn-string); } +.tk-n { color: var(--syn-number); } +.tk-f { color: var(--syn-function); } +.tk-c { color: var(--syn-comment); font-style: italic; } +.tk-ck { color: var(--fg); font-style: normal; font-weight: 600; } +.jp-src { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 10px 14px 0; font-size: 11px; line-height: 16px; color: var(--muted-fg); } +.src { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 10px 0 8px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--fg); white-space: nowrap; } +.src i { width: 6px; height: 6px; border-radius: 50%; } +.src-studio i { background: var(--purple); } +.src-agent i { background: var(--yellow); } +.jp-diff { padding-top: 10px; } +.jp-diff code { display: block; } +.jp-diff .dl { display: block; margin: 0 -14px; padding: 0 14px 0 8px; white-space: pre; } +.jp-diff .gut { display: inline-block; width: 12px; color: var(--faint); user-select: none; -webkit-user-select: none; } +.jp-diff .dl.del { background: var(--del-bg); } +.jp-diff .dl.del .gut { color: var(--del-fg); } +.jp-diff .dl.del .tk-n { text-decoration: line-through; text-decoration-color: color-mix(in oklab, var(--del-fg) 60%, transparent); } +.jp-diff .dl.add { background: var(--add-bg); box-shadow: inset 2px 0 0 var(--olive-ring); } +.jp-diff .dl.add .gut { color: var(--eyebrow); } +.jp-diff .dl.flash { animation: flash .8s var(--ease); } +@keyframes flash { from { background: rgba(208, 236, 26, .55); } } +.jp-commit .jc-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--yellow); box-shadow: 0 0 0 3px rgba(255, 193, 22, .2); flex: none; } +.jp-commit .i-check { display: none; } +.jp-commit .jc-status { flex: none; color: var(--fg); font-weight: 500; } +.jp-commit .jc-msg { display: none; } +.jp-commit[data-state="committed"] .jc-dot { display: none; } +.jp-commit[data-state="committed"] .i-check { display: block; } +.jp-commit[data-state="committed"] .jc-msg { display: block; min-width: 0; font-size: 11px; color: var(--fg); } +.jp-commit[data-state="committed"] .jc-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } +.jp-commit[data-state="clean"] .jc-dot { background: var(--faint); box-shadow: none; } +.jp-commit.just { animation: commit-in 1s var(--ease); } +@keyframes commit-in { from { background: rgba(208, 236, 26, .35); } } +.jp-arrow { + position: absolute; z-index: 2; top: 50%; right: -18px; width: 26px; height: 26px; margin-top: -13px; + display: grid; place-items: center; border-radius: 50%; + background: var(--win-panel); border: 1px solid var(--border); color: var(--eyebrow); box-shadow: var(--shadow-sm); +} +.jp-arrow .icon { width: 14px; height: 14px; } + +/* Studio form inside the window */ +.studio { flex: 1; display: flex; flex-direction: column; background: var(--st-bg); color: var(--st-fg); border-radius: 0 0 11px 11px; } +.studio-bar { display: flex; align-items: center; justify-content: space-between; gap: 8px; height: 42px; padding: 0 14px; border-bottom: 1px solid var(--st-border); } +.studio-block { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; line-height: 20px; font-weight: 500; } +.studio-block .sym, .swf-head .sym { width: 15px; height: 15px; flex: none; } +.sym-light { display: var(--wm-light); } +.sym-dark { display: var(--wm-dark); } +.studio-type { font: 400 11px/16px var(--font-mono); color: var(--st-muted); padding: 2px 8px; border-radius: 999px; background: var(--st-field); border: 1px solid var(--st-border); } +.studio-field { padding: 10px 14px 2px; } +.studio-field + .studio-field { border-top: 1px solid var(--st-border); margin-top: 8px; padding-top: 12px; } +.studio-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.studio-row label { font-size: 12px; line-height: 16px; color: var(--st-muted); } +.studio-row output { min-width: 38px; height: 22px; display: inline-grid; place-items: center; padding: 0 8px; border-radius: 999px; background: var(--tint); font: 500 11px/16px var(--font-mono); font-variant-numeric: tabular-nums; color: var(--tint-fg); } +.studio input[type="range"] { -webkit-appearance: none; appearance: none; display: block; width: 100%; height: 20px; margin: 6px 0 0; background: transparent; cursor: pointer; } +.studio input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: linear-gradient(to right, var(--st-fill) 0 var(--val, 50%), var(--st-track) var(--val, 50%) 100%); } +.studio input[type="range"]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--st-track); } +.studio input[type="range"]::-moz-range-progress { height: 4px; border-radius: 2px; background: var(--st-fill); } +.studio input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; margin-top: -6px; border-radius: 50%; background: var(--brand); border: 1.5px solid var(--brand-ink); box-shadow: 0 1px 3px rgba(0, 0, 0, .2); transition: transform .25s var(--ease); } +.studio input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--brand); border: 1.5px solid var(--brand-ink); box-shadow: 0 1px 3px rgba(0, 0, 0, .2); } +.studio input[type="range"]:active::-webkit-slider-thumb { transform: scale(1.15); } +.studio input[type="range"]:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 4px; } +.studio-actions { margin-top: auto; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--st-border); } +.studio-hint { font-size: 11px; line-height: 16px; color: var(--st-muted); } +.studio-save { height: 30px; min-width: 64px; padding: 0 16px; border: 0; border-radius: 999px; background: var(--brand); color: var(--brand-ink); font-size: 12.5px; font-weight: 500; transition: background-color .25s ease, color .25s ease, transform .25s var(--ease); } +.studio-save:hover:not(:disabled) { background: var(--brand-hover); } +.studio-save:active:not(:disabled) { transform: scale(.96); } +.studio-save:disabled { background: var(--st-field); color: var(--st-muted); box-shadow: inset 0 0 0 1px var(--st-border); cursor: default; } + +/* Step pills under the window: only on phones, where the panels become a swipe row */ +.journey-nav { display: none; justify-content: center; flex-wrap: wrap; gap: 6px; margin-top: 16px; } +.journey-nav button { height: 30px; display: inline-flex; align-items: center; gap: 6px; padding: 0 12px; border: 1px solid rgba(255, 255, 255, .22); border-radius: 999px; background: transparent; color: rgba(255, 255, 255, .78); font-size: 13px; line-height: 16px; transition: background-color .3s ease, color .3s ease, border-color .3s ease; } +.journey-nav button span { font-variant-numeric: tabular-nums; color: rgba(255, 255, 255, .5); } +.journey-nav button[aria-current="step"] { background: rgba(255, 255, 255, .2); border-color: transparent; color: #fff; } +.journey-nav button[aria-current="step"] span { color: var(--lime); } +.journey-caption { display: flex; align-items: baseline; justify-content: space-between; gap: 24px; margin-top: 28px; } +.journey-caption p { margin: 0; max-width: 78ch; font-size: 14px; line-height: 22px; color: var(--band-muted); } +.journey-caption .try { color: #fff; font-weight: 500; } +.text-link { display: inline-flex; align-items: center; gap: 6px; flex: none; font-size: 15px; line-height: 22px; font-weight: 500; color: var(--link); text-decoration: none; border-radius: 4px; } +.text-link .icon { width: 15px; height: 15px; transition: transform .35s var(--ease); } +.text-link:hover .icon { transform: translateX(3px); } +.band .text-link { color: var(--brand); } +.band :focus-visible { outline-color: var(--brand); } +.band .win :focus-visible { outline-color: var(--ring); } + +/* Sections: white, hairline-separated, 1200px */ +.lsec + .lsec { border-top: 1px solid var(--hairline); } +.section { padding-top: 112px; padding-bottom: 112px; } +.section-head { max-width: 720px; margin-bottom: 56px; } +.kicker { display: block; margin: 0 0 20px; font-size: 13px; line-height: 18px; font-weight: 400; letter-spacing: -.005em; text-transform: uppercase; color: var(--eyebrow); } +.kicker.is-center { text-align: center; } +.landing h2 { margin: 0; color: var(--fg); font-size: clamp(30px, 3.5vw, 48px); line-height: 1.17; font-weight: 400; letter-spacing: -.015em; text-wrap: balance; } +.landing h2 .dim { color: var(--muted-fg); } +.section-lede { margin: 12px 0 0; max-width: 620px; font-size: 17px; line-height: 1.5; color: var(--muted-fg); } +.section-lede code { font-size: .82em; } + +/* Stack strip */ +.stack-sec .container { padding-top: 72px; padding-bottom: 80px; } +.stack { list-style: none; margin: 32px 0 0; padding: 0; display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: 16px; background: var(--border); } +.stack li { height: 96px; display: flex; align-items: center; justify-content: center; gap: 10px; padding: 0 12px; background: var(--bg); font-size: 15px; font-weight: 500; letter-spacing: -.01em; color: var(--stack-fg); text-align: center; transition: background-color .3s ease, color .3s ease; } +.stack li:hover { background: color-mix(in oklab, var(--tint) 55%, var(--bg)); color: var(--tint-fg); } +.stack .mark { width: 18px; height: 18px; flex: none; } + +/* Contract: three cells on a hairline grid */ +.contract-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; border: 1px solid var(--border); border-radius: 16px; background: var(--border); overflow: hidden; } +.contract-item { min-width: 0; padding: 24px 28px 36px; background: var(--bg); } +.contract-item .num { display: block; margin-top: 30px; font-size: 13px; line-height: 16px; font-variant-numeric: tabular-nums; color: var(--eyebrow); } +.contract-item h3 { margin: 10px 0 10px; font-size: 22px; line-height: 1.25; font-weight: 400; letter-spacing: -.015em; } +.contract-item p { margin: 0; font-size: 15px; line-height: 1.6; color: var(--muted-fg); } +.ci-visual { position: relative; height: 212px; border: 1px solid var(--hairline); border-radius: 12px; background: var(--bg-subtle); overflow: hidden; box-shadow: none; } +.ci-visual .win-bar { height: 32px; padding: 0 12px; } +.ci-visual .win-dots i { width: 8px; height: 8px; } +.ci-visual .win-dots { gap: 5px; } +.ci-visual .win-title { left: 64px; right: 64px; font-size: 11px; } +.ci-code { background: var(--code-bg); } +.mini-pre { padding: 16px 16px; font-size: 12px; line-height: 21px; } +.sy-k { color: var(--syn-keyword); } +.sy-p { color: var(--syn-property); } +.sy-t { color: var(--syn-type); } +.sy-c { color: var(--syn-comment); font-style: italic; } +.ci-form { background: var(--surface); } +.mf-body { padding: 4px 14px; } +.mf-row { display: grid; grid-template-columns: 64px minmax(0, 1fr); align-items: center; gap: 12px; padding: 9px 0; } +.mf-row + .mf-row { border-top: 1px solid var(--hairline); } +.mf-label { font-size: 11.5px; line-height: 16px; color: var(--muted-fg); } +.mf-input { height: 32px; display: flex; align-items: center; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); font-size: 13px; color: var(--fg); min-width: 0; white-space: nowrap; overflow: hidden; } +.mf-input.mono { font-family: var(--font-mono); font-size: 12.5px; } +.mf-input.is-focus { border-color: var(--olive-ring); box-shadow: 0 0 0 3px color-mix(in oklab, var(--brand) 40%, transparent); } +.caret { width: 1px; height: 16px; margin-left: 2px; background: var(--fg); animation: blink 1.1s steps(1) infinite; } +@keyframes blink { 50% { opacity: 0; } } +.mf-chips { display: flex; gap: 6px; flex-wrap: wrap; } +.mf-chips span { height: 26px; display: inline-flex; align-items: center; padding: 0 10px; border-radius: 999px; background: var(--tint); font: 400 12px/16px var(--font-mono); color: var(--tint-fg); } +.mf-chips .add { background: transparent; border: 1px dashed var(--border-strong); color: var(--muted-fg); font-family: var(--font-sans); } +.ci-agent { padding: 16px; display: flex; flex-direction: column; justify-content: center; gap: 10px; background: var(--bg-warm); } +.bubble { align-self: flex-end; max-width: 88%; padding: 8px 14px 9px; border-radius: 14px 14px 4px 14px; background: var(--hover); color: var(--fg); font-size: 13px; line-height: 19px; } +.bubble-who { display: block; font-size: 10.5px; line-height: 14px; letter-spacing: .04em; text-transform: uppercase; color: var(--who-fg); } +.agent-edit { flex: none; border: 1px solid var(--hairline); border-radius: 12px; background: var(--surface); overflow: hidden; } +.ae-file { display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 12px; border-bottom: 1px solid var(--hairline); font-size: 12px; line-height: 16px; color: var(--muted-fg); white-space: nowrap; overflow: hidden; } +.ae-file .icon { width: 13px; height: 13px; color: var(--eyebrow); } +.ae-file span { min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.ae-line { display: block; padding: 2px 12px; font-size: 12px; line-height: 20px; white-space: pre; border: 0 !important; border-radius: 0 !important; background: none; } +.ae-line.del { background: var(--del-bg) !important; color: var(--del-fg) !important; } +.ae-line.add { background: var(--add-bg) !important; color: var(--fg) !important; box-shadow: inset 2px 0 0 var(--olive-ring); } +.ae-ok { display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-top: 1px solid var(--hairline); font-size: 11.5px; line-height: 16px; color: var(--muted-fg); white-space: nowrap; overflow: hidden; } +.ae-ok .icon { width: 13px; height: 13px; color: var(--eyebrow); } +.ae-ok b { font: 400 11px/16px var(--font-mono); color: var(--fg); } + +/* Tour: sticky copy with a numbered hairline list, window on the right */ +.stepper { display: grid; grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); gap: 56px; align-items: start; margin-top: 44px; } +.stepper-copy { position: sticky; top: calc(var(--header-h) + 48px); } +.stepper-copy h2 { max-width: 15ch; } +.steps { display: flex; flex-direction: column; margin-top: 32px; border-bottom: 1px solid var(--hairline); } +.stepper .step { + position: relative; display: grid; grid-template-columns: 16px minmax(0, 1fr); column-gap: 20px; width: 100%; padding: 16px 0; + border: 0; border-top: 1px solid var(--hairline); border-radius: 0; background: transparent; color: inherit; text-align: left; + transition: background-color .3s ease; +} +.stepper .step::before { content: ""; position: absolute; left: 0; top: -1px; height: 2px; width: 0; background: var(--nav-bar); transition: width .6s var(--ease-slow); } +.stepper .step[aria-selected="true"]::before { width: 100%; } +/* The site's numbered rows: faint tabular index, full-ink text; the active index turns forest */ +.stepper .step .num { padding-top: 1px; font-size: 13px; line-height: 22px; font-variant-numeric: tabular-nums; color: var(--num-faint); transition: color .3s ease; } +.stepper .step:hover .num { color: var(--fg); } +.stepper .step[aria-selected="true"] .num { color: var(--step-on); } +.step-title { display: block; font-size: 15px; line-height: 22px; font-weight: 400; letter-spacing: -.005em; color: var(--fg); transition: color .3s ease; } +.stepper .step[aria-selected="true"] .step-title { font-weight: 500; } +.step-text { display: grid; grid-template-rows: 0fr; margin-top: 0; font-size: 14px; line-height: 1.55; color: var(--muted-fg); transition: grid-template-rows .5s var(--ease), margin-top .5s var(--ease), opacity .4s ease; opacity: 0; } +.step-text > span { min-height: 0; overflow: hidden; } +.stepper .step[aria-selected="true"] .step-text { grid-template-rows: 1fr; margin-top: 6px; opacity: 1; } +.sw { display: flex; flex-direction: column; min-width: 0; min-height: 468px; border-radius: 0; background: var(--surface); } +.sw-tabs { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 16px; border-bottom: 1px solid var(--hairline); flex: none; } +.sw-tab { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 13px; border: 1px solid var(--border); border-radius: 999px; font: 400 12px/16px var(--font-mono); color: var(--muted-fg); white-space: nowrap; cursor: pointer; transition: color .25s ease, background-color .25s ease, border-color .25s ease; } +.sw-tab .icon { width: 13px; height: 13px; } +.sw-tab:hover { color: var(--fg); border-color: var(--olive-ring); } +.sw-tab.is-on { background: var(--pill-on-bg); color: var(--pill-on-fg); border-color: transparent; box-shadow: inset 0 0 0 1px var(--pill-on-ring); } +.sw-panes { flex: 1; display: grid; } +.sw-pane { grid-area: 1 / 1; min-width: 0; display: flex; flex-direction: column; visibility: hidden; opacity: 0; transform: translateY(6px); transition: opacity .3s ease, transform .4s var(--ease), visibility 0s linear .3s; } +.sw-pane.is-on { visibility: visible; opacity: 1; transform: none; transition: opacity .45s ease, transform .6s var(--ease-slow); } +.sw-pane pre { flex: 1; margin: 0; padding: 22px 24px; overflow: auto; font-size: 13px; line-height: 22px; background: var(--code-bg); } +.sw-pane pre:focus-visible { outline-offset: -2px; } +.sw-term { display: flex; gap: 12px; margin: 22px 24px 0; padding: 13px 18px; border-radius: 999px; background: #0C3A1A; font: 400 13px/20px var(--font-mono); color: #EAF3D6; overflow-x: auto; white-space: nowrap; scrollbar-width: none; } +.sw-prompt { color: var(--brand); font-weight: 500; } +.sw-form { flex: none; margin: 16px 24px 22px; border: 1px solid var(--st-border); border-radius: 12px; background: var(--st-bg); color: var(--st-fg); overflow: hidden; } +.swf-head { display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 14px; border-bottom: 1px solid var(--st-border); background: var(--bg-subtle); font-size: 13px; font-weight: 500; } +.swf-type { margin-left: auto; padding: 2px 8px; border: 1px solid var(--st-border); border-radius: 999px; background: var(--st-bg); font: 400 11px/16px var(--font-mono); color: var(--st-muted); } +.swf-row { display: grid; grid-template-columns: minmax(0, 1fr) 72px 48px; align-items: center; gap: 12px; height: 46px; padding: 0 14px; font-size: 13px; } +.swf-row + .swf-row { border-top: 1px solid var(--st-border); } +.swf-num { height: 28px; display: flex; align-items: center; justify-content: flex-end; padding: 0 12px; border: 1px solid var(--st-border); border-radius: 999px; background: var(--st-field); font: 500 12px/16px var(--font-mono); font-variant-numeric: tabular-nums; } +.swf-range { font: 400 11px/16px var(--font-mono); color: var(--st-muted); text-align: right; } +.sw-result { display: flex; align-items: center; gap: 10px; margin: auto 0 0; padding: 14px 20px; border-top: 1px solid var(--hairline); background: var(--tint); font-size: 14px; line-height: 20px; color: var(--tint-fg); flex: none; } +.sw-result .icon { width: 20px; height: 20px; padding: 4px; border-radius: 50%; background: var(--forest); color: var(--lime); stroke-width: 2.5; flex: none; } +.sw-result code { font-size: .86em; background: rgba(255, 255, 255, .6); } + +/* Production: a rounded forest panel with the cobogó on its right edge */ +.prod-sec .container { padding-top: 32px; padding-bottom: 32px; } +.prod { position: relative; isolation: isolate; overflow: hidden; padding: 88px 72px 80px; border-radius: 24px; color: var(--band-fg); + background: radial-gradient(700px 500px at 85% 10%, rgba(30, 110, 55, .5), transparent 60%), linear-gradient(162deg, #145528 0%, #0C4420 50%, #07301A 100%); } +.prod .cobogo { -webkit-mask-image: linear-gradient(to right, transparent 0%, transparent 62%, #000 92%); mask-image: linear-gradient(to right, transparent 0%, transparent 62%, #000 92%); } +.prod .kicker { color: var(--lime); } +.prod h2 { color: #fff; } +.prod h2 .dim { color: rgba(255, 255, 255, .5); } +.prod .section-lede { color: var(--band-muted); } +.prod :not(pre) > code { background: rgba(255, 255, 255, .1); color: var(--lime); } +.prod :focus-visible { outline-color: var(--lime); } + +/* Under the hood: the site's "direct questions" band — warm paper, hairline rows */ +.hood-sec { background: var(--bg-subtle); } +.hood-links { display: grid; margin-top: 40px; border-bottom: 1px solid var(--hairline); } +.hood-links a { position: relative; display: grid; grid-template-columns: 200px minmax(0, 1fr) 24px; align-items: center; gap: 24px; padding: 22px 4px; border-top: 1px solid var(--hairline); text-decoration: none; color: var(--muted-fg); font-size: 14.5px; line-height: 1.6; transition: background-color .3s ease; } +.hood-links .hl-k { justify-self: start; padding: 3px 11px; border-radius: 999px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--border); font-size: 12px; line-height: 18px; color: var(--fg); } +.hood-links .hl-body { display: grid; gap: 2px; min-width: 0; } +.hood-links strong { font-size: 17px; line-height: 24px; font-weight: 400; letter-spacing: -.005em; color: var(--fg); } +.hood-links .icon { width: 18px; height: 18px; color: var(--olive-ring); transition: transform .4s var(--ease), color .3s ease; } +.hood-links a:hover .icon { transform: translateX(4px); color: var(--eyebrow); } +.hood-links a:hover strong { color: var(--link); } +.hood-more { margin-top: 32px; } + +/* Final call to action: centered, light display type */ +.final { padding: 112px 0; background: var(--bg); text-align: center; } +.final-inner { display: flex; flex-direction: column; align-items: center; } +.landing .final h2 { max-width: 620px; margin: 0 auto; font-size: clamp(30px, 3.5vw, 48px); line-height: 1.17; letter-spacing: -.015em; } +.final .cta { justify-content: center; margin-top: 36px; } +/* The site's 13px note under the single CTA (#78726E: its faint grey that still reads at 4.5:1) */ +.final-note { margin: 16px auto 0; max-width: 560px; font-size: 13px; line-height: 20px; color: var(--quiet); } +.final-note a { color: inherit; text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 2px; border-radius: 2px; transition: color .25s ease; } +.final-note a:hover { color: var(--fg); } +.final :focus-visible { outline-color: var(--ring); } + +/* Footer: the site's forest panel with lime headings and the cropped wordmark */ +.site-footer { --container: 1200px; padding: 8px; background: var(--footer-bg); } +body:not([data-layout="landing"]) .site-footer { display: none; } +.footer-panel { position: relative; overflow: hidden; padding-top: 128px; border-radius: 16px; background: var(--forest); color: #E7E5E4; } +.footer-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 40px; } +.footer-col p { margin: 0 0 8px; font-size: 18px; line-height: 1.625; font-weight: 500; color: var(--lime); } +.footer-col a { display: block; width: fit-content; padding: 8px 0; font-size: 16px; line-height: 24px; color: #E7E5E4; text-decoration: none; border-radius: 3px; transition: color .3s ease; } +.footer-col a:hover { color: rgba(208, 236, 26, .85); } +.footer-base { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 16px; margin-top: 72px; padding-top: 24px; border-top: 1px solid rgba(255, 255, 255, .12); font-size: 13px; line-height: 20px; color: rgba(231, 229, 228, .78); } +.footer-base code { font-size: 12px; background: rgba(255, 255, 255, .1); color: #fff; } +.site-footer :focus-visible { outline-color: var(--lime); } +.footer-mark { max-width: 1296px; margin: 56px auto 0; padding: 0 40px; aspect-ratio: 1296 / 250; overflow: hidden; } +.footer-mark .wm { width: 100%; height: auto; } + +/* ---------- Search dialog ---------- */ +dialog#search-dialog { + width: min(660px, calc(100% - 32px)); max-height: min(580px, calc(100vh - 96px)); margin: 12vh auto auto; padding: 0; + display: none; flex-direction: column; overflow: hidden; + border: 1px solid var(--border); border-radius: 20px; background: var(--surface); color: var(--fg); box-shadow: var(--shadow-lg); +} +dialog#search-dialog[open] { display: flex; animation: pop .35s var(--ease-slow); } +@keyframes pop { from { opacity: 0; transform: translateY(7px) scale(.985); } } +dialog::backdrop { background: rgba(14, 26, 16, .3); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); } +.search-head { display: flex; align-items: center; gap: 12px; height: 60px; padding: 0 14px 0 20px; border-bottom: 1px solid var(--hairline); flex: none; transition: box-shadow .25s ease; } +.search-head:has(input:focus-visible) { box-shadow: inset 0 -2px 0 var(--ring); } +.search-head .icon { width: 18px; height: 18px; color: var(--eyebrow); } +.search-head input { flex: 1; min-width: 0; height: 40px; border: 0; outline: none; background: transparent; font-size: 17px; color: var(--fg); } +.search-head input::placeholder { color: var(--muted-fg); } +.search-head input::-webkit-search-cancel-button { display: none; } +.search-close { height: 26px; padding: 0 10px; border: 0; border-radius: 999px; background: var(--muted); color: var(--muted-fg); font: 500 11px/16px var(--font-mono); } +.search-results { flex: 1; overflow-y: auto; padding: 8px; overscroll-behavior: contain; } +.search-label { margin: 10px 12px 6px; font-size: 13px; line-height: 16px; letter-spacing: -.005em; text-transform: uppercase; color: var(--eyebrow); } +.search-results a { position: relative; display: flex; align-items: flex-start; gap: 12px; padding: 10px 12px; border-radius: 12px; text-decoration: none; color: var(--fg); scroll-margin: 8px; } +.search-results a:hover, .search-results a:focus, .search-results a.is-active { background: var(--nav-active-bg); outline: none; } +.search-results a:focus-visible { box-shadow: inset 0 0 0 2px var(--ring); } +.sr-icon { flex: none; width: 30px; height: 30px; display: grid; place-items: center; border-radius: 999px; background: var(--muted); color: var(--muted-fg); } +.sr-icon .icon { width: 14px; height: 14px; } +.search-results a.is-active .sr-icon { color: var(--pill-on-fg); background: var(--pill-on-bg); } +.sr-body { flex: 1; min-width: 0; display: grid; gap: 2px; } +.sr-enter { flex: none; align-self: center; width: 26px; height: 26px; display: grid; place-items: center; border-radius: 999px; background: var(--surface); color: var(--muted-fg); opacity: 0; } +.sr-enter .icon { width: 13px; height: 13px; } +.search-results a.is-active .sr-enter { opacity: 1; } +.sr-path { font-size: 12px; line-height: 16px; color: var(--muted-fg); display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sr-title { font-size: 15px; line-height: 21px; font-weight: 400; } +.search-results a.is-active .sr-title { color: var(--nav-active-fg); font-weight: 500; } +.sr-snip { font-size: 13px; line-height: 20px; color: var(--muted-fg); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.search-results mark { background: var(--mark-bg); color: var(--mark-fg); border-radius: 3px; padding: 0 1px; } +.search-empty { padding: 28px 12px; text-align: center; font-size: 14px; color: var(--muted-fg); } +.search-hint { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 11px 20px; border-top: 1px solid var(--hairline); background: var(--bg-subtle); font-size: 12px; line-height: 16px; color: var(--muted-fg); flex: none; } +.search-hint kbd { display: inline-grid; place-items: center; min-width: 22px; height: 22px; margin-right: 2px; padding: 0 6px; border-radius: 999px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--border); } +.search-hint .sh-note { margin-left: auto; } + +.toast { position: fixed; z-index: 120; left: 50%; bottom: 24px; transform: translateX(-50%); display: inline-flex; align-items: center; gap: 8px; height: 44px; padding: 0 20px; border-radius: 999px; background: var(--forest); color: var(--lime); font-size: 14px; font-weight: 500; box-shadow: 0 24px 60px rgba(0, 0, 0, .3); animation: toast-in .45s var(--ease-slow); } +@keyframes toast-in { from { opacity: 0; transform: translate(-50%, 10px); } } + +/* ---------- Entrance motion (the site's deco-enter / deco-reveal) ---------- */ +.enter { animation: deco-enter 1.1s var(--ease-slow) both; animation-delay: var(--d, 0ms); } +@keyframes deco-enter { from { opacity: 0; transform: translateY(22px); } } +.js .reveal { opacity: 0; transform: translateY(24px); transition: opacity 1s var(--ease-slow), transform 1s var(--ease-slow); transition-delay: var(--d, 0ms); } +.js .reveal.in { opacity: 1; transform: none; } + +/* ========================================================================== + Dark mode refinements for the landing + ========================================================================== */ +:root[data-theme="dark"] .landing .stack li:hover { background: rgba(208, 236, 26, .06); } +:root[data-theme="dark"] .sw-result code { background: rgba(0, 0, 0, .25); } +:root[data-theme="dark"] .sw-result .icon { background: var(--lime); color: var(--forest); } +:root[data-theme="dark"] .footer-panel { background: #0A3A18; } +:root[data-theme="dark"] .sw-term { background: #0C2E16; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .landing .stack li:hover { background: rgba(208, 236, 26, .06); } + :root:not([data-theme="light"]) .sw-result code { background: rgba(0, 0, 0, .25); } + :root:not([data-theme="light"]) .sw-result .icon { background: var(--lime); color: var(--forest); } + :root:not([data-theme="light"]) .footer-panel { background: #0A3A18; } + :root:not([data-theme="light"]) .sw-term { background: #0C2E16; } +} + +/* ========================================================================== + Responsive + ========================================================================== */ +@media (max-width: 1279px) { + body[data-layout="docs"] .shell { grid-template-columns: 256px minmax(0, 1fr) 224px; } + body[data-layout="docs"] main { padding-left: 40px; padding-right: 40px; } + .search-trigger { width: 208px; } + .stack { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .prod { padding: 72px 48px; } +} +@media (max-width: 1199px) { + .journey { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .jp-arrow { display: none; } +} +@media (min-width: 1200px) { + .doc-top .page-tools { display: none; } +} +@media (max-width: 1199px) { + body[data-layout="docs"] .shell { grid-template-columns: 256px minmax(0, 1fr); } + .rail { display: none; } + .toc-inline { display: block; } +} +@media (max-width: 1139px) { + .contract-list { grid-template-columns: 1fr; } + .contract-item { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 32px; align-items: start; } + .contract-item .ci-visual { grid-row: span 3; } + .contract-item .num { margin-top: 4px; } +} +@media (max-width: 1023px) { + .stepper { grid-template-columns: 1fr; gap: 40px; } + .stepper-copy { position: static; } + .stepper-copy h2 { max-width: none; } + .hood-links a { grid-template-columns: 150px minmax(0, 1fr) 20px; gap: 16px; } + .search-trigger { width: 38px; padding: 0; justify-content: center; margin-right: 4px; border-color: transparent; background: transparent; } + .search-trigger .st-label, .search-trigger kbd { display: none; } + .search-trigger .icon { width: 18px; height: 18px; } + .site-header.on-band .search-trigger { background: transparent; color: rgba(255, 255, 255, .86); } +} +@media (max-width: 899px) { + :root { --header-h: 58px; } + body[data-layout="docs"] .shell { display: block; } + body[data-layout="docs"] main { padding: 24px 24px 72px; } + /* The site's mobile header: wordmark left; search, theme, GitHub, the CTA pill and the menu + button on the right (the menu button is last in the DOM too, so focus follows the eye). */ + .menu-btn { display: inline-grid; margin: 0 -6px 0 2px; } + body[data-layout="landing"] .hdr-actions { gap: 4px; } + body[data-layout="landing"] .hdr-cta { height: 36px; margin-left: 4px; padding: 0 16px; font-size: 14px; } + .sidebar { + position: fixed; z-index: 70; top: 0; right: 0; left: auto; bottom: 0; height: 100%; width: min(340px, 88vw); + background: var(--bg-subtle); border-right: 0; border-left: 1px solid var(--hairline); box-shadow: var(--shadow-lg); + transform: translateX(102%); visibility: hidden; + transition: transform .45s var(--ease-slow), visibility 0s linear .45s; + } + .nav-open .sidebar { transform: none; visibility: visible; transition: transform .45s var(--ease-slow); } + .mobile-overlay { position: fixed; inset: 0; z-index: 65; background: rgba(14, 26, 16, .34); opacity: 0; pointer-events: none; transition: opacity .3s ease; } + .nav-open .mobile-overlay { opacity: 1; pointer-events: auto; } + .drawer-head { display: flex; align-items: center; justify-content: space-between; height: 58px; padding: 0 12px 0 24px; border-bottom: 1px solid var(--hairline); flex: none; } + .drawer-title { font-size: 13px; font-weight: 400; letter-spacing: -.005em; text-transform: uppercase; color: var(--eyebrow); } + #toc { padding-top: 20px; } + .heading-anchor { display: none; } + .hdr-inner, body[data-layout="docs"] .hdr-inner { padding: 0 16px 0 20px; } + body[data-layout="landing"] .site-header { padding: 16px 16px 0; } + body[data-layout="landing"] .hdr-inner { height: 56px; padding: 0 8px 0 12px; } + body[data-layout="landing"] .site-header.is-scrolled .hdr-inner { height: 56px; } + .hero > .cobogo { top: 88px; height: calc(100% - 88px); } +} +@media (max-width: 767px) { + .tabs { display: none; } + .drawer-tabs { display: flex; } + .drawer-tabs a { color: var(--muted-fg); } + /* Phones: one swipeable row of panels (like the site's single scaled snapshot), with step pills. */ + .journey { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 86%; gap: 10px; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 10px; overscroll-behavior-x: contain; scrollbar-width: none; } + .journey::-webkit-scrollbar { display: none; } + .jp { scroll-snap-align: start; } + .jp-arrow { display: none; } + .journey-nav { display: flex; } + .journey-caption { margin-top: 20px; } + .stack { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .stack li { height: 80px; font-size: 14px; } + .contract-item { display: block; } + .contract-item .num { margin-top: 26px; } + .journey-caption { display: block; } + .journey-caption .text-link { margin-top: 14px; } + .footer-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 36px 24px; } + .hood-links a { grid-template-columns: minmax(0, 1fr) 20px; gap: 8px 16px; } + .hood-links .hl-k { grid-column: 1 / -1; } + .hood-links .hl-body { grid-column: 1; } + .doc-top-row { align-items: flex-start; } + .flow { grid-template-columns: 1fr; grid-template-rows: auto; } + .flow-arrow { height: 1px; } + .flow-arrow::after { content: "↓"; } + .flow-node { padding: 18px 20px; } + .flow-node::before { margin-bottom: 8px; } + .pager { grid-template-columns: 1fr; } + .pager .pg-next { grid-column: 1; } + .two-column { grid-template-columns: 1fr; } + .search-hint .sh-note { display: none; } + dialog#search-dialog { margin-top: 16px; max-height: calc(100vh - 32px); } + .cobogo .cb-lg { display: none; } + .cobogo .cb-sm { display: inline; } +} +@media (max-width: 559px) { + .container { padding: 0 16px; } + body[data-layout="docs"] main { padding: 20px 16px 64px; } + .hdr-inner, body[data-layout="docs"] .hdr-inner { padding: 0 8px 0 16px; } + .logo .product { font-size: 14px; } + .hero.band { padding-top: calc(var(--header-h) + 56px); padding-bottom: 56px; } + .badge-preview { margin-bottom: 24px; } + .landing .cta { margin-top: 30px; gap: 10px; } + .landing .cta .btn { flex: 1 1 auto; } + .install { width: 100%; justify-content: space-between; font-size: 13px; } + .install > span:nth-child(2) { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; } + .hero-window { margin-top: 44px; border-radius: 14px; } + .journey { padding: 10px; } + .hero-window .win-title { left: 64px; right: 16px; text-align: right; } + .bp-msg { display: none; } + .sw-pane pre { padding: 16px; font-size: 12px; line-height: 20px; } + .sw-term { margin: 16px 16px 0; font-size: 12px; border-radius: 12px; } + .sw-form { margin: 12px 16px 16px; } + .swf-row { grid-template-columns: minmax(0, 1fr) 56px; } + .swf-range { display: none; } + .sw-result { padding: 12px 16px; font-size: 13.5px; } + .sw .win-title { left: 64px; right: 12px; text-align: right; } + .sw { min-height: 0; } + .sw-tabs { padding: 10px 12px; } + .stack-sec .container { padding-top: 56px; padding-bottom: 56px; } + .section { padding-top: 80px; padding-bottom: 80px; } + .section-head { margin-bottom: 36px; } + .section-lede { font-size: 16px; } + .contract-item { padding: 16px 16px 28px; } + .ci-visual { height: 204px; } + .ci-agent { padding: 12px 14px; gap: 8px; } + .prod-sec .container { padding: 16px 8px; } + .prod { padding: 56px 20px 48px; border-radius: 16px; } + .hood-links a { padding: 18px 0; } + .final { padding: 80px 0; } + .final .cta .btn { flex: 1 1 auto; } + .site-footer { padding: 6px; } + .footer-panel { padding-top: 64px; } + .footer-base { margin-top: 48px; } + .footer-mark { padding: 0 16px; margin-top: 40px; } + .page-tools .tool-btn { width: 34px; height: 34px; padding: 0; justify-content: center; } + .page-tools .tool-btn span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } + .doc-top-row { align-items: center; } + .doc-section h2 { font-size: 34px; line-height: 1.15; } + .doc-section > h2 + p { font-size: 17px; } + .doc-section h3 { font-size: 23px; margin-top: 48px; } + .doc-section p, .doc-section li { font-size: 16px; line-height: 1.7; } + .doc-section .code-panel:not(.is-oneline) { margin-left: -16px; margin-right: -16px; border-radius: 0; border-left: 0; border-right: 0; } + .code-panel pre { padding: 14px 16px; font-size: 12.5px; line-height: 21px; } + .table-wrap { border-radius: 12px; } + table { font-size: 13.5px; } + th, td { padding: 11px 12px; } + .table-wrap table { min-width: 520px; } + td code.can-wrap, th code.can-wrap { white-space: normal; } + .table-wrap table:has(tr > :nth-child(3)) { min-width: 640px; } + .explorer { border-radius: 14px; } + .explorer-body { padding: 12px; } + .explorer-top { padding: 10px 12px; } + .explorer-top:first-child strong::before { margin-right: 44px; } + .explorer-top .segmented { flex-wrap: wrap; overflow: visible; } + .explorer-top:first-child .segmented { width: 100%; } + .callout, .intro-note { padding: 14px 16px 14px 44px; } + .callout::before { left: 16px; top: 18px; } + .intro-note::before { left: 19px; top: 21px; } +} + +/* Tablets: the landing's pill header can't hold four tabs beside its CTA, so they move into the + menu drawer (the docs header, with no CTA, keeps them). */ +@media (min-width: 768px) and (max-width: 859px) { + body[data-layout="landing"] .tabs { display: none; } + body[data-layout="landing"] .drawer-tabs { display: flex; } +} +@media (max-width: 389px) { + .drawer-tabs a { padding: 0 8px; } +} + +/* Narrow phones: the landing header hands GitHub, then theme, then search to the menu drawer + (in that order) only when the pill runs out of room. */ +@media (max-width: 479px) { + body[data-layout="landing"] .hdr-actions > .gh-link { display: none; } + body[data-layout="landing"] .drawer-tools { display: grid; } +} +@media (max-width: 429px) { + body[data-layout="landing"] .hdr-actions > .theme-btn { display: none; } +} +@media (max-width: 379px) { + body[data-layout="landing"] .hdr-actions > .search-trigger { display: none; } +} +@media (max-width: 359px) { + .hdr-inner, body[data-layout="docs"] .hdr-inner { gap: 2px; padding: 0 6px 0 12px; } + .hdr-actions { gap: 0; } + .menu-btn { margin: 0; } + body[data-layout="landing"] .hdr-inner { padding: 0 6px 0 10px; } + body[data-layout="landing"] .hdr-cta { padding: 0 12px; } +} + +@media (max-width: 480px) { + .code-head:not(.is-lang) .code-lang { display: none; } + .code-panel.is-oneline .code-lang { display: none; } + .copy-button.has-label { padding: 0 8px; } + .copy-button .copy-label { display: none; } + .sw-tabs { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; } + .sw-tabs::-webkit-scrollbar { display: none; } + .sw-tab { padding: 0 11px; } + .sw-tab .icon { display: none; } +} + +/* ---------- Motion & print ---------- */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } + .js .reveal { opacity: 1; transform: none; } +} +@media print { + :root, :root:not([data-theme="light"]), :root[data-theme="dark"] { +{{light_tokens}} + --bg: #fff; --bg-subtle: #f6f5f3; --surface: #fff; --muted: #f1f0ee; --fg: #1c1917; --fg-body: #292524; --muted-fg: #57534e; + --border: #e2e0dc; --border-strong: #d6d3d1; --hairline: #e7e5e4; --code-bg: #f8f7f5; --code-head: #f1f0ee; --code-fg: #292524; + --eyebrow: #5E7500; --link: #07401A; + --syn-comment: #57534e; --syn-keyword: #6b21a8; --syn-string: #3f6212; --syn-type: #0e7490; --syn-function: #1d4ed8; + --syn-number: #9a3412; --syn-property: #1e3a8a; --syn-punctuation: #57534e; --syn-operator: #44403c; --syn-tag: #b91c1c; + color-scheme: light; + } + body { background: #fff; font-size: 11pt; line-height: 1.55; } + .site-header, .sidebar, .rail, .doc-top, .toc-inline, .page-tools, .pager, .doc-footer, .mobile-overlay, .toast, dialog, .skip, .copy-button, .heading-anchor, .trace-steps, .explorer-top .segmented, .explorer-footer button { display: none !important; } + body[data-layout] .shell { display: block !important; } + body[data-layout] main { padding: 0 !important; } + body[data-layout] main > * { max-width: none !important; } + .js .page.active > section { display: block !important; animation: none !important; } + .js .reveal { opacity: 1 !important; transform: none !important; } + .doc-section { break-before: page; } + .page.active > section:first-child { break-before: auto; } + .doc-section h2 { font-size: 26pt; line-height: 1.2; } + .doc-section h3 { font-size: 16pt; line-height: 1.3; break-after: avoid; margin-top: 24pt; } + .doc-section p, .doc-section li { font-size: 10.5pt; line-height: 1.55; } + .doc-section > h2 + p { font-size: 12pt; line-height: 1.5; } + pre, .code-panel pre { max-height: none !important; overflow: visible !important; white-space: pre-wrap; font-size: 8pt; line-height: 1.5; } + .code-panel pre { -webkit-mask-image: none !important; mask-image: none !important; } + .code-panel .code-lang { display: none; } + pre code { white-space: pre-wrap; overflow-wrap: anywhere; } + .code-panel, .table-wrap, .explorer, .callout { break-inside: avoid-page; box-shadow: none !important; } + .table-wrap { overflow: visible; } + tr { break-inside: avoid; } + a { color: inherit !important; } + .explorer pre { min-height: 0; } + @page { margin: 16mm 14mm; } +} + +/* ===== landing ===== */ +/* Three business-first sections: the reliability band (content ships with the site), the hosted + Deco CMS (publishing without a redeploy) and "Small on purpose" (ownership, no lock-in). */ +.landing { --pub-live-bg: #07401A; --pub-live-fg: #D0EC1A; --tag-yours-bg: #07401A; } +:root[data-theme="dark"] .landing { --pub-live-bg: #D0EC1A; --pub-live-fg: #07401A; --tag-yours-bg: #0B4A20; } +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .landing { --pub-live-bg: #D0EC1A; --pub-live-fg: #07401A; --tag-yours-bg: #0B4A20; } +} + +/* "For developers:" links: the prefix steps back so a skimming reader can pass it by */ +.dev-link { display: block; width: fit-content; max-width: 100%; margin-top: 36px; } +.dev-link .dev-k { font-weight: 400; color: var(--muted-fg); } +.dev-link .icon { display: inline-block; margin-left: 6px; vertical-align: -2px; } +.dev-link .dl-end { white-space: nowrap; } +.prod .dev-link { color: var(--lime); } +.prod .dev-link .dev-k { color: var(--band-muted); } + +/* ---- Reliability band: the forest panel, three checked points + developer link left, a status window right. + The window comes first in the source so phones see it right after the lede (and focus order matches). ---- */ +.rely .section-head { position: relative; max-width: 900px; margin-bottom: 52px; } +.rely-grid { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.04fr); grid-template-rows: auto 1fr; column-gap: 64px; align-items: start; } +.rely-grid > .status { grid-column: 2; grid-row: 1 / span 2; } +.rely-points { grid-column: 1; grid-row: 1; list-style: none; margin: 0; padding: 0; border-bottom: 1px solid var(--band-line); } +.rely-grid > .dev-link { grid-column: 1; grid-row: 2; } +.rely-points li { display: grid; grid-template-columns: 32px minmax(0, 1fr); padding: 18px 0 19px; border-top: 1px solid var(--band-line); } +.rely-points li > .icon { width: 16px; height: 16px; margin-top: 3px; stroke-width: 2.25; color: var(--lime); } +.rp-body { display: grid; gap: 4px; } +.rp-body strong { font-size: 16px; line-height: 22px; font-weight: 500; letter-spacing: -.005em; color: #fff; } +.rp-body > span { font-size: 14.5px; line-height: 1.55; color: var(--band-muted); } +.prod .win :focus-visible { outline-color: var(--ring); } + +.status { border-radius: 16px; border-color: rgba(255, 255, 255, .14); box-shadow: 0 0 0 1px rgba(0, 0, 0, .05), 0 44px 90px -34px rgba(0, 0, 0, .6); } +.status-body { padding: 26px 22px 8px; } +.pkg { position: relative; padding: 20px 14px 14px; border: 1.5px dashed var(--border-strong); border-radius: 14px; } +.pkg-label { position: absolute; top: -10px; left: 12px; margin: 0; padding: 0 8px; background: var(--surface); font-size: 12px; line-height: 18px; letter-spacing: .02em; text-transform: uppercase; color: var(--eyebrow); } +.pkg-tiles { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: stretch; gap: 8px; } +.pkg-plus { align-self: center; } +.pkg-plus { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); font-size: 14px; line-height: 1; color: var(--muted-fg); } +.pkg-tile { min-width: 0; display: grid; align-content: start; gap: 1px; padding: 12px 12px 13px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-subtle); } +.pkg-tile .icon { width: 16px; height: 16px; margin-bottom: 8px; color: var(--muted-fg); } +.pkg-tile strong { font-size: 14.5px; line-height: 20px; font-weight: 500; letter-spacing: -.005em; color: var(--fg); } +.pkg-tile small { font-size: 11.5px; line-height: 17px; color: var(--muted-fg); } +.pkg-tile.is-content { background: var(--tint); border-color: color-mix(in oklab, var(--olive-ring) 40%, transparent); } +.pkg-tile.is-content .icon, .pkg-tile.is-content strong { color: var(--tint-fg); } +.pkg-tile.is-content small { color: color-mix(in oklab, var(--tint-fg) 78%, transparent); } +.pkg-cap { margin: 12px 0 0; text-align: center; font-size: 12.5px; line-height: 18px; color: var(--muted-fg); } +.st-rows { list-style: none; margin: 14px 0 0; padding: 0; } +.st-rows li { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 12px; min-height: 52px; padding: 10px 2px; } +.st-rows li + li { border-top: 1px solid var(--hairline); } +.st-k { font-size: 14px; line-height: 20px; color: var(--fg); } +.st-pill { display: inline-flex; align-items: center; gap: 7px; height: 28px; padding: 0 12px 0 10px; border-radius: 999px; font-size: 12.5px; line-height: 16px; font-weight: 500; white-space: nowrap; } +.st-pill i { width: 7px; height: 7px; border-radius: 50%; flex: none; transition: background-color .3s ease, box-shadow .3s ease; } +.st-pill .icon { width: 13px; height: 13px; stroke-width: 2.25; } +.st-pill.is-serving { background: var(--pill-on-bg); color: var(--pill-on-fg); box-shadow: inset 0 0 0 1px var(--pill-on-ring); } +.st-pill.is-serving i { background: var(--lime); box-shadow: 0 0 0 3px rgba(208, 236, 26, .22); } +.st-pill.is-cms { border: 1px solid var(--border-strong); background: var(--surface); color: var(--fg); } +.st-pill.is-cms i { background: var(--yellow); box-shadow: 0 0 0 3px rgba(255, 193, 22, .22); } +.st-pill.is-ok { background: var(--tint); color: var(--tint-fg); } +.st-up { display: none; } +.st-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding: 12px 22px 13px; border-top: 1px solid var(--hairline); background: var(--win-bar); } +.st-foot { margin: 0; font-size: 12.5px; line-height: 18px; color: var(--muted-fg); } +.st-switch { display: inline-flex; align-items: center; gap: 10px; margin-left: auto; font-size: 13px; line-height: 18px; color: var(--fg); cursor: pointer; user-select: none; -webkit-user-select: none; } +.st-switch input { -webkit-appearance: none; appearance: none; position: relative; flex: none; width: 34px; height: 20px; margin: 0; border-radius: 999px; background: var(--st-track); cursor: pointer; transition: background-color .25s ease; } +.st-switch input::before { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: var(--st-bg); box-shadow: 0 1px 2px rgba(0, 0, 0, .25); transition: transform .25s var(--ease); } +.st-switch input:checked { background: var(--st-fill); } +.st-switch input:checked::before { transform: translateX(14px); } +.st-switch input:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +/* The switch is the whole interaction: off means the CMS is back. Site and visitors never change; + the CMS pill and the footnote swap their .st-down / .st-up text. */ +.status:has(.st-switch input:not(:checked)) .st-down { display: none; } +.status:has(.st-switch input:not(:checked)) .st-up { display: inline; } +.status:has(.st-switch input:not(:checked)) .is-cms i { background: var(--lime); box-shadow: 0 0 0 3px rgba(140, 170, 37, .28); } + +/* ---- Hosted Deco CMS: two lanes on one six-column grid; the second skips Build and Deploy ---- */ +.pub { border-radius: 16px; } +.pub-lane { padding: 26px 28px 20px; } +.pub-lane.is-hosted { border-top: 1px solid var(--hairline); background: color-mix(in oklab, var(--tint) 70%, var(--surface)); } +.pub-label { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; margin: 0 0 24px; font-size: 13.5px; line-height: 20px; color: var(--muted-fg); } +.pub-label b { font-weight: 500; color: var(--fg); } +.pub-steps, .pub-notes { --gap: 28px; display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); column-gap: var(--gap); } +.pub-steps { list-style: none; margin: 0; padding: 0; } +.pub-steps li { position: relative; grid-column: var(--c); grid-row: 1; min-width: 0; height: 40px; display: flex; align-items: center; justify-content: center; padding: 0 10px; border: 1px solid var(--border-strong); border-radius: 999px; background: var(--surface); font-size: 14px; line-height: 18px; font-weight: 500; letter-spacing: -.005em; color: var(--fg); text-align: center; white-space: nowrap; } +/* Dashed connectors in the gaps, with a small arrowhead into each step */ +.pub-steps li + li::before { content: ""; position: absolute; top: 50%; right: calc(100% + 1px); width: var(--gap); height: 1px; background: repeating-linear-gradient(to right, var(--border-strong) 0 4px, transparent 4px 8px); } +.pub-steps li + li::after { content: ""; position: absolute; top: calc(50% - 3px); right: calc(100% + 3px); width: 6px; height: 6px; border-top: 1px solid var(--muted-fg); border-right: 1px solid var(--muted-fg); transform: rotate(45deg); opacity: .7; } +.pub-steps li.is-far::before { width: calc(100% + 2 * var(--gap)); } +.pub-steps .is-skip { border-style: dashed; background: transparent; color: var(--muted-fg); text-decoration: line-through; text-decoration-thickness: 1px; } +.pub-steps .is-merged { display: none; } +.pub-steps .is-live { border-color: transparent; background: var(--pub-live-bg); color: var(--pub-live-fg); box-shadow: 0 6px 18px -8px rgba(7, 64, 26, .55); } +.pub-steps .is-end { color: var(--fg); } +.pub-badge { position: absolute; left: 50%; bottom: calc(100% - 9px); transform: translateX(-50%); display: inline-flex; align-items: center; gap: 5px; height: 20px; padding: 0 8px; border-radius: 999px; background: var(--pill-on-bg); box-shadow: inset 0 0 0 1px var(--pill-on-ring); font-size: 10.5px; line-height: 14px; font-weight: 500; letter-spacing: 0; color: var(--pill-on-fg); white-space: nowrap; } +.pub-badge i { width: 5px; height: 5px; border-radius: 50%; background: var(--lime); } +.pub-notes { margin-top: 10px; min-height: 30px; font-size: 12.5px; line-height: 18px; color: var(--muted-fg); } +.pub-bracket { position: relative; grid-column: 4 / 6; padding-top: 12px; text-align: center; } +.pub-bracket::before { content: ""; position: absolute; top: 0; left: 18%; right: 18%; height: 7px; border: 1px solid var(--border-strong); border-top: 0; border-radius: 0 0 7px 7px; } +.pub-bracket.is-skip::before { border-style: dashed; } +.pub-cap { grid-column: 6; justify-self: center; padding-top: 12px; text-align: center; white-space: nowrap; } +.pub-cap.is-live { font-weight: 500; color: var(--tint-fg); } +.pub-points { list-style: none; margin: 28px 0 0; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; border: 1px solid var(--border); border-radius: 16px; background: var(--border); overflow: hidden; } +.pub-points li { min-width: 0; display: flex; flex-direction: column; padding: 24px 26px 30px; background: var(--bg); } +.pub-points strong { margin: 0 0 8px; font-size: 19px; line-height: 1.3; font-weight: 400; letter-spacing: -.012em; color: var(--fg); } +.pub-points li > span:last-child { font-size: 14.5px; line-height: 1.6; color: var(--muted-fg); } + +/* ---- Small on purpose: head + ownership map left, the plain-language links right ---- */ +.hood-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 80px; align-items: start; } +.hood-right { padding-top: 46px; } +.hood-grid .hood-links { margin-top: 0; } +.hood-grid .hood-links a { grid-template-columns: minmax(0, 1fr) 20px; gap: 8px 16px; padding: 22px 4px 24px; } +/* The destination is a quiet trailing line under each benefit, not a tag above it */ +.hood-grid .hood-links .hl-body { grid-column: 1; } +.hood-grid .hood-links .hl-body .hl-k { justify-self: start; margin-top: 6px; padding: 0; border-radius: 0; background: none; box-shadow: none; font-size: 13px; line-height: 20px; color: var(--muted-fg); transition: color .3s ease; } +.hood-grid .hood-links a:hover .hl-k { color: var(--link); } +.final .btn-white { background: var(--surface); color: var(--fg); box-shadow: inset 0 0 0 1px var(--border-strong); } +.final .btn-white:hover { background: var(--bg-subtle); } +.own { margin-top: 40px; } +.own-title { margin: 0 0 12px; font-size: 14px; line-height: 20px; font-weight: 500; color: var(--fg); } +.own-map { margin: 0; display: grid; gap: 1px; overflow: hidden; border: 1px solid var(--border); border-radius: 16px; background: var(--border); } +.own-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; column-gap: 20px; padding: 14px 18px 15px; background: var(--bg); } +.own-row dt { grid-column: 1; font-size: 15px; line-height: 22px; font-weight: 500; letter-spacing: -.005em; color: var(--fg); } +.own-row dd { margin: 0; } +.own-where { grid-column: 1; font-size: 14px; line-height: 20px; color: var(--muted-fg); } +.own-tag { grid-column: 2; grid-row: 1 / span 2; } +.tag { height: 24px; display: inline-flex; align-items: center; padding: 0 11px; border-radius: 999px; font-size: 12px; line-height: 16px; font-weight: 500; white-space: nowrap; } +.tag-yours { background: var(--tag-yours-bg); color: var(--lime); } +.tag-opt { box-shadow: inset 0 0 0 1px var(--border-strong); color: var(--muted-fg); } + +@media (max-width: 1279px) { + .rely-grid { column-gap: 48px; } +} +@media (max-width: 1099px) { + .pub-points { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .pub-lane { padding: 24px 22px 18px; } + .pub-steps, .pub-notes { --gap: 20px; } + .pub-steps li { font-size: 13.5px; padding: 0 8px; } + .hood-grid { column-gap: 56px; } +} +@media (min-width: 640px) { + /* The dimmed clause starts its own line, like the other two-clause headings */ + .rely h2 .dim { display: block; } +} +@media (max-width: 999px) { + /* Each lane turns vertical below 1000px (the six pills get too tight); the two lanes sit side by side + so Build and Deploy still line up */ + .pub-body { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-rows: auto auto auto; } + .pub-lane { display: grid; grid-row: span 3; grid-template-rows: subgrid; align-content: start; padding: 20px 16px 16px; } + .pub-lane.is-hosted { border-top: 0; border-left: 1px solid var(--hairline); } + .pub-label { align-items: flex-start; align-content: flex-start; margin-bottom: 20px; font-size: 13px; } + .pub-sep { display: none; } + .pub-sub { flex-basis: 100%; } + .pub-steps, .pub-notes { --gap: 16px; grid-template-columns: minmax(0, 1fr); column-gap: 0; } + .pub-steps { grid-template-rows: repeat(6, 38px); row-gap: var(--gap); justify-items: stretch; } + .pub-steps li { grid-column: 1; grid-row: var(--c); width: 100%; max-width: 240px; height: 38px; justify-self: center; font-size: 13.5px; } + .pub-steps li + li::before { top: auto; right: auto; bottom: calc(100% + 1px); left: 50%; width: 1px; height: var(--gap); background: repeating-linear-gradient(to bottom, var(--border-strong) 0 4px, transparent 4px 8px); } + .pub-steps li + li::after { top: -8px; right: auto; left: calc(50% - 3px); transform: rotate(135deg); } + .pub-steps li.is-far::before { width: 1px; height: calc(100% + 2 * var(--gap) + 2px); } + .pub-steps li.is-skip:not(.is-merged) { display: none; } + .pub-steps li.is-merged { display: flex; grid-row: 4 / 6; height: auto; align-self: stretch; white-space: normal; font-size: 13px; line-height: 17px; text-decoration: none; border-radius: 18px; color: var(--fg); } + .pub-badge { display: none; } + .pub-notes { margin-top: 12px; min-height: 0; } + .pub-bracket { display: none; } + .pub-cap { grid-column: 1; padding-top: 0; white-space: normal; } +} +@media (max-width: 899px) { + .rely-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: none; row-gap: 40px; } + /* One column in source order: the window right after the lede, then the points, then the link */ + .rely-grid > .status, .rely-points, .rely-grid > .dev-link { grid-column: 1; grid-row: auto; } + .rely-grid > .dev-link { margin-top: -4px; } + .hood-grid { grid-template-columns: minmax(0, 1fr); } + .hood-right { padding-top: 48px; } +} +@media (max-width: 639px) { + .pub-points { grid-template-columns: minmax(0, 1fr); } + .pub-points li { padding: 18px 16px 24px; } + .pub-points strong { font-size: 18px; } + .pub-lane { padding: 18px 10px 14px; } + .pub-label { font-size: 12.5px; line-height: 18px; } + .pub-steps li { font-size: 13px; padding: 0 6px; } + .status-body { padding: 24px 14px 6px; } + .st-bar { padding: 12px 14px; } + .st-switch { order: -1; margin-left: 0; } + .pkg { padding: 20px 10px 12px; } + .pkg-tiles { gap: 6px; } + .pkg-tile { padding: 10px 10px 11px; } + .pkg-tile strong { font-size: 13.5px; } + .pkg-tile small { font-size: 11.5px; line-height: 16px; } + .rely-points li { grid-template-columns: 28px minmax(0, 1fr); } + .pub .win-title { left: 64px; right: 14px; text-align: right; } + .own-row { padding: 13px 14px 14px; column-gap: 12px; } + .hood-grid .hood-links a { padding: 18px 0 20px; } +} From 52028bd83ef2d67329ca3e38ec6cd65f22d44833 Mon Sep 17 00:00:00 2001 From: gimenes Date: Wed, 30 Sep 2026 09:55:40 -0300 Subject: [PATCH 002/132] docs(site): put text and code in one column Prose, code blocks, tables, callouts and diagrams now share the same 720px column, so their left and right edges line up. This drops the 70ch cap on paragraphs, the wide-screen breakout of code and tables, and the edge-to-edge code panels on phones. Code lines longer than the column scroll inside their panel. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/src/style.css | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/site/src/style.css b/site/src/style.css index 1e973a7c..63020ebc 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -297,7 +297,9 @@ body[data-layout="landing"] .pager, body[data-layout="landing"] .doc-footer { display: none; } main { display: block; min-width: 0; outline: none; } body[data-layout="docs"] main { padding: 40px 56px 96px; } -body[data-layout="docs"] main > * { max-width: 768px; margin-left: auto; margin-right: auto; } +/* One column for everything in an article: prose, code, tables, callouts and diagrams share the + same left and right edges. Code lines longer than the column scroll inside their panel. */ +body[data-layout="docs"] main > * { max-width: 720px; margin-left: auto; margin-right: auto; } .js .page { display: none; } .js .page.active { display: block; } .js .page > section:not(.is-current) { display: none; } @@ -457,7 +459,6 @@ body[data-page="roadmap"] .sidebar-foot .sf-link { display: none; } .doc-section h2 code, .doc-section h3 code { font-size: .86em; padding: 0; background: none; border: 0; color: inherit; font-weight: 400; letter-spacing: -.03em; } .doc-section p, .doc-section li { color: var(--fg-body); font-size: 16px; line-height: 1.72; } .doc-section p { margin: 16px 0; } -.doc-section > p, .doc-section > ul, .doc-section > ol { max-width: 70ch; } .doc-section ul, .doc-section ol { margin: 18px 0; padding-left: 22px; } .doc-section li { padding-left: 6px; } .doc-section li + li { margin-top: 8px; } @@ -548,21 +549,6 @@ pre code { font: inherit; font-variant-ligatures: none; background: none; border .code-panel.is-diagram pre { line-height: 1.3; } .code-panel.is-cmd pre code::before { content: "$"; margin-right: 12px; color: var(--eyebrow); font-weight: 500; user-select: none; -webkit-user-select: none; } -/* Wide screens: code, tables and diagrams use the article's side gutters (up to 48px each side, - always leaving 24px to the sidebar and the rail), so fewer lines run past the panel edge. */ -@media (min-width: 1360px) { - body[data-layout="docs"] { - --bleed: clamp(0px, (min(100vw, var(--hdr-max)) - 528px - 768px) / 2 - 24px, 48px); - } - body[data-layout="docs"] .doc-section > .code-panel, - body[data-layout="docs"] .doc-section > .code-example, - body[data-layout="docs"] .doc-section > .table-wrap, - body[data-layout="docs"] .doc-section > .flow, - body[data-layout="docs"] .doc-section > .explorer { - width: auto; margin-left: calc(-1 * var(--bleed)); margin-right: calc(-1 * var(--bleed)); - } -} - /* Syntax */ pre .token { background: none; text-shadow: none; } pre .token.comment, pre .token.prolog, pre .token.doctype, pre .token.cdata { color: var(--syn-comment); font-style: italic; } @@ -1240,7 +1226,6 @@ dialog::backdrop { background: rgba(14, 26, 16, .3); -webkit-backdrop-filter: bl .doc-section > h2 + p { font-size: 17px; } .doc-section h3 { font-size: 23px; margin-top: 48px; } .doc-section p, .doc-section li { font-size: 16px; line-height: 1.7; } - .doc-section .code-panel:not(.is-oneline) { margin-left: -16px; margin-right: -16px; border-radius: 0; border-left: 0; border-right: 0; } .code-panel pre { padding: 14px 16px; font-size: 12.5px; line-height: 21px; } .table-wrap { border-radius: 12px; } table { font-size: 13.5px; } From 9cc91d1a050baebe5990709a095bc738e262db27 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 13:23:30 -0300 Subject: [PATCH 003/132] docs(site): frame the home page around stability, publish in seconds The stability section no longer talks about CMS outages: no outage switch, no "unreachable" status, and the points now speak to speed and stability by design. The hosted-CMS section says changes go live in seconds instead of about five minutes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/src/landing.html | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/site/src/landing.html b/site/src/landing.html index 39332954..4665bf08 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -144,8 +144,8 @@

        Native for AI

        -

        Nothing extra to run

        -

        Your site ships with its content. If the hosted CMS fails, your site doesn't.

        +

        Built for stability

        +

        Your site ships with its content. Nothing stands between it and your visitors.

        Your pages, banners and settings are files in your own repository, and every deploy carries them alongside the code. The content your visitors see is already inside your site, even when you publish through the optional hosted Deco CMS.

        @@ -162,22 +162,18 @@

        Your site ships with its content. Packed together, tested together.

          -
        • Your siteServing
        • -
        • Hosted Deco CMSUnreachableConnected
        • -
        • Visitors{{icon:check}}No errors
        • +
        • Your siteOnline
        • +
        • Content{{icon:check}}Inside this deploy
        • +
        • Page loads{{icon:check}}No extra round trip
        -
        -

        New changes go live once it's back.Publishing works as usual.

        - -
        • {{icon:check}}No content servers to runNo content database or content server to host, scale or keep online.
        • -
        • {{icon:check}}Never slowed by the CMSEvery page your visitors see is built from content your site already holds, so a slow CMS can't slow your site down.
        • -
        • {{icon:check}}Stays up through outagesIf the hosted Deco CMS can't be reached, your site keeps serving the content it already has, with no error pages. A server that starts during the outage uses the content from your last deploy; new changes go live once the CMS is back.
        • +
        • {{icon:check}}Fast on every pageEvery page is built from content your site already holds, so it loads as fast as your own code.
        • +
        • {{icon:check}}Stable by designYour site depends on no other service to show its content. Every deploy carries a tested copy of it, so what visitors see is always a version you shipped.
        - For developers: how content loads and falls back{{icon:arrow-right}} + For developers: how content loads{{icon:arrow-right}} @@ -187,8 +183,8 @@

        Your site ships with its content.

        Hosted Deco CMS · optional

        -

        Publish in minutes, not on the next deploy.

        -

        The hosted Deco CMS adds Deco Studio, the visual editor, and publishing without a redeploy: each change reaches every visitor within about five minutes, and editors preview drafts on your real pages first. Without it, developers and agents edit the files, and content goes live with your next deploy.

        +

        Publish in seconds, not on the next deploy.

        +

        The hosted Deco CMS adds Deco Studio, the visual editor, and publishing without a redeploy: each change reaches every visitor in seconds, and editors preview drafts on your real pages first. Without it, developers and agents edit the files, and content goes live with your next deploy.

      -
      not neededwithin about five minutes
      +
      not neededin seconds
      @@ -279,7 +275,7 @@

      Your function stays. Its i loader: { token: process.env.DECO_SITE_TOKEN }, blocks, }); -

      {{icon:check}}With the hosted Deco CMS (the token above), a commit is live everywhere within about five minutes, no deploy. Without it, content ships with each deploy.

      +

      {{icon:check}}With the hosted Deco CMS (the token above), a commit is live everywhere in seconds, no deploy. Without it, content ships with each deploy.

      From d9421b2ef774619fc22508da9a400beec37f9cbd Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 13:27:19 -0300 Subject: [PATCH 004/132] docs(site): remove the "next major" notices Drops the preview badge and notes from the home hero, the home footer, the docs sidebar, the docs footer and the top of "How it works". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/src/content.html | 1 - site/src/landing.html | 5 ----- site/src/shell.html | 6 +----- 3 files changed, 1 insertion(+), 11 deletions(-) diff --git a/site/src/content.html b/site/src/content.html index be4871ec..81020937 100644 --- a/site/src/content.html +++ b/site/src/content.html @@ -10,7 +10,6 @@

      Getting started

      How it works

      -

      Next major. This reference describes the proposed API for the next major version of Deco Blocks. The APIs shown here and the deco schema command aren't in the current @decocms/blocks release.

      Deco Blocks has two pieces you install, and one you don't:

      diff --git a/site/src/landing.html b/site/src/landing.html index 4665bf08..fe711962 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -3,7 +3,6 @@
      - Next major · preview — proposed API, not yet released

      Headless for developers.
      Editable for humans.
      Native for AI.

      Deco Blocks is the AI-native headless CMS. Developers write the functions, marketers edit the content and settings those functions use in Deco Studio's visual editor, AI agents edit the same content as files, and every change lands in Git.

      @@ -343,10 +342,6 @@

      Start with one function. S GitHubContributingUpgradingRoadmap

      -
      diff --git a/site/src/shell.html b/site/src/shell.html index 911154e9..f22cbe79 100644 --- a/site/src/shell.html +++ b/site/src/shell.html @@ -69,10 +69,6 @@ -
      @@ -104,7 +100,7 @@

      Deco Blocks documentation

      - Deco Blocks · Next major + Deco Blocks
      From 86161e899ab3fad0cb42ef698426c3639a301544 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 13:32:56 -0300 Subject: [PATCH 005/132] docs(site): default the loader to ./.deco and document how to configure it createCMS's loader option is optional and defaults to "./.deco", so the quickstart example drops it. "Loaders and deployment" gains a "Configuring the loader" subsection: the default, a different root directory, the hosted Deco CMS token (the site's API key, kept in an environment variable), and both together. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/src/content.html | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/site/src/content.html b/site/src/content.html index 81020937..e36f86d1 100644 --- a/site/src/content.html +++ b/site/src/content.html @@ -104,11 +104,11 @@

      3. Add content

      }

      4. Read the content from your code

      -

      Create the CMS once, over the .deco directory and your block map. Ask it for a client, then fetch the entry by name. The client runs experiments on the saved inputs, so you get the same result as the direct call, with the same type:

      +

      Create the CMS once with your block map. It reads the .deco directory by default, so there's nothing else to configure (to change the directory or connect the hosted Deco CMS, see Configuring the loader). Ask it for a client, then fetch the entry by name. The client runs experiments on the saved inputs, so you get the same result as the direct call, with the same type:

      src/cms/cms.ts
      import { createCMS } from "@decocms/blocks";
       import blocks from "../../cms.index";
       
      -export const cms = createCMS({ loader: "./.deco", blocks });
      +export const cms = createCMS({ blocks });
      checkout.ts
      import { cms } from "./src/cms/cms";
       
       const client = cms.forRelease();
      @@ -572,7 +572,7 @@ 

      Choose a transport

      createCMS(config)

      Two levels. A CMS is a loader plus a block map, created once at module scope. It hands out clients, one per request, for the release or for a draft, and keeps content current. A client resolves and lists, and that's all. Both are cheap: content caches in the loader, and dynamically imported modules are cached by the runtime.

      function createCMS(config: {
      -  loader:
      +  loader?:                               // optional: defaults to "./.deco"
           | string                             // a content directory: localLoader(path)
           | { root?: string; token?: string }  // local content in root (default "./.deco"); with a token, remote releases over it
           | Loader;                            // anything else: bundled content, KV, your own
      @@ -589,7 +589,7 @@ 

      createCMS(config)

      resolve<T = unknown>(target: unknown, options?: { resolve?: boolean }): Promise<Result<T>>; // resolve: false reads without running list<T = Block>(type: string, options?: ListOptions<T>): Promise<Result<T[]>>; }
      -

      The loader option covers the common cases by shape. A path is the files in that directory. { token } is releases from the Deco API with the files in ./.deco as fallback, and root changes the directory. Leave token undefined and it's just the files, which is what makes one line work in development and production. Anything else is a Loader you built yourself. Create the CMS once, at module scope, and ask it for a client per request: forRelease() for visitors, forDraft(pointer) when the request carries one. Clients are free to make and hold nothing you'd want to share. Everything either does is a few lines over a loader, and the registry rule from Blocks is how it resolves; those internals aren't part of the public API.

      +

      The loader option covers the common cases by shape. Leave it out and the CMS reads the files in ./.deco. A path is the files in that directory. { token } is releases from the Deco API with the files in ./.deco as fallback, and root changes the directory. Leave token undefined and it's just the files, which is what makes one line work in development and production. Anything else is a Loader you built yourself. Create the CMS once, at module scope, and ask it for a client per request: forRelease() for visitors, forDraft(pointer) when the request carries one. Clients are free to make and hold nothing you'd want to share. Everything either does is a few lines over a loader, and the registry rule from Blocks is how it resolves; those internals aren't part of the public API.

      Loaders

      One interface. load() with no argument is production; load(pointer) is a draft. update() checks for newer content.

      @@ -1132,6 +1132,24 @@

      4. Configure Cloudflare Workers

      PieceWhat it doesWhere it runs

      Revisions are immutable: change any entry and the hash changes. A revision is a safe cache key everywhere.

      +

      Configuring the loader

      +

      Most apps never pass a loader: createCMS reads the ./.deco directory by default. Pass an object to keep your content somewhere else, to connect the hosted Deco CMS, or both:

      +
      src/cms/cms.ts
      // Default: the files in ./.deco
      +createCMS({ blocks });
      +
      +// Content in another directory
      +createCMS({ loader: { root: "./content/.deco" }, blocks });
      +
      +// Releases from the hosted Deco CMS, with the files in ./.deco as the fallback
      +createCMS({ loader: { token: process.env.DECO_SITE_TOKEN }, blocks });
      +
      +// Both: hosted releases, with ./content/.deco as the fallback
      +createCMS({
      +  loader: { root: "./content/.deco", token: process.env.DECO_SITE_TOKEN },
      +  blocks,
      +});
      +

      token is the site's API key for the Deco API. Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads the files only, so the same line works everywhere. For anything else, such as content bundled into a Workers build or your own store, pass a Loader (see API reference).

      +

      One revision per response

      A client reads one revision for its lifetime: its first call loads, and every later list and resolve reuses that content, even concurrent ones. Two clients can see different revisions, so make one client per request and do everything the response needs through it.

      When the browser requests more data for a page it has already rendered, send the page's revision along so the data matches the HTML. A revision ID picks content; it doesn't grant access. Preview access is the draft pointer's signed grant and the site's preview hosts on decocms, see Preview.

      From e554571de77972f270a180ac326c69fab6ac97d9 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 13:38:39 -0300 Subject: [PATCH 006/132] docs(site): set up the CMS inline in the home page's "Resolve it" step The hero's fourth step now shows the whole setup in one file: createCMS({ blocks: { experiments } }) and the resolve call, instead of importing a cms module the page never shows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/src/landing.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/landing.html b/site/src/landing.html index fe711962..e85c6bc6 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -54,7 +54,7 @@

      Headless for developers.

      From f7ccb66f6f1e06b7eef1e12d4cea356108575cc9 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 13:39:29 -0300 Subject: [PATCH 007/132] docs(site): move the example function from config/experiments.ts to experiments.ts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/src/content.html | 6 +++--- site/src/landing.html | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/site/src/content.html b/site/src/content.html index e36f86d1..49de196d 100644 --- a/site/src/content.html +++ b/site/src/content.html @@ -51,7 +51,7 @@

      Small on purpose

      Getting started

      Quickstart

      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:

      -
      config/experiments.ts
      export interface Experiments {
      +
      experiments.ts
      export interface Experiments {
         /** @title New checkout flow @minimum 0 @maximum 100 */
         newCheckout: number;
         /** @title Sticky header @minimum 0 @maximum 100 */
      @@ -69,7 +69,7 @@ 

      Small on purpose

      freeShippingBanner: roll(input.freeShippingBanner), }; }
      -
      checkout.ts
      import experiments from "./config/experiments";
      +
      checkout.ts
      import experiments from "./experiments";
       
       experiments({ newCheckout: 10, stickyHeader: 50, freeShippingBanner: 0 });
       // { newCheckout: false, stickyHeader: true, freeShippingBanner: false }
      @@ -80,7 +80,7 @@

      Small on purpose

      1. Make the function configurable

      Any function with a typed first parameter can be a block. Give it a name in a block map. The key, experiments, is the block type that content will refer to. This file is plain data: the CLI reads it, and your app hands it to createCMS.

      cms.index.ts
      import type { Blocks } from "@decocms/blocks";
      -import experiments from "./config/experiments";
      +import experiments from "./experiments";
       
       export default { experiments } satisfies Blocks;
      diff --git a/site/src/landing.html b/site/src/landing.html index e85c6bc6..9714834b 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -17,7 +17,7 @@

      Headless for developers.
      Deco Blocks · my-store — Experiments
      @@ -242,14 +242,14 @@

      Your function stays. Its i
      -
      // config/experiments.ts: the function you already have
      +
      // experiments.ts: the function you already have
       export default function experiments(input: Experiments) {
         return { newCheckout: roll(input.newCheckout), /* … */ };
       }
       
       // cms.index.ts: give it a name in a block map
       import type { Blocks } from "@decocms/blocks";
      -import experiments from "./config/experiments";
      +import experiments from "./experiments";
       
       export default { experiments } satisfies Blocks;

      {{icon:check}}The key experiments is now a block type content can refer to.

      From 290a268903e6d8a986c7bc9a16cff78d5513f4df Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 13:53:36 -0300 Subject: [PATCH 008/132] docs(site): simplify the code examples across the docs - Flatter, shorter example file names, one name per file everywhere: cms.index.ts -> blocks.ts, src/cms/cms.ts -> cms.ts, content/* and views/* moved to the root, the framework guides' src/cms/*, src/views/*, src/server/* -> src/*. Framework-mandated paths (app/.../page.tsx, src/routes/*, .deco/*) stay. - The guides' base map, product block and block map merge into one src/blocks.ts(x) each. - Options equal to their defaults and no-op lines are dropped (runtime = 'nodejs', a bare `enabled;`, needless variables, duplicate react imports). - Correctness fixes found on the way: "get" references renamed to resolve, the multivariate map's missing imports, the alias snippet's imports, a stray

      . - The Experiments result is named `flags` instead of `enabled`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/src/app.js | 4 +- site/src/content.html | 186 ++++++++++++++++++++---------------------- site/src/landing.html | 12 +-- site/src/style.css | 2 +- 4 files changed, 96 insertions(+), 108 deletions(-) diff --git a/site/src/app.js b/site/src/app.js index ce6ebad8..d6d24de6 100644 --- a/site/src/app.js +++ b/site/src/app.js @@ -160,7 +160,7 @@ return btn; } const langBadge = label => { const lang = document.createElement('span'); lang.className = 'code-lang'; lang.textContent = label; lang.setAttribute('aria-hidden', 'true'); return lang; }; - // "src/cms/cms.ts" is a path; "A plain request handler" describes the block; "cms.index.tsx + // "cms.ts" is a path; "A plain request handler" describes the block; "blocks.tsx // (Next.js)" and ".deco/schema.json, abridged" are a path with a note. const PATH = /^([^\s,]*\/[^\s,]*|[^\s,]+\.[a-z]\w*)(?=$|[\s,])/i; $$('pre').forEach(pre => { @@ -864,7 +864,7 @@ 'The saved entry. Its product input names CurrentProduct, another saved entry.', 'Look up CurrentProduct: data. The reference is replaced with the entry, and the rule runs again on the result.', 'Look up catalog-product: function. Its inputs contain no blocks, so it runs, and the product data takes its place.', - 'Look up product-card: function. Its inputs are resolved, so it runs. get returns the result untouched.', + 'Look up product-card: function. Its inputs are resolved, so it runs. resolve returns the result untouched.', ], get: [ 'The saved entry. Its product input names CurrentProduct, another saved entry.', diff --git a/site/src/content.html b/site/src/content.html index 49de196d..d80729fc 100644 --- a/site/src/content.html +++ b/site/src/content.html @@ -79,14 +79,14 @@

      Small on purpose

      1. Make the function configurable

      Any function with a typed first parameter can be a block. Give it a name in a block map. The key, experiments, is the block type that content will refer to. This file is plain data: the CLI reads it, and your app hands it to createCMS.

      -
      cms.index.ts
      import type { Blocks } from "@decocms/blocks";
      +
      blocks.ts
      import type { Blocks } from "@decocms/blocks";
       import experiments from "./experiments";
       
       export default { experiments } satisfies Blocks;

      2. Give editors a form

      The CLI reads the map and turns the Experiments type into a JSON Schema. The JSDoc tags become labels and validation. Studio builds the editor form from it:

      -
      npx deco schema --entry ./cms.index.ts --out ./.deco/schema.json
      +
      npx deco schema --entry ./blocks.ts --out ./.deco/schema.json

      Commit the file. From now on, Studio shows each experiment as a number field clamped to 0–100.

      3. Add content

      @@ -105,18 +105,16 @@

      3. Add content

      4. Read the content from your code

      Create the CMS once with your block map. It reads the .deco directory by default, so there's nothing else to configure (to change the directory or connect the hosted Deco CMS, see Configuring the loader). Ask it for a client, then fetch the entry by name. The client runs experiments on the saved inputs, so you get the same result as the direct call, with the same type:

      -
      src/cms/cms.ts
      import { createCMS } from "@decocms/blocks";
      -import blocks from "../../cms.index";
      +
      cms.ts
      import { createCMS } from "@decocms/blocks";
      +import blocks from "./blocks";
       
       export const cms = createCMS({ blocks });
      -
      checkout.ts
      import { cms } from "./src/cms/cms";
      +
      checkout.ts
      import { cms } from "./cms";
       
       const client = cms.forRelease();
      -const [enabled, error] = await client.resolve("Experiments");
      +const [flags, error] = await client.resolve("Experiments");
       if (error) throw error;
      -
      -enabled;
      -// { newCheckout: false, stickyHeader: true, freeShippingBanner: false }
      +// flags: { newCheckout: false, stickyHeader: true, freeShippingBanner: false }

      Every call returns a [value, error] tuple, so a missing entry or a failing function never throws. Pass { resolve: false } to get the saved JSON instead of the function's result.

      That's it. The function didn't change, but its inputs now live in a file that editors change through a form and agents change directly, and every change is a commit. A real implementation would hash a visitor ID from a cookie instead of rolling a die, so each visitor gets a stable result. The CMS doesn't hand blocks the request; read it the way the rest of your app does (see Block maps).

      @@ -154,7 +152,7 @@

      Saved entries and references

      { "__resolveType": "SummerSEO", "title": "Summer sale" }

      An entry doesn't have to be a block itself. It can be any JSON value that contains blocks, like the page in the next example.

      -

      How get works

      +

      How resolve works

      Every __resolveType is a name looked up in one registry, and a name is either data or code:

      const registry = { ...savedEntries, ...blocks };   // name → saved JSON | function

      Saved entries come from the loader. Functions come from the block map you give createCMS. Resolution is one rule, applied to every object with a __resolveType:

      @@ -181,7 +179,7 @@

      The rule in detail

      • A saved entry is a preset. It's a block, or any JSON containing blocks, saved under a name. Referring to it by name and overriding a field is the same as writing the merged block inline. Overrides merge shallowly: an override replaces the whole property, and the saved entry itself doesn't change.
      • Bottom-up and parallel. A function only runs once its inputs are resolved, so product-card receives product data, never a catalog-product block. Sibling blocks, such as sections, resolve concurrently.
      • -
      • Failures stop the parent. If a nested block fails, its parent never runs and get returns the error. A reference to an entry that doesn't exist fails with NOT_FOUND.
      • +
      • Failures stop the parent. If a nested block fails, its parent never runs and resolve returns the error. A reference to an entry that doesn't exist fails with NOT_FOUND.
      • Results are opaque. The CMS never walks a function's return value. Return plain data, a descriptor, or JSX. This is also why a function that returns its input can't loop: __resolveType is stripped before the call, and whatever comes back is never looked up again.
      • Only data can loop. A data hit is the one branch that says "apply 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.
      • Stored form. To read an entry exactly as saved, with nothing expanded, take it from loader.load().
      • @@ -191,18 +189,18 @@

        The rule in detail

        Core concepts

        Schema generation

        -

        Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. Point it at the file that exports your block map, the cms.index.ts from the quickstart:

        -
        npx deco schema --entry ./cms.index.ts --out ./.deco/schema.json
        +

        Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. Point it at the file that exports your block map, the blocks.ts from the quickstart:

        +
        npx deco schema --entry ./blocks.ts --out ./.deco/schema.json

        For each block type in the map, the CLI reads the type of the function's first parameter and turns it into a form definition. Commit .deco/schema.json with your code, and run the command in CI so the schema never drifts from your types.

        The CLI also checks your content model: it fails if a saved entry's name collides with a block type, or if an alias collides with either.

        Content that is only data

        A page, a blog post, a redirect: these have a shape but no function. Nothing runs when they resolve; your app reads them and resolves the blocks inside them. They still need a schema so Studio can create them, so declare them for the CLI in a type-level map next to the block map:

        -
        cms.index.ts
        import type { Blocks, Redirect } from "@decocms/blocks";
        -import type { Page } from "./content/page";
        -import type { Post } from "./content/post";
        -import seo from "./content/seo";
        -import hero from "./views/hero";
        +
        blocks.ts
        import type { Blocks, Redirect } from "@decocms/blocks";
        +import type { Page } from "./page";
        +import type { Post } from "./post";
        +import seo from "./seo";
        +import hero from "./hero";
         
         export default { seo, hero } satisfies Blocks;          // code: a schema per function parameter
         
        @@ -269,11 +267,11 @@ 

        Who may preview

        Advanced

        Block maps

        -

        A block map is the code half of the registry: a plain object from block type to function. The quickstart's cms.index.ts is one. This section covers what else can go in it.

        -
        cms.index.ts
        import type { Blocks } from "@decocms/blocks";
        -import seo from "./content/seo";
        -import productCard from "./views/product-card";
        -import hero from "./views/hero";
        +  

        A block map is the code half of the registry: a plain object from block type to function. The quickstart's blocks.ts is one. This section covers what else can go in it.

        +
        blocks.ts
        import type { Blocks } from "@decocms/blocks";
        +import seo from "./seo";
        +import productCard from "./product-card";
        +import hero from "./hero";
         
         export default {
           seo,
        @@ -287,9 +285,9 @@ 

        Who may preview

        Lazy loading

        The map above imports every block at the top, so they all load with the map. That's fine for functions like seo. It's not what you want for a section that drags in a charting library, or for the two hundred sections a large store has when any page uses five.

        What's heavy is the component a block renders, and your framework already knows how to load components lazily. So do it in one place, the map, and let the block files stay plain components:

        -
        cms.index.tsx (Next.js)
        import dynamic from 'next/dynamic'
        +
        blocks.tsx (Next.js)
        import dynamic from 'next/dynamic'
         import type { Blocks } from '@decocms/blocks'
        -import seo from './content/seo'
        +import seo from './seo'
         import type { ProductProps } from './sections/Product'
         import type { HeroProps } from './sections/Hero'
         
        @@ -305,7 +303,7 @@ 

        Lazy loading

        Swap dynamic for React's lazy on TanStack RSC, with the sections rendered under a Suspense boundary. In TanStack's data mode the blocks return descriptors and the browser-side view registry is the map to do this in: const views = { product: lazy(() => import('./sections/Product')) }. In every case the section files know nothing about the CMS, the map stays a plain object the CLI can read, and the bundler splits on the import().

        For a block that's heavy for a non-UI reason, say a data function that pulls in a large SDK, a dynamic import inside the function body is enough. It's one line and needs no helper:

        "catalog-product": async (input: ProductQuery) => {
        -  const { fetchProduct } = await import("./commerce/client")   // loaded on first call, cached by the runtime
        +  const { fetchProduct } = await import("./commerce")   // loaded on first call, cached by the runtime
           return fetchProduct(input)
         },

        Keep import paths literal in both cases. A bundler can split import("./sections/Product") into its own chunk; it can't split import(`./sections/${name}`).

        @@ -329,13 +327,15 @@

        The three matchers that ship

        Everything else that could gate content, a device, a cookie, a country, a user's plan, depends on where your app runs and how it sees its environment. Those are yours to write, below.

        The multivariate is three lines you own

        -
        content/multivariate.ts
        export function multivariate<T>({ variants }: { variants: { rule: boolean; value: T }[] }) {
        +
        multivariate.ts
        export function multivariate<T>({ variants }: { variants: { rule: boolean; value: T }[] }) {
           return variants.find((v) => v.rule)?.value;   // first match wins; put the always() variant first as the default
         }
        -
        cms.index.ts
        import { always, never, date } from "@decocms/blocks/matchers";
        -import { multivariate } from "./content/multivariate";
        +
        blocks.ts
        import type { Blocks } from "@decocms/blocks";
        +import { always, never, date } from "@decocms/blocks/matchers";
        +import { multivariate } from "./multivariate";
        +import hero from "./hero";
         
        -export default { always, never, date, multivariate, seo, hero } satisfies Blocks;
        +export default { always, never, date, multivariate, hero } satisfies Blocks;

        Editors compose them in Studio: a variant is a tab, its rule is picked from the matchers in your map, and the first tab is the default. A hero whose title changes for a campaign is a hero whose title is a multivariate:

        .deco/blocks/HomeHero.json
        {
           "__resolveType": "hero",
        @@ -351,14 +351,14 @@ 

        The multivariate is three lines you own

        Write your own matcher

        A matcher is any block function that returns a boolean. It reads whatever your app knows about the current request or user the way every other block does, through the context your runtime already provides:

        -
        content/matchers.ts (website)
        import { headers } from "next/headers";
        +
        matchers.ts (website)
        import { headers } from "next/headers";
         
         // A website: the device from the user agent.
         export async function device({ device }: { device: "mobile" | "desktop" }) {
           const ua = (await headers()).get("user-agent") ?? "";
           return (/Mobi|Android/i.test(ua) ? "mobile" : "desktop") === device;
         }
        -
        content/matchers.ts (app)
        // A mobile app: the user's plan from app state. No request, no headers.
        +
        matchers.ts (app)
        // A mobile app: the user's plan from app state. No request, no headers.
         export const plan = ({ plan }: { plan: "free" | "pro" }) => session.current().plan === plan;

        Add them to the map and Studio's rule picker shows them beside date: it lists every function in the manifest's matchers group, which the CLI fills with every block function that returns a boolean. Combinators are matchers too: and, or, and not are one-liners over booleans, if you want them.

        @@ -376,7 +376,7 @@

        Every variant resolves

        const [hero] = await client.resolve(name); // only this variant ran

        A variant is not access control. It chooses between values that are all fine to compute and show. 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.

        Anything a matcher looks at is a cache dimension. If a page varies by device, the device belongs in the cache key.

        -
        +

      Websites

      Routes, redirects, and content types

      @@ -419,7 +419,7 @@

      Every variant resolves

      Your own routable types

      A blog doesn't want a page entry per post. Extend Route and every saved post routes on its own:

      -
      content/post.ts
      import type { Route } from "@decocms/blocks";
      +
      post.ts
      import type { Route } from "@decocms/blocks";
       
       export interface Post extends Route {
         /** @title Published on @format date */
      @@ -490,8 +490,8 @@ 

      Linking and listing

      // { kind: "match", entry: { __resolveType: "post", name: "Hello, world", path: "/blog/hello-world", … }, params: {} }

      Example project

      -

      The framework guides share these files. The product function is different in each guide.

      -
      src/cms/model.ts
      import type { Block, Route } from '@decocms/blocks'
      +  

      The framework guides share these files: the model and two content entries. Each guide's block map (src/blocks.tsx or src/blocks.ts) declares seo, its own product, and the Content type.

      +
      src/model.ts
      import type { Block, Route } from '@decocms/blocks'
       
       export interface SEO { title: string; description: string }
       export interface ProductProps { id: string; name: string; price: number; currency: string }
      @@ -502,17 +502,6 @@ 

      Example project

      seo: SEO | Block sections: Block[] }
      -
      src/cms/blocks.base.ts
      import type { Redirect } from '@decocms/blocks'
      -import type { SEO, StoredPage } from './model'
      -
      -export const baseBlocks = {
      -  seo: (input: SEO) => input,
      -}
      -
      -export type Content = {
      -  page: StoredPage
      -  redirect: Redirect
      -}
      .deco/blocks/ProductPage.json
      {
         "__resolveType": "page",
         "name": "Product page",
      @@ -681,7 +670,7 @@ 

      matchRoute(url, items)

      Types

      type Block = { __resolveType: string; [input: string]: unknown };
       
      -// A block map: what cms.index.ts exports and the CLI reads.
      +// A block map: what blocks.ts exports and the CLI reads.
       type BlockFunction = (inputs: any) => unknown | Promise<unknown>;
       type Blocks = Record<string, BlockFunction>;
       
      @@ -723,7 +712,7 @@ 

      Errors

      Prerequisites: an App Router app on the Node runtime with a root layout, plus the example project files under src.

      1. Add the views

      -
      src/views/CartButton.tsx
      'use client'
      +
      src/CartButton.tsx
      'use client'
       import { useState } from 'react'
       
       export default function CartButton() {
      @@ -734,8 +723,8 @@ 

      1. Add the views

      </button> ) }
      -
      src/views/Product.tsx
      import CartButton from './CartButton'
      -import type { ProductProps } from '../cms/model'
      +
      src/Product.tsx
      import CartButton from './CartButton'
      +import type { ProductProps } from './model'
       
       const money = (props: ProductProps) =>
         new Intl.NumberFormat('en-US', { style: 'currency', currency: props.currency }).format(props.price)
      @@ -752,24 +741,23 @@ 

      1. Add the views

      2. Create the CMS

      The product block returns JSX. Next renders it as a Server Component, and CartButton stays a Client Component.

      -
      src/blocks/product.tsx
      import Product from '../views/Product'
      -import type { ProductProps } from '../cms/model'
      +
      src/blocks.tsx
      import type { Blocks, Redirect } from '@decocms/blocks'
      +import Product from './Product'
      +import type { ProductProps, SEO, StoredPage } from './model'
       
      -export default async function product(input: ProductProps) {
      -  return <Product {...input} />
      -}
      -
      src/cms/blocks.ts
      import type { Blocks } from '@decocms/blocks'
      -import { baseBlocks } from './blocks.base'
      -import product from '../blocks/product'
      +export default {
      +  seo: (input: SEO) => input,
      +  product: (input: ProductProps) => <Product {...input} />,
      +} satisfies Blocks
       
      -export default { ...baseBlocks, product } satisfies Blocks
      -
      src/cms/cms.ts
      import { createCMS } from '@decocms/blocks'
      +export type Content = { page: StoredPage; redirect: Redirect }
      +
      src/cms.ts
      import { createCMS } from '@decocms/blocks'
       import blocks from './blocks'
       
       // Development (no token) reads the files in .deco. Production reads releases from the Deco API
       // and falls back to those same files.
       export const cms = createCMS({ loader: { token: process.env.DECO_SITE_TOKEN }, blocks })
      -
      src/cms/client.server.ts
      import 'server-only'
      +
      src/client.server.ts
      import 'server-only'
       import { cookies } from 'next/headers'
       import { DRAFT_COOKIE } from '@decocms/blocks'
       import { cms } from './cms'
      @@ -780,7 +768,7 @@ 

      2. Create the CMS

      const pointer = (await cookies()).get(DRAFT_COOKIE)?.value return pointer ? cms.forDraft(pointer) : cms.forRelease() }
      -

      Generate the schema from the map file: npx deco schema --entry ./src/cms/blocks.ts --out ./.deco/schema.json. For preview, let proxy.ts store the draft pointer in a cookie on the request Studio opens, so navigation keeps it:

      +

      Generate the schema from the map file: npx deco schema --entry ./src/blocks.tsx --out ./.deco/schema.json. For preview, let proxy.ts store the draft pointer in a cookie on the request Studio opens, so navigation keeps it:

      src/proxy.ts
      import { NextResponse, type NextRequest } from 'next/server'
       import { draftCookie } from '@decocms/blocks'
      @@ -794,9 +782,8 @@ 

      2. Create the CMS

      3. Open the page once per request

      generateMetadata and the page component both need the page. Wrap the lookup in React's cache so they share one client, one match, and the same section promises.

      -
      src/cms/open-page.server.ts
      import 'server-only'
      -import { cache } from 'react'
      -import type { ReactNode } from 'react'
      +
      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'
      @@ -833,9 +820,8 @@ 

      4. Render the catch-all route

      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 '../../cms/open-page.server'
      +import { openPage, pathnameFor } from '../../open-page.server'
       
      -export const runtime = 'nodejs'
       type Props = { params: Promise<{ path?: string[] }> }
       
       export async function generateMetadata({ params }: Props): Promise<Metadata> {
      @@ -870,8 +856,8 @@ 

      4. Render the catch-all route

      5. List another content type

      A blog index needs no page entry. List the post type; each entry carries its own path:

      src/app/blog/page.tsx
      import Link from 'next/link'
      -import { client } from '../../cms/client.server'
      -import type { Post } from '../../../content/post'
      +import { client } from '../../client.server'
      +import type { Post } from '../../../post'
       
       export default async function BlogIndex() {
         const c = await client()
      @@ -901,23 +887,22 @@ 

      How it works

      Prerequisites: a Start app with its route tree, router, and document shell, plus the example project files and the Product and CartButton views from the Next.js guide. Enable resolveJsonModule in tsconfig.json.

      1. Return descriptors from blocks

      -
      src/blocks/product.data.ts
      import type { ProductProps, SectionData } from '../cms/model'
      +
      src/blocks.ts
      import type { Blocks, Redirect } from '@decocms/blocks'
      +import type { ProductProps, SectionData, SEO, StoredPage } from './model'
       
      -export default async function product(input: ProductProps): Promise<SectionData> {
      -  return { component: 'product', props: input }
      -}
      +export default { + seo: (input: SEO) => input, + product: (input: ProductProps): SectionData => ({ component: 'product', props: input }), +} satisfies Blocks + +export type Content = { page: StoredPage; redirect: Redirect }

      2. Create the CMS

      Workers have no filesystem, so local content is the JSON imported at build time, and remoteLoader serves releases over it. wrangler dev reads the token from .dev.vars.

      -
      src/cms/blocks.ts
      import type { Blocks } from '@decocms/blocks'
      -import { baseBlocks } from './blocks.base'
      -import product from '../blocks/product.data'
      -
      -export default { ...baseBlocks, product } satisfies Blocks
      -
      src/cms/cms.ts
      import { createCMS, localLoader, remoteLoader, draftPointer } from '@decocms/blocks'
      +
      src/cms.ts
      import { createCMS, localLoader, remoteLoader, draftPointer } from '@decocms/blocks'
       import { env } from 'cloudflare:workers'
      -import ProductPage from '../../.deco/blocks/ProductPage.json'
      -import LegacyProduct from '../../.deco/blocks/LegacyProduct.json'
      +import ProductPage from '../.deco/blocks/ProductPage.json'
      +import LegacyProduct from '../.deco/blocks/LegacyProduct.json'
       import blocks from './blocks'
       
       // The content this build shipped with. remoteLoader falls back to it if the API is unreachable.
      @@ -936,7 +921,7 @@ 

      2. Create the CMS

      3. Open the page

      This helper is shared by both TanStack guides. The server function hands the incoming request to client(), so preview needs no framework support. Each section promise resolves to the value, or to null if the section failed, so no internal error details reach the browser.

      -
      src/cms/open-page.server.ts
      import { notFound, redirect } from '@tanstack/react-router'
      +
      src/open-page.server.ts
      import { notFound, redirect } from '@tanstack/react-router'
       import { matchRoute, type Redirect } from '@decocms/blocks'
       import { client } from './cms'
       import type { SEO, StoredPage } from './model'
      @@ -957,7 +942,7 @@ 

      3. Open the page

      key: `${href}:${index}`, value: c.resolve<T>(section).then(([value, sectionError]) => { if (sectionError) console.error(sectionError) - return sectionError ? null : value + return value }), })) @@ -968,11 +953,11 @@

      3. Open the page

      }

      4. Expose it through a server function

      -
      src/server/page.functions.ts
      import { createServerFn } from '@tanstack/react-start'
      +
      src/page.functions.ts
      import { createServerFn } from '@tanstack/react-start'
       import { getRequest } from '@tanstack/react-start/server'
       import { z } from 'zod'
      -import { openPage } from '../cms/open-page.server'
      -import type { SectionData } from '../cms/model'
      +import { openPage } from './open-page.server'
      +import type { SectionData } from './model'
       
       export const loadPage = createServerFn({ method: 'GET' })
         .inputValidator(z.object({ href: z.string().startsWith('/') }))
      @@ -981,8 +966,8 @@ 

      4. Expose it through a server function

      5. Render the catch-all route

      src/routes/$.tsx
      import { Suspense } from 'react'
       import { Await, createFileRoute } from '@tanstack/react-router'
      -import Product from '../views/Product'
      -import { loadPage } from '../server/page.functions'
      +import Product from '../Product'
      +import { loadPage } from '../page.functions'
       
       const views = { product: Product }
       
      @@ -1044,19 +1029,18 @@ 

      6. Configure Cloudflare Workers

      This guide reuses the data guide's content, CMS, and openPage helper, but the product block returns JSX instead of a descriptor. The server renders the tree through React Flight, so the browser needs no view registry.

      1. Swap the product block

      -

      Use src/blocks/product.tsx from the Next.js guide, and import it in src/cms/blocks.ts in place of product.data. Keep cms.ts as it is.

      +

      Replace src/blocks.ts with the Next.js guide's src/blocks.tsx. Keep src/cms.ts as it is.

      2. Render sections on the server

      -
      src/server/page.functions.tsx
      import { Suspense, type ReactNode } from 'react'
      +
      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 '../cms/open-page.server'
      +import { openPage } from './open-page.server'
       
       async function Section({ value }: { value: Promise<ReactNode | null> }) {
      -  const node = await value
      -  return node ?? <p role="status">This section is temporarily unavailable.</p>
      +  return (await value) ?? <p role="status">This section is temporarily unavailable.</p>
       }
       
       export const loadPage = createServerFn({ method: 'GET' })
      @@ -1077,7 +1061,7 @@ 

      2. Render sections on the server

      3. Render the route

      src/routes/$.tsx
      import { createFileRoute } from '@tanstack/react-router'
      -import { loadPage } from '../server/page.functions'
      +import { loadPage } from '../page.functions'
       
       export const Route = createFileRoute('/$')({
         loader: ({ location }) => loadPage({ data: { href: location.pathname + location.searchStr } }),
      @@ -1134,7 +1118,7 @@ 

      4. Configure Cloudflare Workers

      Configuring the loader

      Most apps never pass a loader: createCMS reads the ./.deco directory by default. Pass an object to keep your content somewhere else, to connect the hosted Deco CMS, or both:

      -
      src/cms/cms.ts
      // Default: the files in ./.deco
      +
      cms.ts
      // Default: the files in ./.deco
       createCMS({ blocks });
       
       // Content in another directory
      @@ -1166,7 +1150,7 @@ 

      Fallback

      Write your own loader

      The contract is one method. A KV-backed loader for a Worker:

      -
      src/cms/kv-loader.ts
      import type { Loader } from '@decocms/blocks'
      +
      kv-loader.ts
      import type { Loader } from '@decocms/blocks'
       
       export function kvLoader(kv: KVNamespace, key = 'release:current'): Loader {
         return {
      @@ -1205,7 +1189,11 @@ 

      Tracing

      Rename a type with an alias

      An alias is another key pointing at the same thing: the same function in the block map, or the same type in Content. Content that stores the old name keeps working, and routing is unaffected because it looks at path, not the type name:

      -
      cms.index.ts
      export default {
      +
      blocks.ts
      import type { Blocks, Redirect } from "@decocms/blocks";
      +import type { Page } from "./page";
      +import productCard from "./product-card";
      +
      +export default {
         "product-card": productCard,
         "site/sections/Product.tsx": productCard,     // the name legacy content stores
       } satisfies Blocks;
      @@ -1240,7 +1228,7 @@ 

      Upgrade checklist

      Content comes back unresolvedYou passed { resolve: false }, or the client was created without a block map. A function runs more than once per requestUse one client for the whole request, and keep the block map at module level. Each new client, and each new map object, gets its own cache. CYCLEFollow the references in error.path and remove the one that leads back to an entry already being expanded. - Wrong fields in the editorRegenerate the schema from the right cms.index.ts. Check the first parameter's type and its property JSDoc. + Wrong fields in the editorRegenerate the schema from the right blocks.ts. Check the first parameter's type and its property JSDoc. A result can't cross the networkJSON can't carry functions or React trees. Return serializable descriptors, or use an RSC integration. Hydration mismatchCompare revisions and request context between server and client. Look for time-dependent or random rendering, and send the page's revision with follow-up requests. Content changes mid-responseUse one client for the whole response; a client never changes revision. Check that the loader never rewrites an existing revision. diff --git a/site/src/landing.html b/site/src/landing.html index 9714834b..5e2f82b5 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -54,7 +54,7 @@

      Headless for developers.
      @@ -96,7 +96,7 @@

      Every change is a commit,
      Deco Blocks · my-store — setup
      - +
      // experiments.ts: the function you already have
      @@ -247,7 +247,7 @@ 

      Your function stays. Its i return { newCheckout: roll(input.newCheckout), /* … */ }; } -// cms.index.ts: give it a name in a block map +// blocks.ts: give it a name in a block map import type { Blocks } from "@decocms/blocks"; import experiments from "./experiments"; @@ -255,7 +255,7 @@

      Your function stays. Its i

      {{icon:check}}The key experiments is now a block type content can refer to.

      -
      npx deco schema --entry ./cms.index.ts --out ./.deco/schema.json
      +
      npx deco schema --entry ./blocks.ts --out ./.deco/schema.json
      {{symbol}}Experimentsexperiments
      New checkout flow100–100
      @@ -266,7 +266,7 @@

      Your function stays. Its i

      import { createCMS } from "@decocms/blocks";
      -import blocks from "../../cms.index";
      +import blocks from "./blocks";
       
       // Production (with a token) reads the hosted Deco CMS;
       // development reads the files in .deco.
      diff --git a/site/src/style.css b/site/src/style.css
      index 63020ebc..4aeb6a76 100644
      --- a/site/src/style.css
      +++ b/site/src/style.css
      @@ -524,7 +524,7 @@ pre code { font: inherit; font-variant-ligatures: none; background: none; border
       .code-file { flex: 1; color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
       .code-head.is-lang .code-file { font-family: var(--font-sans); font-size: 13px; font-weight: 400; letter-spacing: -.005em; text-transform: uppercase; color: var(--eyebrow); }
       /* A caption that describes the block rather than naming a file reads as text, not as a path;
      -   a path with a note ("cms.index.tsx (Next.js)") keeps the path in mono and the note in text. */
      +   a path with a note ("blocks.tsx (Next.js)") keeps the path in mono and the note in text. */
       .code-head.is-desc .code-file { font-family: var(--font-sans); font-size: 13.5px; letter-spacing: -.005em; }
       .code-file .cf-note { font-family: var(--font-sans); font-size: 13px; letter-spacing: -.005em; color: var(--muted-fg); }
       .code-lang { flex: none; height: 22px; display: inline-flex; align-items: center; padding: 0 9px; border-radius: 999px; background: var(--surface); box-shadow: inset 0 0 0 1px var(--code-border); font: 400 11px/16px var(--font-sans); letter-spacing: .02em; color: var(--muted-fg); }
      
      From 1fd591c89ae0769cc29ad8c743d20fc8051e283d Mon Sep 17 00:00:00 2001
      From: gimenes 
      Date: Thu, 1 Oct 2026 15:39:09 -0300
      Subject: [PATCH 009/132] docs(site): rewrite Blocks around function
       composition, add Content and loaders
      
      - Blocks (#model) now teaches blocks as function composition stored as
        data: the composition in code, the same composition as JSON, saved
        entries as named compositions in a content map, and one page resolving
        step by step ("Resolving a page, step by step").
      - Saved entries are no longer described as files in .deco/blocks. A new
        Core concepts section, "Content and loaders" (#content), explains where
        that map comes from: files by default, bundled JSON, the hosted Deco
        CMS or your own store, plus configuring the loader and writing one.
        Loaders and deployment keeps release and deployment behaviour.
      - { resolve: false } moves out of the prose into one "Reading without
        running" callout; the Quickstart aside about it is gone.
      - Troubleshooting now agrees with the registry rule (the function wins
        on a name clash), so that item leaves the Roadmap's contradictions list.
      
      Co-Authored-By: Claude Opus 5.5 
      Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
      ---
       site/build.py          |   2 +-
       site/data/roadmap.json |   2 +-
       site/src/app.js        |   4 +-
       site/src/content.html  | 246 ++++++++++++++++++++++++++---------------
       site/src/landing.html  |   6 +-
       5 files changed, 161 insertions(+), 99 deletions(-)
      
      diff --git a/site/build.py b/site/build.py
      index abbca6b9..7c860399 100755
      --- a/site/build.py
      +++ b/site/build.py
      @@ -57,7 +57,7 @@ def page_fragment(name):
       docs = balanced_sections(page_fragment("docs"))
       internals = balanced_sections(page_fragment("internals"))
       assert [s for s, _ in docs][:2] == ["architecture", "quickstart"], docs[:2]
      -assert len(docs) == 18 and len(internals) == 6, (len(docs), len(internals))
      +assert len(docs) == 19 and len(internals) == 6, (len(docs), len(internals))
       
       prism = re.search(r'', content, re.S).group(0)
       assert "MIT LICENSE" in prism, "keep the Prism license notice in the bundle"
      diff --git a/site/data/roadmap.json b/site/data/roadmap.json
      index 330565d7..2cbc42b3 100644
      --- a/site/data/roadmap.json
      +++ b/site/data/roadmap.json
      @@ -1334,7 +1334,7 @@
             "id": "roadmap-docs--resolve-contradictions-in-these-docs",
             "group": "docs",
             "title": "Resolve contradictions in these docs",
      -      "plan": "

      Also: make Page.seo optional, drop array elements that resolve to undefined (Studio's hidden array items), make a hidden section's undefined render nothing, say what a reference to a Content entry resolves to, and fix the credential sentence in Preview.

      ", + "plan": "

      Also: make Page.seo optional, drop array elements that resolve to undefined (Studio's hidden array items), make a hidden section's undefined render nothing, say what a reference to a Content entry resolves to, and fix the credential sentence in Preview.

      ", "features": [ "cms-page-routing", "codegen-pipeline", diff --git a/site/src/app.js b/site/src/app.js index d6d24de6..dc98cc5c 100644 --- a/site/src/app.js +++ b/site/src/app.js @@ -43,7 +43,7 @@ pages.forEach(page => $$(':scope > section', page).forEach(section => pageOf.set(section.id, page.dataset.page))); const groupsByPage = { home: [], - docs: [["Getting started", ["architecture", "quickstart"]], ["Core concepts", ["model", "schema", "preview"]], ["Advanced", ["manifest", "variants"]], ["Websites", ["content-types", "routing", "rendering"]], ["Reference", ["standalone"]], ["Framework guides", ["nextjs", "tanstack-data", "tanstack-rsc"]], ["Production", ["releases", "operations", "adoption", "troubleshooting"]]], + docs: [["Getting started", ["architecture", "quickstart"]], ["Core concepts", ["model", "content", "schema", "preview"]], ["Advanced", ["manifest", "variants"]], ["Websites", ["content-types", "routing", "rendering"]], ["Reference", ["standalone"]], ["Framework guides", ["nextjs", "tanstack-data", "tanstack-rsc"]], ["Production", ["releases", "operations", "adoption", "troubleshooting"]]], internals: [["Under the hood", ["internals", "walkthrough", "loader-internals", "router-internals", "studio", "decisions"]]], roadmap: [["Overview", ["roadmap"]], ["Release blockers", ["roadmap-blockers"]], ["Studio support", ["roadmap-studio-new", "roadmap-studio-legacy"]], ["Work items", ["roadmap-api", "roadmap-cli", "roadmap-platform", "roadmap-docs"]], ["Site migrations", ["roadmap-storefront", "roadmap-blog", "roadmap-faststore"]], ["Feature readiness", ["roadmap-features"]]], }; @@ -868,7 +868,7 @@ ], get: [ 'The saved entry. Its product input names CurrentProduct, another saved entry.', - 'With { resolve: false }, only saved entries are in the registry. CurrentProduct expands; catalog-product and product-card are left as they are. No code runs.', + '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 = { diff --git a/site/src/content.html b/site/src/content.html index d80729fc..5dc130a0 100644 --- a/site/src/content.html +++ b/site/src/content.html @@ -14,7 +14,7 @@
      - + @@ -34,15 +34,15 @@

      Delivery: from files to your app

      SDKclient.list and client.resolve read and resolve content
      Your appNext.js, TanStack Start, or any server
      -

      At request time, the SDK reads those files, follows references between them, and runs each block through the function you mapped to its type in your block map. What you render, and how, is up to your framework.

      -

      In production the same files are read from the Deco API instead of disk, but a request never waits for it: content is served from memory and refreshed in the background, and the files the build shipped with are always there as the fallback. See how the loader stays current.

      +

      At request time, the SDK reads that content through a loader (by default, those same files), follows references between entries, and runs each block through the function you mapped to its type in your block map. What you render, and how, is up to your framework.

      +

      With the hosted Deco CMS, production reads the same content from the Deco API instead of disk, but a request never waits for it: content is served from memory and refreshed in the background, and the files the build shipped with are always there as the fallback. See how the loader stays current.

      Small on purpose

      Deco Blocks stays out of your way, and every piece is replaceable:

      • Content is plain JSON. Open it in any editor, grep it, diff it, or generate it with a script. There's no database and no content API to run.
      • Blocks are plain functions. No framework plugin, no base classes, no magic directories. The block map is a plain object, and you can call any block function directly in a test.
      • -
      • Loaders are one method. Loading from the filesystem, KV, or your own store is a load() that returns { revision, blocks }. Swap it and nothing else changes.
      • +
      • Loaders are one method. Loading from the filesystem, KV, or your own store is a load() that returns { revision, blocks }. Swap it and nothing else changes (see Content and loaders).
      • The schema is a file. Commit it, inspect it, or generate it another way. Studio only needs the JSON.
      • Nothing is hidden. If the SDK doesn't do what you need, read the files yourself. The format is documented in Blocks.
      @@ -93,9 +93,9 @@

      3. Add content

      There are two ways to create content, and they produce the same thing:

      • In Studio. Open the editor, pick the experiments block type, fill in the form, and save. Studio writes the JSON file into your repository.
      • -
      • Manually. Create the JSON file yourself, or let an AI agent asked to "ramp the new checkout to 25%" do it. The file is the only source of truth, so both routes are equivalent.
      • +
      • Manually. Create the JSON file yourself, or let an AI agent asked to "ramp the new checkout to 25%" do it. Either way the result is the same file, so both routes are equivalent.
      -

      Either way, the file lands in .deco/blocks. The filename is the entry's name, and __resolveType is the block type:

      +

      Either way, the file lands in .deco/blocks, which is where the CMS reads content from by default. The filename is the entry's name, and __resolveType is the block type:

      .deco/blocks/Experiments.json
      {
         "__resolveType": "experiments",
         "newCheckout": 10,
      @@ -104,7 +104,7 @@ 

      3. Add content

      }

      4. Read the content from your code

      -

      Create the CMS once with your block map. It reads the .deco directory by default, so there's nothing else to configure (to change the directory or connect the hosted Deco CMS, see Configuring the loader). Ask it for a client, then fetch the entry by name. The client runs experiments on the saved inputs, so you get the same result as the direct call, with the same type:

      +

      Create the CMS once with your block map. It reads the .deco directory by default, so there's nothing else to configure (to read it from somewhere else, or connect the hosted Deco CMS, see Configuring the loader). Ask it for a client, then fetch the entry by name. The client runs experiments on the saved inputs, so you get the same result as the direct call, with the same type:

      cms.ts
      import { createCMS } from "@decocms/blocks";
       import blocks from "./blocks";
       
      @@ -115,12 +115,13 @@ 

      4. Read the content from your code

      const [flags, error] = await client.resolve("Experiments"); if (error) throw error; // flags: { newCheckout: false, stickyHeader: true, freeShippingBanner: false }
      -

      Every call returns a [value, error] tuple, so a missing entry or a failing function never throws. Pass { resolve: false } to get the saved JSON instead of the function's result.

      +

      Every call returns a [value, error] tuple, so a missing entry or a failing function never throws.

      That's it. The function didn't change, but its inputs now live in a file that editors change through a form and agents change directly, and every change is a commit. A real implementation would hash a visitor ID from a cookie instead of rolling a die, so each visitor gets a stable result. The CMS doesn't hand blocks the request; read it the way the rest of your app does (see Block maps).

      Next steps

      • Blocks: nesting, saved entries, and references between them. That's how a whole page is composed out of sections.
      • +
      • Content and loaders: where saved entries come from, and how to read them from somewhere other than .deco/blocks.
      • Schema generation: every JSDoc tag the CLI understands.
      • Routes and redirects: the two framework types behind every URL.
      • Preview: render drafts in your real pages, no deploy.
      • @@ -132,61 +133,159 @@

        Next steps

        Core concepts

        Blocks

        -

        A block is a JSON object with a __resolveType. The type names what the block is, and the other properties are its inputs. Any input can itself be a block, so content forms a tree:

        -
        {
        +  

        Most of what changes on a website isn't the code. It's the values the code is given: a headline, a banner image, the share of visitors who see the new checkout. Deco is built on that idea: developers write the functions, and the people who run the site decide what goes into them.

        +

        Blocks are how they make those decisions. A block is a small piece of JSON that names one of your functions and lists the inputs to call it with. Editors change the inputs in Studio, and agents edit the file directly. When your app resolves the block, Deco calls the function with whatever is saved and gives you back the result. This page shows what a block looks like, how blocks fit inside each other, and exactly how they turn into values.

        + +

        Start from code you already write

        +

        Here's the kind of code you write every day:

        +
        productCard({
        +  title: "Summer collection",
        +  product: catalogProduct({ slug: "summer-shirt" }),
        +})
        +

        It's function composition. The inner call runs first, and its result becomes an input to the outer one, so productCard receives product data, never a pending call. The functions are the developer's job. The composition is a decision: which product, which title, which function fetches the data. Written in code, changing that decision means editing code and deploying it.

        + +

        A block is that composition, stored as data

        +

        Write the same composition as JSON and nobody has to edit code to change it. __resolveType names the function, the other properties are its inputs, and a block inside an input is a call inside a call. Here productCard and catalogProduct are the functions your map registers as "product-card" and "catalog-product":

        +
        // Stored as JSON
        +{
           "__resolveType": "product-card",
           "title": "Summer collection",
        -  "product": {
        -    "__resolveType": "catalog-product",
        -    "slug": "summer-shirt"
        -  }
        -}
        -

        Use the same model for anything your site needs: UI sections, product data, SEO, navigation, campaign settings. The CMS doesn't reserve any directories or categories.

        + "product": { "__resolveType": "catalog-product", "slug": "summer-shirt" } +} -

        Saved entries and references

        -

        Every file in .deco/blocks is a saved entry, named after the file. Other content refers to an entry by putting its name in __resolveType, and can override some of its inputs for that one use:

        -
        // Use the saved entry as is.
        -{ "__resolveType": "SummerSEO" }
        +// Means
        +productCard({
        +  title: "Summer collection",
        +  product: catalogProduct({ slug: "summer-shirt" }),
        +})
        +

        Now an editor can change the title or the slug in Studio, and an agent can even point product at a different function, and the composition changes without a deploy. The functions are whatever your block map registers, and each one's input type is what deco schema turns into the editor form. Use the same model for anything your site needs: UI sections, product data, SEO, navigation, campaign settings. The CMS doesn't reserve any directories or categories.

        -// Override one input for this occurrence. -{ "__resolveType": "SummerSEO", "title": "Summer sale" }
        -

        An entry doesn't have to be a block itself. It can be any JSON value that contains blocks, like the page in the next example.

        +

        Name a composition, reuse it

        +

        In code, you'd give a composition you use often a name and reuse it. Saved entries do that. Alongside the block map of functions, the CMS reads a second map, the content map, where each name holds a piece of saved JSON. { "__resolveType": "SummerSEO" } means whatever is saved under the name SummerSEO, written in place. The map can come from files in your repository, the hosted Deco CMS, or your own store; see Content and loaders.

        +

        Unlike a const that holds a result, what's named here is the call itself, inputs and all. That's why you can add inputs to a reference to override the saved block's inputs for this one use:

        +
        // 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" }
        +
        +// Means
        +seo({ title: "Summer sale", description: "Light layers for long days." })
        +
          +
        • Overrides are shallow. The merge is { ...entry, ...inputs }, so an override replaces the whole top-level property, and the saved entry itself doesn't change. It isn't currying or partial application: the reference is expanded into one complete call.
        • +
        • An entry doesn't have to be a block. It can be any JSON that contains blocks, like a page with a list of sections: an object literal with calls inside. Resolving it calls each block inside and leaves the rest of the JSON as it is.
        • +
        -

        How resolve works

        -

        Every __resolveType is a name looked up in one registry, and a name is either data or code:

        +

        Resolving a page, step by step

        +

        Resolving a block is evaluating its composition: named entries expand, then calls run from the inside out. Here's a page saved as pages-summer. It uses the SummerSEO entry with an override, and one Carousel section. The block map has two functions: seo returns its inputs, and Carousel returns a { component, props } descriptor.

        +
        // Saved as pages-summer
        +{
        +  "seo": { "__resolveType": "SummerSEO", "title": "Summer sale" },
        +  "sections": [{ "__resolveType": "Carousel", "banners": [] }]
        +}
        +

        Call client.resolve("pages-summer") and follow the page as it changes.

        +

        Step 1. The reference expands. SummerSEO is a saved entry, so it's data. The entry merges under the override, and its __resolveType now names a function.

        +
        {
        +  "seo": { "__resolveType": "seo", "title": "Summer sale", "description": "Light layers for long days." },
        +  "sections": [{ "__resolveType": "Carousel", "banners": [] }]
        +}
        +

        Step 2. The functions are called. What's left is a composition of plain calls. Both blocks have plain inputs, so both functions run. They're siblings, so neither waits for the other.

        +
        {
        +  seo: seo({ title: "Summer sale", description: "Light layers for long days." }),
        +  sections: [Carousel({ banners: [] })]
        +}
        +

        Step 3. The result. Each return value takes its block's place. The page itself was never a block, so its shape stays the same.

        +
        {
        +  seo: { title: "Summer sale", description: "Light layers for long days." },
        +  sections: [{ component: "Carousel", props: { banners: [] } }]
        +}
        +

        To step through a deeper example, see How resolution works.

        +

        Reading without running. Pass { resolve: false } to read content without calling any functions. client.resolve("pages-summer", { resolve: false }) stops after step 1: saved entries like SummerSEO still expand, and blocks that name a function, like seo and Carousel, come back as JSON, untouched. Nothing runs. That's useful for previews, editor tooling, and debugging. To read an entry exactly as stored, with its references left in place, look it up in the map your loader returns, for example (await localLoader("./.deco").load()).blocks["pages-summer"].

        + +

        The rule

        +

        Every __resolveType is a name looked up in one registry, and a name is either data or code: a named composition to expand, or a function to call.

        const registry = { ...savedEntries, ...blocks };   // name → saved JSON | function
        -

        Saved entries come from the loader. Functions come from the block map you give createCMS. Resolution is one rule, applied to every object with a __resolveType:

        +

        Saved entries are the content map your loader returns. Functions come from the block map you give createCMS. The CMS applies one rule to every object with a __resolveType:

        1. Look up the name.
        2. Data: replace the object with { ...entry, ...inputs }, so your inputs override the entry's, and apply the rule to the result again.
        3. -
        4. Function: resolve the inputs first, children before parents, then call the function with them. Its return value takes the block's place.
        5. +
        6. Function: resolve the inputs first, children before parents and siblings concurrently, then call the function. Its return value takes the block's place.
        7. Neither: fail with UNKNOWN_BLOCK.
        -

        Take these two entries and a block map with seo and Carousel functions:

        -
        .deco/blocks/pages-summer.json
        {
        -  "seo": { "__resolveType": "SummerSEO" },
        -  "sections": [{ "__resolveType": "Carousel", "banners": [] }]
        -}
        -
        .deco/blocks/SummerSEO.json
        { "__resolveType": "seo", "title": "Sunny!" }
        -

        client.resolve("pages-summer") walks the page. seo holds { "__resolveType": "SummerSEO" }: a data hit, so it becomes { "__resolveType": "seo", "title": "Sunny!" } and the rule runs again. Now seo is a function hit, so seo({ title: "Sunny!" }) runs. Carousel is a function hit straight away. The result:

        -
        {
        -  seo: { title: "Sunny!" },
        -  sections: [{ component: "Carousel", props: { banners: [] } }]
        -}
        -

        With { resolve: false }, the registry holds data only. Names that aren't saved entries pass through untouched, so client.resolve("pages-summer", { resolve: false }) returns the page with SummerSEO expanded and the seo and Carousel blocks left as they are. That's how you read content without running anything.

        The rule in detail

          -
        • A saved entry is a preset. It's a block, or any JSON containing blocks, saved under a name. Referring to it by name and overriding a field is the same as writing the merged block inline. Overrides merge shallowly: an override replaces the whole property, and the saved entry itself doesn't change.
        • -
        • Bottom-up and parallel. A function only runs once its inputs are resolved, so product-card receives product data, never a catalog-product block. Sibling blocks, such as sections, resolve concurrently.
        • -
        • Failures stop the parent. If a nested block fails, its parent never runs and resolve returns the error. A reference to an entry that doesn't exist fails with NOT_FOUND.
        • -
        • Results are opaque. The CMS never walks a function's return value. Return plain data, a descriptor, or JSX. This is also why a function that returns its input can't loop: __resolveType is stripped before the call, and whatever comes back is never looked up again.
        • -
        • Only data can loop. A data hit is the one branch that says "apply 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.
        • -
        • Stored form. To read an entry exactly as saved, with nothing expanded, take it from loader.load().
        • +
        • Failures stop the parent. If a nested block fails, its parent never runs and resolve returns the error, much as an outer call never runs when an inner one throws. A reference to an entry that doesn't exist fails with NOT_FOUND.
        • +
        • Results are opaque. The CMS never walks a function's return value. Return plain data, a descriptor, or JSX. This is also why a function that returns its input, like seo above, can't loop: __resolveType is stripped before the call, and whatever comes back is never looked up again.
        • +
        • Only data can loop. A data hit 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.
        -

        Entry names and block types share one registry. A saved entry called seo and a seo function can't coexist. deco schema fails when an entry name matches a block type, so rename the entry.

        +

        Entry names and block types share one registry. If a name is both, the function wins, so a saved entry called seo and a seo function can't coexist. deco schema fails when an entry name matches a block type, so rename the entry.

        +
        +

        Core concepts

        Content and loaders

        +

        Resolving takes two maps. The block map holds your functions, and it ships with your code. The content map holds your saved entries: each name points at a piece of JSON. A loader supplies the content map, and it's the only part of the runtime that knows where content lives. Your functions, the resolver, and your pages never do. (This isn't the same thing as the data-fetching block functions some Deco apps call loaders, like catalogProduct in Blocks. Those are ordinary functions in your block map.)

        +

        A loader returns the whole map at once, with a revision that identifies it:

        +
        {
        +  "revision": "b1946ac92492d2347c6235b4d2611184",
        +  "blocks": {
        +    "SummerSEO": { "__resolveType": "seo", "title": "Sunny!", "description": "Light layers for long days." },
        +    "pages-summer": {
        +      "seo": { "__resolveType": "SummerSEO", "title": "Summer sale" },
        +      "sections": [{ "__resolveType": "Carousel", "banners": [] }]
        +    }
        +  }
        +}
        +

        Each key under blocks is an entry name. It's the name a __resolveType refers to, and the name you pass to client.resolve("SummerSEO"). Despite the field name, these are saved entries: any JSON, never functions. Functions only ever come from the block map you give createCMS. The revision names this exact set of entries, so any change to any entry must give a new revision. The built-in loaders use a hash of the whole map. How releases use it is in Releases and deployment.

        + +

        Where content comes from

        +

        The content map can come from anywhere that can return JSON. These are the usual sources:

        +
      PieceWhat it doesWhere it runs
      SDK @decocms/blocksReads content from files or the Deco API and runs your block functions on it.Your app: any JavaScript server, Node or Cloudflare Workers
      SDK @decocms/blocksReads content through a loader (files, the Deco API, or your own store) and runs your block functions on it.Your app: any JavaScript server, Node or Cloudflare Workers
      CLI deco schemaGenerates the JSON Schema from your types.Your machine or CI
      StudioBuilds a visual editor from that schema and saves edits back to your files.Hosted by Deco
      + + + + + + + +
      Content inLoaderUse it for
      Files in your repository (the default)localLoader("./.deco"): one JSON file per entry in .deco/blocks; the file name without .json is the entry name (.deco/blocks/SummerSEO.json → "SummerSEO")Development, tests, and the fallback of remoteLoader in production
      JSON bundled into the buildlocalLoader({ revision, blocks })Cloudflare Workers, which have no filesystem
      The hosted Deco CMSremoteLoader(fallback, { token })Production: publish without a deploy, and preview drafts
      KV, a database, or your own APIA Loader you writeAnything else
      +

      When you edit with Studio or an agent, content is authored as files: they write to .deco/blocks in your repository, and every change is a commit. The loader only decides where your app reads that content from at runtime. With the hosted Deco CMS, each commit becomes a release, a published version of your content that reaches your app without a deploy, and the files the build shipped with are the fallback (see Releases and deployment). If you load from your own store, how content gets there is up to you.

      + +

      Configuring the loader

      +

      Most apps never pass a loader: createCMS reads the ./.deco directory by default. Pass an object to keep your content somewhere else, to connect the hosted Deco CMS, or both. These are shortcuts for the loaders in the table above: { root } is localLoader(root), and adding token wraps it as remoteLoader(localLoader(root), { token }).

      +
      cms.ts
      // Default: the files in ./.deco
      +createCMS({ blocks });
      +
      +// Content in another directory
      +createCMS({ loader: { root: "./content/.deco" }, blocks });
      +
      +// Releases from the hosted Deco CMS, with the files in ./.deco as the fallback
      +createCMS({ loader: { token: process.env.DECO_SITE_TOKEN }, blocks });
      +
      +// Both: hosted releases, with ./content/.deco as the fallback
      +createCMS({
      +  loader: { root: "./content/.deco", token: process.env.DECO_SITE_TOKEN },
      +  blocks,
      +});
      +

      token is the site's API key for the Deco API. Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads the files only, so the same line works everywhere. For anything else, such as content bundled into a Workers build or your own store, pass a Loader (see API reference).

      + +

      Write your own loader

      +

      The contract is one method. A KV-backed loader for a Worker:

      +
      kv-loader.ts
      import type { Loader } from '@decocms/blocks'
      +
      +export function kvLoader(kv: KVNamespace, key = 'release:current'): Loader {
      +  return {
      +    async load() {
      +      const content = await kv.get(key, 'json')
      +      if (!content) throw new Error('No content in KV')
      +      return content   // { revision, blocks }
      +    },
      +  }
      +}
      +

      Pass it as createCMS's loader, or as the fallback of remoteLoader. Store the revision with the content, and change it whenever any entry changes. If your store has drafts, honor the draft pointer passed to load(pointer). If it can go stale, add update(). The client only ever sees { revision, blocks }.

      +

      + +

      Core concepts

      Schema generation

      Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. Point it at the file that exports your block map, the blocks.ts from the quickstart:

      @@ -578,12 +677,12 @@

      createCMS(config)

      resolve<T = unknown>(target: unknown, options?: { resolve?: boolean }): Promise<Result<T>>; // resolve: false reads without running list<T = Block>(type: string, options?: ListOptions<T>): Promise<Result<T[]>>; }
      -

      The loader option covers the common cases by shape. Leave it out and the CMS reads the files in ./.deco. A path is the files in that directory. { token } is releases from the Deco API with the files in ./.deco as fallback, and root changes the directory. Leave token undefined and it's just the files, which is what makes one line work in development and production. Anything else is a Loader you built yourself. Create the CMS once, at module scope, and ask it for a client per request: forRelease() for visitors, forDraft(pointer) when the request carries one. Clients are free to make and hold nothing you'd want to share. Everything either does is a few lines over a loader, and the registry rule from Blocks is how it resolves; those internals aren't part of the public API.

      +

      The loader option covers the common cases by shape. Leave it out and the CMS reads the files in ./.deco. A path is the files in that directory. { token } is releases from the Deco API with the files in ./.deco as fallback, and root changes the directory. Leave token undefined and it's just the files, which is what makes one line work in development and production. Anything else is a Loader you built yourself (examples in Content and loaders). Create the CMS once, at module scope, and ask it for a client per request: forRelease() for visitors, forDraft(pointer) when the request carries one. Clients are free to make and hold nothing you'd want to share. Everything either does is a few lines over a loader, and the registry rule from Blocks is how it resolves; those internals aren't part of the public API.

      Loaders

      -

      One interface. load() with no argument is production; load(pointer) is a draft. update() checks for newer content.

      +

      One interface. load() with no argument is production; load(pointer) is a draft. update() checks for newer content. Which one to use, and how to write your own, is in Content and loaders.

      interface Loader {
      -  load(pointer?: string | null): Promise<{ revision: string; blocks: Record<string, Block> }>;
      +  load(pointer?: string | null): Promise<{ revision: string; blocks: Record<string, Block> }>;   // Block: any saved JSON
         update?(): Promise<{ updated: boolean }>;
       }
      @@ -1098,15 +1197,9 @@

      4. Configure Cloudflare Workers

      Also requires @vitejs/plugin-rsc 0.5.30 and react-server-dom-webpack 19.2.7. If one repository maintains both integrations, give each its own build configuration.

      -
      -

      Production

      Loaders and deployment

      -

      A loader returns a revision: a hash of the content plus every saved entry in it. localLoader, the CLI, and the Deco API all compute the hash the same way, so "is what I have current?" is one string comparison. Your block functions don't know where content came from, so the same code runs against local files, content bundled into the build, or published releases.

      -
      {
      -  "revision": "b1946ac92492d2347c6235b4d2611184",
      -  "blocks": {
      -    "SummerSEO": { "__resolveType": "seo", "title": "Summer collection" }
      -  }
      -}
      +
      +

      Production

      Releases and deployment

      +

      Every loader returns a revision with its content (see Content and loaders). localLoader, the CLI, and the Deco API all compute it as the same hash of the entries, so "is what I have current?" is one string comparison.

      @@ -1116,24 +1209,6 @@

      4. Configure Cloudflare Workers

      Content fromDeployment

      Revisions are immutable: change any entry and the hash changes. A revision is a safe cache key everywhere.

      -

      Configuring the loader

      -

      Most apps never pass a loader: createCMS reads the ./.deco directory by default. Pass an object to keep your content somewhere else, to connect the hosted Deco CMS, or both:

      -
      cms.ts
      // Default: the files in ./.deco
      -createCMS({ blocks });
      -
      -// Content in another directory
      -createCMS({ loader: { root: "./content/.deco" }, blocks });
      -
      -// Releases from the hosted Deco CMS, with the files in ./.deco as the fallback
      -createCMS({ loader: { token: process.env.DECO_SITE_TOKEN }, blocks });
      -
      -// Both: hosted releases, with ./content/.deco as the fallback
      -createCMS({
      -  loader: { root: "./content/.deco", token: process.env.DECO_SITE_TOKEN },
      -  blocks,
      -});
      -

      token is the site's API key for the Deco API. Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads the files only, so the same line works everywhere. For anything else, such as content bundled into a Workers build or your own store, pass a Loader (see API reference).

      -

      One revision per response

      A client reads one revision for its lifetime: its first call loads, and every later list and resolve reuses that content, even concurrent ones. Two clients can see different revisions, so make one client per request and do everything the response needs through it.

      When the browser requests more data for a page it has already rendered, send the page's revision along so the data matches the HTML. A revision ID picks content; it doesn't grant access. Preview access is the draft pointer's signed grant and the site's preview hosts on decocms, see Preview.

      @@ -1148,20 +1223,7 @@

      Never waiting on the network

      Fallback

      The fallback loader is the only fallback, and it's always available: it's the content the build shipped with, which is the content the deploy was tested against, so it's always compatible with the code. It always swaps whole revisions: mixing entries from different revisions breaks references and can bring back deleted content. The loader reports the revision it actually served.

      -

      Write your own loader

      -

      The contract is one method. A KV-backed loader for a Worker:

      -
      kv-loader.ts
      import type { Loader } from '@decocms/blocks'
      -
      -export function kvLoader(kv: KVNamespace, key = 'release:current'): Loader {
      -  return {
      -    async load() {
      -      const content = await kv.get(key, 'json')
      -      if (!content) throw new Error('No content in KV')
      -      return content   // { revision, blocks }
      -    },
      -  }
      -}
      -

      Pass it as createCMS's loader, or as the fallback of remoteLoader. If your store has drafts, honour the pointer argument; if it can go stale, add update(). The client only ever sees { revision, blocks }.

      +

      To point the CMS at a different source, or to write a loader for your own store, see Content and loaders.

      @@ -1218,13 +1280,13 @@

      Upgrade checklist

      Production

      Troubleshooting

      -

      Start with three facts: the revision served, the entry name, and the block type. Call client.resolve(target, { resolve: false }) to see the content before any code runs, and loader.load() to see an entry exactly as stored.

      +

      Start with three facts: the revision served, the entry name, and the block type. Call client.resolve(target, { resolve: false }) to see the content before any code runs, and the map your loader returns to see an entry exactly as stored.

      - + diff --git a/site/src/landing.html b/site/src/landing.html index 5e2f82b5..b340c1d3 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -302,7 +302,7 @@

      Small on purpose. Nothing How it works as a whole{{icon:arrow-right}} @@ -327,11 +327,11 @@

      Start with one function. S

      SymptomWhat to check
      UNKNOWN_BLOCKCompare __resolveType with your block map's keys, including case. Add an alias for renamed types. If the function does a dynamic import(), confirm the imported module has a default export.
      NOT_FOUNDA string target is always an entry name, never a block type. Confirm the entry exists in the served revision. To run a type directly, pass an inline block: client.resolve({ __resolveType: "seo", … }).
      My function never runs for a typeA saved entry probably has the same name. Run deco schema, which reports the collision, and rename the entry.
      My saved entry is ignoredA block type probably has the same name, and the function wins. Run deco schema, which reports the collision, and rename the entry.
      Content comes back unresolvedYou passed { resolve: false }, or the client was created without a block map.
      A function runs more than once per requestUse one client for the whole request, and keep the block map at module level. Each new client, and each new map object, gets its own cache.
      CYCLEFollow the references in error.path and remove the one that leads back to an entry already being expanded.
      - + - + @@ -1332,7 +1366,7 @@

      Contributing

      "product": { "__resolveType": "CurrentProduct" } }

      The saved entry. Its product input names CurrentProduct, another saved entry.

      -

      CurrentProduct is a saved catalog-product block, so looking it up is a data hit followed by a function hit. A descriptor names a view for the browser to render. A React tree is consumed by an RSC-capable framework. Either way, the CMS returns it untouched.

      +

      CurrentProduct is a saved catalog-product block, so looking it up is a data hit followed by a function hit. Once CurrentProduct expands, the stored entry means productCard({ title: "Summer collection", product: catalogProduct({ slug: "summer-shirt" }) }). A descriptor names a view for the browser to render. A React tree is consumed by an RSC-capable framework. Either way, the CMS returns it untouched.

      From 7df26e194786f136cf96a8e2492adfa24a926072 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 15:54:36 -0300 Subject: [PATCH 011/132] docs(site): rename the resolve option to run, align the docs with the new model - client.resolve(target, { run: false }) reads without running any function; client.list(type, { run: true }) runs each entry. The signatures, examples, troubleshooting and the walkthrough follow. - A cohesion pass over every page brings the rest of the docs in line with blocks as function composition and the content map: pages tagged with a Content type are read, not resolved; publishing is scoped to the hosted CMS vs the next deploy; captions name saved entries rather than files; loader wording no longer assumes the filesystem; stale links and leftovers fixed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/data/roadmap.json | 6 +- site/src/content.html | 171 ++++++++++++++++++++++------------------- site/src/landing.html | 6 +- 3 files changed, 99 insertions(+), 84 deletions(-) diff --git a/site/data/roadmap.json b/site/data/roadmap.json index 2cbc42b3..199ac398 100644 --- a/site/data/roadmap.json +++ b/site/data/roadmap.json @@ -523,7 +523,7 @@ { "id": "roadmap-studio-legacy--drop-hidden-array-items-instead-of-leaving-holes", "title": "Drop hidden array items instead of leaving holes", - "today": "Studio hides a banner or link by wrapping it in website/flags/multivariate.ts with a never rule (array-item-hidden.ts:35-40). Today's resolver drops undefined array items (resolve.ts:849-860). The proposed API's rule and resolve(page.sections) ('one result per section') keep them, so components receive undefined entries.", + "today": "Studio hides a banner or link by wrapping it in website/flags/multivariate.ts with a never rule (array-item-hidden.ts:35-40). Today's resolver drops undefined array items (resolve.ts:849-860). The proposed API's rule and resolve(page.sections) ('An array of results, one per section') keep them, so components receive undefined entries.", "features": [ "matchers-and-variants" ], @@ -1857,7 +1857,7 @@ { "id": "content-model", "title": "Content model", - "description": "How content is shaped: block types, named or global blocks, collections, props, rich text, editor widgets, drift and targeting rules." + "description": "How content is shaped: block types, saved entries (named or global), collections, props, rich text, editor widgets, drift and targeting rules." }, { "id": "sections", @@ -2319,7 +2319,7 @@ "sites": [ "blog-tanstack" ], - "summary": "Reading posts with `resolve: true` 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.", + "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": [ "model", "standalone" diff --git a/site/src/content.html b/site/src/content.html index 0962f9a4..c9da3431 100644 --- a/site/src/content.html +++ b/site/src/content.html @@ -30,11 +30,11 @@

      Authoring: from types to an editor

      Delivery: from files to your app

      -
      Content in GitJSON files in .deco/blocks
      +
      ContentFiles in .deco/blocks, or a release from the Deco API
      SDKclient.list and client.resolve read and resolve content
      Your appNext.js, TanStack Start, or any server
      -

      At request time, the SDK reads that content through a loader (by default, those same files), follows references between entries, and runs each block through the function you mapped to its type in your block map. What you render, and how, is up to your framework.

      +

      At request time, the SDK reads that content through a loader (by default, those same files), expands references to saved entries, and calls the function each block names in your block map. What you render, and how, is up to your framework.

      With the hosted Deco CMS, production reads the same content from the Deco API instead of disk, but a request never waits for it: content is served from memory and refreshed in the background, and the files the build shipped with are always there as the fallback. See how the loader stays current.

      Small on purpose

      @@ -44,7 +44,7 @@

      Small on purpose

    1. Blocks are plain functions. No framework plugin, no base classes, no magic directories. The block map is a plain object, and you can call any block function directly in a test.
    2. Loaders are one method. Loading from the filesystem, KV, or your own store is a load() that returns { revision, blocks }. Swap it and nothing else changes (see Content and loaders).
    3. The schema is a file. Commit it, inspect it, or generate it another way. Studio only needs the JSON.
    4. -
    5. Nothing is hidden. If the SDK doesn't do what you need, read the files yourself. The format is documented in Blocks.
    6. +
    7. Nothing is hidden. If the SDK doesn't do what you need, read the content yourself: the block format is documented in Blocks, and the files and the content map in Content and loaders.
    8. @@ -78,7 +78,7 @@

      Small on purpose

      npm install @decocms/blocks

      1. Make the function configurable

      -

      Any function with a typed first parameter can be a block. Give it a name in a block map. The key, experiments, is the block type that content will refer to. This file is plain data: the CLI reads it, and your app hands it to createCMS.

      +

      Any function with a typed first parameter can be a block. Give it a name in a block map. The key, experiments, is the block type that content will refer to. This file is a plain object of functions: the CLI reads it, and your app hands it to createCMS.

      blocks.ts
      import type { Blocks } from "@decocms/blocks";
       import experiments from "./experiments";
       
      @@ -95,7 +95,7 @@ 

      3. Add content

    9. In Studio. Open the editor, pick the experiments block type, fill in the form, and save. Studio writes the JSON file into your repository.
    10. Manually. Create the JSON file yourself, or let an AI agent asked to "ramp the new checkout to 25%" do it. Either way the result is the same file, so both routes are equivalent.
    11. -

      Either way, the file lands in .deco/blocks, which is where the CMS reads content from by default. The filename is the entry's name, and __resolveType is the block type:

      +

      Either way, the file lands in .deco/blocks, which is where the CMS reads content from by default. The file is a saved entry: its filename is the entry's name (see Content and loaders), and __resolveType names the function by its block type:

      .deco/blocks/Experiments.json
      {
         "__resolveType": "experiments",
         "newCheckout": 10,
      @@ -147,7 +147,7 @@ 

      Start from code you already write

      It's function composition. The inner call runs first, and its result becomes an input to the outer one, so productCard receives product data, never a pending call. The functions are the developer's job. The composition is a decision: which product, which title, which function fetches the data. Written in code, changing that decision means editing code and deploying it.

      A block is that composition, stored as data

      -

      Write the same composition as JSON and nobody has to edit code to change it. __resolveType names the function, the other properties are its inputs, and a block inside an input is a call inside a call. Here productCard and catalogProduct are the functions your map registers as "product-card" and "catalog-product":

      +

      Write the same composition as JSON and nobody has to edit code to change it. __resolveType names the function, the other properties are its inputs, and a block inside an input is a call inside a call. Here productCard and catalogProduct are the functions your block map registers as "product-card" and "catalog-product":

      // Stored as JSON
       {
         "__resolveType": "product-card",
      @@ -164,7 +164,7 @@ 

      A block is that composition, stored as data

      Name a composition, reuse it

      In code, you'd give a composition you use often a name and reuse it. Saved entries do that. Alongside the block map of functions, the CMS reads a second map, the content map, where each name holds a piece of saved JSON. { "__resolveType": "SummerSEO" } means whatever is saved under the name SummerSEO, written in place. The map can come from files in your repository, the hosted Deco CMS, or your own store; see Content and loaders.

      -

      Unlike a const that holds a result, what's named here is the call itself, inputs and all. That's why you can add inputs to a reference to override the saved block's inputs for this one use:

      +

      Unlike a const that holds a result, what's named here is the call itself, inputs and all. That's why you can add inputs to a reference to override the saved entry's inputs for this one use:

      // Saved as SummerSEO
       { "__resolveType": "seo", "title": "Sunny!", "description": "Light layers for long days." }
       
      @@ -185,6 +185,7 @@ 

      Resolving a page, step by step

      "seo": { "__resolveType": "SummerSEO", "title": "Summer sale" }, "sections": [{ "__resolveType": "Carousel", "banners": [] }] }
      +

      This page has no __resolveType, so it's plain JSON with calls inside, and resolving it is safe. The routable pages in Routes and redirects are tagged "__resolveType": "page", a data-only type the runtime doesn't know, so there you resolve each field instead (client.resolve(page.seo), one call per section).

      Call client.resolve("pages-summer") and follow the page as it changes.

      Step 1. The reference expands. SummerSEO is a saved entry, so it's data. The entry merges under the override, and its __resolveType now names a function.

      {
      @@ -202,7 +203,7 @@ 

      Resolving a page, step by step

      sections: [{ component: "Carousel", props: { banners: [] } }] }

      To step through a deeper example, see How resolution works.

      -

      Reading without running. Pass { resolve: false } to read content without calling any functions. client.resolve("pages-summer", { resolve: false }) stops after step 1: saved entries like SummerSEO still expand, and blocks that name a function, like seo and Carousel, come back as JSON, untouched. Nothing runs. That's useful for previews, editor tooling, and debugging. To read an entry exactly as stored, with its references left in place, look it up in the map your loader returns, for example (await localLoader("./.deco").load()).blocks["pages-summer"].

      +

      Reading without running. Pass { run: false } to read content without running any function. client.resolve("pages-summer", { run: false }) stops after step 1: saved entries like SummerSEO still expand, and blocks that name a function, like seo and Carousel, come back as JSON, untouched, and so does a data-only type such as page. Nothing runs. That's useful for previews, editor tooling, and debugging. To read an entry exactly as stored, with its references left in place, look it up in the map your loader returns; with the default loader, for example, (await localLoader("./.deco").load()).blocks["pages-summer"].

      The rule

      Every __resolveType is a name looked up in one registry, and a name is either data or code: a named composition to expand, or a function to call.

      @@ -212,7 +213,7 @@

      The rule

    12. Look up the name.
    13. Data: replace the object with { ...entry, ...inputs }, so your inputs override the entry's, and apply the rule to the result again.
    14. Function: resolve the inputs first, children before parents and siblings concurrently, then call the function. Its return value takes the block's place.
    15. -
    16. Neither: fail with UNKNOWN_BLOCK.
    17. +
    18. Neither: fail with UNKNOWN_BLOCK. That includes a data-only type declared in Content, such as page: the runtime never sees those types, so resolve the blocks inside such an entry, not the entry itself.
    19. The rule in detail

      @@ -221,7 +222,7 @@

      The rule in detail

    20. Results are opaque. The CMS never walks a function's return value. Return plain data, a descriptor, or JSX. This is also why a function that returns its input, like seo above, can't loop: __resolveType is stripped before the call, and whatever comes back is never looked up again.
    21. Only data can loop. A data hit 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.
    22. -

      Entry names and block types share one registry. If a name is both, the function wins, so a saved entry called seo and a seo function can't coexist. deco schema fails when an entry name matches a block type, so rename the entry.

      +

      Entry names and block types share one registry. If a name is both, the function wins, so a saved entry called seo and a seo function can't coexist. deco schema fails when an entry in .deco/blocks has the name of a block type, so rename the entry.

      @@ -239,6 +240,7 @@

      The rule in detail

      } }

      Each key under blocks is an entry name. It's the name a __resolveType refers to, and the name you pass to client.resolve("SummerSEO"). Despite the field name, these are saved entries: any JSON, never functions. Functions only ever come from the block map you give createCMS. The revision names this exact set of entries, so any change to any entry must give a new revision. The built-in loaders use a hash of the whole map. How releases use it is in Releases and deployment.

      +

      The map itself isn't a call: it's what the names in your content refer to. What each entry means is in Blocks.

      Where content comes from

      The content map can come from anywhere that can return JSON. These are the usual sources:

      @@ -260,6 +262,7 @@

      Configuring the loader

      // Content in another directory createCMS({ loader: { root: "./content/.deco" }, blocks }); +createCMS({ loader: "./content/.deco", blocks }); // shorthand for { root } // Releases from the hosted Deco CMS, with the files in ./.deco as the fallback createCMS({ loader: { token: process.env.DECO_SITE_TOKEN }, blocks }); @@ -269,7 +272,7 @@

      Configuring the loader

      loader: { root: "./content/.deco", token: process.env.DECO_SITE_TOKEN }, blocks, });
      -

      token is the site's API key for the Deco API. Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads the files only, so the same line works everywhere. For anything else, such as content bundled into a Workers build or your own store, pass a Loader (see API reference).

      +

      token is the site's API key for the Deco API. Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads the files only, so the same line works everywhere. For anything else, pass a Loader: localLoader({ revision, blocks }) for content bundled into a Workers build, or your own for any other store.

      Write your own loader

      The contract is one method. A KV-backed loader for a Worker:

      @@ -293,10 +296,10 @@

      Write your own loader

      Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. Point it at the file that exports your block map, the blocks.ts from the quickstart:

      npx deco schema --entry ./blocks.ts --out ./.deco/schema.json

      For each block type in the map, the CLI reads the type of the function's first parameter and turns it into a form definition. Commit .deco/schema.json with your code, and run the command in CI so the schema never drifts from your types.

      -

      The CLI also checks your content model: it fails if a saved entry's name collides with a block type, or if an alias collides with either.

      +

      The CLI also checks your content model: it fails if a saved entry in .deco/blocks has the name of a block type, or if an alias collides with either.

      Content that is only data

      -

      A page, a blog post, a redirect: these have a shape but no function. Nothing runs when they resolve; your app reads them and resolves the blocks inside them. They still need a schema so Studio can create them, so declare them for the CLI in a type-level map next to the block map:

      +

      A page, a blog post, a redirect: these have a shape but no function. They're never resolved themselves: your app reads them (with list or matchRoute) and resolves the blocks inside them. They still need a schema so Studio can create them, so declare them for the CLI in a type-level map next to the block map:

      blocks.ts
      import type { Blocks, Redirect } from "@decocms/blocks";
       import type { Page } from "./page";
       import type { Post } from "./post";
      @@ -314,7 +317,7 @@ 

      Content that is only data

      Studio reads the schema this command writes, and nothing else about your code. What it looks for, and how the CLI bridges today's Studio conventions, is covered under the hood.

      -

      There is no publish command. Content is published by changing it: commit a file in .deco/blocks and push, or save in Studio, which commits for you. Both land in the repository, and the Deco API serves the latest commit of the production branch as the current release. If an agent runs deco publish, the CLI only says this.

      +

      There is no publish command. Content is published by changing it: commit a file in .deco/blocks and push, or save in Studio, which commits for you. With the hosted Deco CMS, that commit becomes the current release without a deploy; without it, the content goes live with your next deploy. See Publishing. If an agent runs deco publish, the CLI only says this.

      @@ -330,9 +333,9 @@

      Content that is only data

      const pointer = draftPointer(request) // null on an ordinary request const client = pointer ? cms.forDraft(pointer) : cms.forRelease() - return render(client, request) // list, matchRoute, resolve: as usual + return render(client, request) // list, matchRoute and resolve as usual }
      -

      forDraft hands the pointer to the loader. remoteLoader fetches that exact version from Studio's content API, waits for it, and caches it by version, so the second request for the same draft is a memory read. A draft replaces the whole revision, deletions included, so a page in preview looks exactly as it will after publishing. localLoader ignores the pointer: in development you're already looking at the files. That's why the code above never checks which loader it has.

      +

      forDraft hands the pointer to the loader. remoteLoader fetches that exact version from Studio's content API, waits for it, and caches it by version, so the second request for the same draft is a memory read. A draft replaces the whole revision, deletions included, so a page in preview looks exactly as it will after publishing. localLoader ignores the pointer: in development you're already looking at the files. A loader you write honors it if your store has drafts (see Write your own loader). That's why the code above never checks which loader it has.

      Keep the draft across navigation

      The pointer is only in the URL of the first page Studio opens. draftCookie(request) gives you the Set-Cookie value that response needs, and null on every other request. Append it, and draftPointer reads the cookie from then on, so every link the editor clicks stays in preview:

      @@ -358,7 +361,7 @@

      Not a website

      A script passes it as an argument. To look inside a pointer, or build one from parts, use parseDraftPointer and formatDraftPointer from the reference; forDraft parses on its own, so normally you don't have to.

      Who may preview

      -

      Two checks, and neither needs configuration in your app. remoteLoader uses its token to learn the site's content API from decocms, and forDraft ignores any pointer whose authority isn't that API, so a pointer can never turn into a request to an arbitrary origin. The pointer's path carries a signed grant that the content API verifies, so a draft is only served to someone Studio let in.

      +

      Two checks, and neither needs configuration in your app. remoteLoader uses its token to learn the site's content API from the Deco API, and forDraft ignores any pointer whose authority isn't that API, so a pointer can never turn into a request to an arbitrary origin. The pointer's path carries a signed grant that the content API verifies, so a draft is only served to someone Studio let in.

      If a website wants to show drafts on its preview domain only, it checks the request's host before calling forDraft:

      const host = new URL(request.url).host
       const pointer = host === 'preview.store.example.com' ? draftPointer(request) : null
      @@ -368,7 +371,7 @@ 

      Who may preview

      Advanced

      Block maps

      -

      A block map is the code half of the registry: a plain object from block type to function. The quickstart's blocks.ts is one. This section covers what else can go in it.

      +

      A block map is the code half of the registry: a plain object from block type to function. The other half is the content map of saved entries, which your loader supplies. The quickstart's blocks.ts is one. This section covers what else can go in it.

      blocks.ts
      import type { Blocks } from "@decocms/blocks";
       import seo from "./seo";
       import productCard from "./product-card";
      @@ -379,7 +382,7 @@ 

      Who may preview

      "product-card": productCard, hero, } satisfies Blocks;
      -

      Each key is a block type. Each value is a function (inputs) => value, where inputs is the block without its __resolveType, with nested blocks already resolved. Use satisfies Blocks rather than a type annotation: it checks the shape while keeping the concrete keys and function types, which is what makes resolve<T> inference and the schema work.

      +

      Each key is a block type. Each value is a function (inputs) => value, where inputs is the block without its __resolveType: saved entries already expanded and merged, and nested calls already run, so the function receives values, never a pending call. Use satisfies Blocks rather than a type annotation: it checks the shape while keeping the concrete keys and function types, which is what the schema is generated from.

      Keep the map a plain object at module level. The CLI reads the file without running it, so build it once and give it to createCMS. Only functions go in the map. Types that are pure data, such as pages, posts, and redirects, have nothing to run; they're declared for the CLI instead, see content that is only data.

      The CMS is pure. It passes your function the block and nothing else: no context object, no request, no hidden state. When a block needs the current request, a cookie, or a route parameter, read it the way the rest of your app does, usually through the AsyncLocalStorage your server or framework already provides (next/headers in Next.js, getRequest() in TanStack Start). That keeps block functions ordinary functions you can call and test directly.

      @@ -410,7 +413,7 @@

      Lazy loading

      Keep import paths literal in both cases. A bundler can split import("./sections/Product") into its own chunk; it can't split import(`./sections/${name}`).

      Choose type names that last

      -

      A type name is what content stores in __resolveType, so pick it like a database column, not like a file path: short (seo, hero) or namespaced when a site has many (commerce/product). Moving a file only means updating an import; the name doesn't change. Renaming a type means migrating every block that uses it, or adding an alias.

      +

      A block type's name is what content stores in __resolveType to call it, so pick it like a database column, not like a file path: short (seo, hero) or namespaced when a site has many (commerce/product). Moving a file only means updating an import; the name doesn't change. Renaming a type means migrating every block that uses it, or adding an alias. Type names share one namespace with saved entry names (see Blocks), and deco schema fails on a clash, so prefer names an editor wouldn't give an entry.

      @@ -438,7 +441,7 @@

      The multivariate is three lines you own

      export default { always, never, date, multivariate, hero } satisfies Blocks;

      Editors compose them in Studio: a variant is a tab, its rule is picked from the matchers in your map, and the first tab is the default. A hero whose title changes for a campaign is a hero whose title is a multivariate:

      -
      .deco/blocks/HomeHero.json
      {
      +
      Saved as HomeHero
      {
         "__resolveType": "hero",
         "title": {
           "__resolveType": "multivariate",
      @@ -470,7 +473,7 @@ 

      Write your own matcher

      }
      matchers.ts (app)
      // A mobile app: the user's plan from app state. No request, no headers.
       export const plan = ({ plan }: { plan: "free" | "pro" }) => session.current().plan === plan;
      -

      Add them to the map and Studio's rule picker shows them beside date: it lists every function in the manifest's matchers group, which the CLI fills with every block function that returns a boolean. Combinators are matchers too: and, or, and not are one-liners over booleans, if you want them.

      +

      Add them to the map and Studio's rule picker shows them beside date: it lists every function in the matchers group of the schema's manifest, which the CLI fills with every block function that returns a boolean. Combinators are matchers too: and, or, and not are one-liners over booleans, if you want them.

      Every variant resolves

      Inputs resolve before a function runs, so every variant's value resolves before multivariate picks one. For a title that's free. For two product queries it isn't, and a failure in a variant you didn't pick still fails the whole thing.

      @@ -482,9 +485,15 @@

      Every variant resolves

      { "rule": { "__resolveType": "always" }, "value": "DefaultHero" } ] }
      -
      // section (the JSON above) means
      -// multivariate({ variants: [{ rule: date({ … }), value: "BlackFridayHero" }, { rule: always(), value: "DefaultHero" }] })
      -// The values are strings, not calls: only the rules and multivariate run.
      +
      // Means
      +multivariate({
      +  variants: [
      +    { rule: date({ start: "2026-11-20", end: "2026-11-30" }), value: "BlackFridayHero" },
      +    { rule: always(), value: "DefaultHero" },
      +  ],
      +})   // the values are entry names, not calls: only the rules and multivariate run
      +
      +// With the client, pass the stored JSON above as section
       const [name] = await client.resolve(section);   // "BlackFridayHero"
       const [hero] = await client.resolve(name);      // only this variant ran

      A variant is not access control. It chooses between values that are all fine to compute and show. 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.

      @@ -508,12 +517,12 @@

      Every variant resolves

      to: string; permanent: boolean; // 301 or 302 }
      -

      A page is a route with SEO and sections. It's not special to the router; it's just the first type that extends Route. Like every data-only type, it's declared in Content, not in the block map:

      +

      A page is a route with SEO and sections. It's not special to the router; it's just the first type that extends Route. It's shaped like the pages-summer entry in Blocks, with three additions: "__resolveType": "page", so the CMS can list it by type, and a name and path, so the router can find it. Like every data-only type, it's declared in Content, not in the block map:

      interface Page extends Route {
         seo: PageSeo;
         sections: VariantOf<Array<VariantOf<Block>>>;
       }
      -
      .deco/blocks/SummerPage.json
      {
      +
      Saved as SummerPage
      {
         "__resolveType": "page",
         "name": "Summer campaign",
         "path": "/summer",
      @@ -522,7 +531,7 @@ 

      Every variant resolves

      { "__resolveType": "hero", "title": "Summer starts here", "image": "https://cdn.example.com/summer.jpg" } ] }
      -
      // Means: page is a Content type, not a function, so the page stays data with calls inside
      +
      // Means: page names no function (it's a Content type), so resolve the fields, not the page
       {
         __resolveType: "page",
         name: "Summer campaign",
      @@ -530,7 +539,7 @@ 

      Every variant resolves

      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" })], }
      -
      .deco/blocks/LegacySummer.json
      {
      +
      Saved as LegacySummer
      {
         "__resolveType": "redirect",
         "from": "/campaigns/summer",
         "to": "/summer",
      @@ -548,14 +557,14 @@ 

      Your own routable types

      /** @format rich-text */ body: string; }
      -
      .deco/blocks/HelloWorld.json
      { "__resolveType": "post", "name": "Hello, world", "path": "/blog/hello-world", "date": "2026-09-01", "body": "…" }
      +
      Saved as HelloWorld
      { "__resolveType": "post", "name": "Hello, world", "path": "/blog/hello-world", "date": "2026-09-01", "body": "…" }

      Studio pre-fills path from a per-type template such as /blog/{slug}, so editors get consistent URLs by default and can still change one. Two entries with the same path, or two templates that can match the same URL, are rejected by the CLI and by matchRoute.

      Pages are data; resolve their fields

      -

      A page has no function, so you don't resolve the page. You resolve the blocks it holds: client.resolve(page.seo), and one client.resolve(section) per section, so sections can stream as they finish (see the framework guides). A redirect is never resolved at all; matchRoute reads it. If you do want code to run for a whole page, such as validation or a layout step, add a page function to the block map and move Page out of Content; then client.resolve(page) runs it after every section has resolved.

      +

      A page has no function, so you don't resolve the page. You resolve the blocks it holds: client.resolve(page.seo), and one client.resolve(section) per section, so sections can stream as they finish (see the framework guides). A redirect is never resolved at all; matchRoute reads it. If you do want code to run for a whole page, such as validation or a layout step, add a page function to the block map and move Page out of Content; then client.resolve(page) runs it after every section has resolved. Until you do, resolving the page itself, client.resolve("SummerPage") or client.list("page", { run: true }), fails with UNKNOWN_BLOCK: page names no function, and the runtime never sees Content (see the rule). To read a page, list it, or pass { run: false }.

      Content types without a URL

      -

      Navigation, email templates, and campaign settings work the same way, minus Route: declare the type in Content if it's data, or register a function if something should run, and save entries of that type. Block is a stored block. VariantOf<T> accepts a T or a flag that picks one.

      +

      Navigation, email templates, and campaign settings work the same way, minus Route: declare the type in Content if it's data, or register a function if something should run, and save entries of that type. Block is a block as stored: JSON whose __resolveType names a function or a saved entry. VariantOf<T> accepts a T or a multivariate block that picks one.

      @@ -564,7 +573,7 @@

      Content types without a URL

      import { matchRoute, type Redirect } from "@decocms/blocks";
       
       const client = cms.forRelease();
      -const [pages]     = await client.list<Page>("page");
      +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");
       
      @@ -594,7 +603,7 @@ 

      Content types without a URL

      CallReturns
      resolve("SummerSEO")The result of running the entry's function
      resolve("SummerSEO")The result of running the entry's function: for SummerSEO, seo({ title: "Sunny!", description: "Light layers for long days." })
      resolve("SummerSEO", { resolve: false })The saved entry, with references expanded and nothing run
      resolve({ __resolveType: "seo", title: "Sale" })The result for an inline block
      resolve({ __resolveType: "seo", title: "Sale" })The result for an inline block: seo({ title: "Sale" })
      resolve(page.sections)An array of results, one per section
      resolve({ title: "Store" })The same object: it contains no blocks
      "not-found"None
      -

      matchRoute is a helper, not a requirement. The CMS has no idea what a URL is. If your framework already routes, or you'd rather look up a page by name (client.resolve("SummerPage")) or by a field of your own, skip it. It exists because "an editor typed a path into Studio and that page is now live" is the common case, and getting precedence and parameters right by hand is tedious.

      +

      matchRoute is a helper, not a requirement. The CMS has no idea what a URL is. If your framework already routes, or you'd rather read a page by name (client.resolve("SummerPage", { run: false }), then resolve its fields) or find it by a field of your own, skip it. It exists because "an editor typed a path into Studio and that page is now live" is the common case, and getting precedence and parameters right by hand is tedious.

      Which types route is your decision, per handler: a blog lists post, a docs site lists doc. match.params holds the URL parameters; hand them to your code however your app shares request state. The CMS never injects them into blocks.

      Matching is a lookup, not a scan: entries are compiled into a segment trie once per list, so a match costs the URL's depth, not the catalogue's size. The algorithm is described under the hood.

      @@ -611,14 +620,14 @@

      Linking and listing

      // { kind: "match", entry: { __resolveType: "post", name: "Hello, world", path: "/blog/hello-world", … }, params: {} }

      Example project

      -

      The framework guides share these files: the model and two content entries. Each guide's block map (src/blocks.tsx or src/blocks.ts) declares seo, its own product, and the Content type.

      +

      The framework guides share these files: the model and two content entries, stored as files in .deco/blocks, where the default loader reads them (see Content and loaders). Each guide's block map (src/blocks.tsx or src/blocks.ts) declares seo, its own product, and the Content type.

      src/model.ts
      import type { Block, Route } from '@decocms/blocks'
       
       export interface SEO { title: string; description: string }
       export interface ProductProps { id: string; name: string; price: number; currency: string }
       export interface SectionData { component: 'product'; props: ProductProps }
       
      -// A stored page, as client.list returns it.
      +// A saved page, as client.list returns it by default (nothing run).
       export interface StoredPage extends Route {
         seo: SEO | Block
         sections: Block[]
      @@ -637,7 +646,7 @@ 

      Example project

      { "__resolveType": "product", "id": "related", "name": "Related shirt", "price": 39, "currency": "USD" } ] }
      -
      // Means: page is a Content type, not a function, so the page stays data with calls inside
      +
      // Means: page names no function (it's a Content type), so resolve the fields, not the page
       {
         __resolveType: "page",
         name: "Product page",
      @@ -655,7 +664,7 @@ 

      Example project

      "to": "/:slug/p", "permanent": true }
      -

      The redirect sends /old-products/summer to /summer/p. The page uses literal product values to keep the example small. A real page would use a block that fetches the product for the slug in match.params.

      +

      The redirect sends /old-products/summer to /summer/p. The page uses literal product values to keep the example small. A real page would use a block that fetches the product for the slug, reading match.params from your app's request state (see Block maps).

      @@ -708,22 +717,22 @@

      createCMS(config)

      } interface Client { - resolve<T = unknown>(target: unknown, options?: { resolve?: boolean }): Promise<Result<T>>; // resolve: false reads without running + 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[]>>; }
      -

      The loader option covers the common cases by shape. Leave it out and the CMS reads the files in ./.deco. A path is the files in that directory. { token } is releases from the Deco API with the files in ./.deco as fallback, and root changes the directory. Leave token undefined and it's just the files, which is what makes one line work in development and production. Anything else is a Loader you built yourself (examples in Content and loaders). Create the CMS once, at module scope, and ask it for a client per request: forRelease() for visitors, forDraft(pointer) when the request carries one. Clients are free to make and hold nothing you'd want to share. Everything either does is a few lines over a loader, and the registry rule from Blocks is how it resolves; those internals aren't part of the public API.

      +

      The loader option takes a directory path, { root?, token? }, or a Loader; leave it out to read ./.deco. What each shape does is in Configuring the loader. Create the CMS once, at module scope, and ask it for a client per request: forRelease() for visitors, forDraft(pointer) when the request carries one. Clients are free to make and hold nothing you'd want to share. Everything either does is a few lines over a loader, and the rule from Blocks is how it resolves; those internals aren't part of the public API.

      Loaders

      One interface. load() with no argument is production; load(pointer) is a draft. update() checks for newer content. Which one to use, and how to write your own, is in Content and loaders.

      interface Loader {
      -  load(pointer?: string | null): Promise<{ revision: string; blocks: Record<string, Block> }>;   // Block: any saved JSON
      +  load(pointer?: string | null): Promise<{ revision: string; blocks: Record<string, unknown> }>;   // the content map: entry name → saved JSON
         update?(): Promise<{ updated: boolean }>;
       }
      - +
      LoaderLoadsUse it for
      localLoader(dir)
      localLoader({ revision, blocks })
      The content that ships with the app: a .deco directory on Node, or JSON imported at build time on Workers. Ignores the pointer: there are no drafts in local content. No update().Development, tests, and the fallback in production
      remoteLoader(fallback, { token })The current release from the Deco API over a fallback loader, or the draft a pointer names. The token identifies the site; everything else comes from decocms.Production
      remoteLoader(fallback, { token })The current release from the Deco API over a fallback loader, or the draft a pointer names. The token identifies the site; everything else comes from the Deco API.Production

      remoteLoader(localLoader("./.deco"), { token }): load() never waits on the network. It returns what's in memory, the newest release fetched or else the fallback loader's content, and update() checks for newer content in the background. See how it stays current. load(pointer) fetches the draft a pointer names and caches it by version; drafts never fall back. See Preview.

      @@ -757,12 +766,12 @@

      Draft pointers

      Caching, ETags, and fallbacks live inside the loader, which may be shared by any number of clients. The client only ever sees { revision, blocks }.

      client.resolve(target, options?)

      -

      Loads the content once and applies the registry rule to the target: saved entries expand, and each block type in the map runs with its resolved inputs. { resolve: false } expands entries but runs nothing.

      +

      Loads the content once and applies the registry rule to the target: saved entries expand, and each function the target names in your block map runs with its resolved inputs. With { run: false } it reads without running: entries expand, but no function runs.

      - + @@ -772,12 +781,12 @@

      client.resolve(target, options?)

      A client reads one revision for its lifetime. Its first call loads; every later call, list or resolve, reuses that content and shares a cache. That's why a client is made per request: everything a response renders agrees with itself, sections can stream as separate calls, and the next request sees the next revision. Two clients can see different revisions, so don't share one across requests.

      client.list(type, options?)

      -

      Every saved entry of a block type, through aliases. Returns stored entries by default; resolve: true runs each one.

      +

      Every saved entry whose __resolveType is type or an alias of it. 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 must name a function in your block map, or it fails with UNKNOWN_BLOCK.

      interface ListOptions<T> {
         where?: (entry: T) => boolean;
         sort?: (a: T, b: T) => number;
         limit?: number;
      -  resolve?: boolean;
      +  run?: boolean;
       }
       
       const [posts] = await client.list<Post>("post", {
      @@ -785,7 +794,7 @@ 

      client.list(type, options?)

      sort: (a, b) => b.date.localeCompare(a.date), limit: 20, });
      -

      Filters run in memory over the loaded content, which is the honest cost of a file-based store. Sites with tens of thousands of entries of one type should keep an index elsewhere.

      +

      Filters run in memory over the loaded content map, the cost of a loader returning the whole map at once (see Content and loaders). 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 Routing.

      @@ -808,7 +817,7 @@

      Types

      type Blocks = Record<string, BlockFunction>; interface Loader { - load(pointer?: string | null): Promise<{ revision: string; blocks: Record<string, Block> }>; + load(pointer?: string | null): Promise<{ revision: string; blocks: Record<string, unknown> }>; // the content map: entry name → saved JSON update?(): Promise<{ updated: boolean }>; } @@ -890,13 +899,14 @@

      2. Create the CMS

      // Development (no token) reads the files in .deco. Production reads releases from the Deco API // and falls back to those same files. export const cms = createCMS({ loader: { token: process.env.DECO_SITE_TOKEN }, blocks }) +

      The loader shapes and their fallbacks are in Configuring the loader.

      src/client.server.ts
      import 'server-only'
       import { cookies } from 'next/headers'
       import { DRAFT_COOKIE } from '@decocms/blocks'
       import { cms } from './cms'
       
       // The client for this request: a draft when the cookie holds a valid pointer, production otherwise.
      -// Without a token, in development, this is always the release client; only the API loader fetches drafts.
      +// Without a token, in development, a draft client reads the same files as the release: localLoader ignores the pointer, and only remoteLoader fetches drafts.
       export async function client() {
         const pointer = (await cookies()).get(DRAFT_COOKIE)?.value
         return pointer ? cms.forDraft(pointer) : cms.forRelease()
      @@ -1031,7 +1041,7 @@ 

      1. Return descriptors from blocks

      export type Content = { page: StoredPage; redirect: Redirect }

      2. Create the CMS

      -

      Workers have no filesystem, so local content is the JSON imported at build time, and remoteLoader serves releases over it. wrangler dev reads the token from .dev.vars.

      +

      Workers have no filesystem, so local content is the JSON imported at build time, keyed by entry name (see Content and loaders), and remoteLoader serves releases over it. wrangler dev reads the token from .dev.vars.

      src/cms.ts
      import { createCMS, localLoader, remoteLoader, draftPointer } from '@decocms/blocks'
       import { env } from 'cloudflare:workers'
       import ProductPage from '../.deco/blocks/ProductPage.json'
      @@ -1039,13 +1049,15 @@ 

      2. Create the CMS

      import blocks from './blocks' // The content this build shipped with. remoteLoader falls back to it if the API is unreachable. -const local = localLoader({ revision: 'bundled', blocks: { ProductPage, LegacyProduct } }) +// revision: any string that changes whenever this JSON does, e.g. a content hash your build defines +const local = localLoader({ revision: import.meta.env.VITE_CONTENT_REVISION, blocks: { ProductPage, LegacyProduct } }) export const cms = createCMS({ loader: remoteLoader(local, { token: env.DECO_SITE_TOKEN }), blocks }) // The client for this request: the draft it points at, or the current release. // Workers have no timers between requests, so each request also kicks the update check; // it returns at once unless five minutes have passed, and never blocks the response. +// Not passed to waitUntil here, so the check can be cut short after the response; see Loaders in the API reference. export const client = (request: Request) => { cms.update() const pointer = draftPointer(request) @@ -1135,7 +1147,7 @@

      5. Render the catch-all route

      ) }

      Keep section promises unawaited. Start streams nested promises through its own transport and Await consumes them. A plain JSON endpoint can't do this: it has to await every value first.

      -

      Import the server function statically from the route, so Start can replace it with an RPC stub in the client build. The component registry and its views ship to the browser. A block that needs the request calls getRequest() from @tanstack/react-start/server. Check preview authorization in the handler before selecting a private release. See Start server functions.

      +

      Import the server function statically from the route, so Start can replace it with an RPC stub in the client build. The component registry and its views ship to the browser. A block that needs the request calls getRequest() from @tanstack/react-start/server. Drafts need no authorization code here: the pointer carries a signed grant (see Who may preview). See Start server functions.

      6. Configure Cloudflare Workers

      vite.config.ts
      import { defineConfig } from 'vite'
      @@ -1233,29 +1245,30 @@ 

      4. Configure Cloudflare Workers

      Production

      Releases and deployment

      -

      Every loader returns a revision with its content (see Content and loaders). localLoader, the CLI, and the Deco API all compute it as the same hash of the entries, so "is what I have current?" is one string comparison.

      +

      Every loader returns a revision with its content (see Content and loaders). localLoader, remoteLoader, the CLI, and the Deco API all compute it as the same hash of the whole content map, so "is what I have current?" is one string comparison. A loader you write must keep the same guarantee (see Write your own loader).

      CallReturns
      resolve("SummerSEO")The result of running the entry's function: for SummerSEO, seo({ title: "Sunny!", description: "Light layers for long days." })
      resolve("SummerSEO", { resolve: false })The saved entry, with references expanded and nothing run
      resolve("SummerSEO", { run: false })The saved entry, with references expanded and nothing run
      resolve({ __resolveType: "seo", title: "Sale" })The result for an inline block: seo({ title: "Sale" })
      resolve(page.sections)An array of results, one per section
      resolve({ title: "Store" })The same object: it contains no blocks
      +
      Content fromDeployment
      localLoaderThe content that ships with the app: a directory on Node, or JSON bundled into the build on Workers. On its own, changing content means redeploying (or, on Node, editing files); as the fallback of remoteLoader, it's what the app serves when the API is down.
      remoteLoader(fallback, …)The Deco API serves each commit of the production branch as an immutable release. The loader reads the current one at request time, cached by revision, with the content the build shipped with as the fallback. A content change reaches visitors without a deploy. This is what "instant deploys" means.
      A Loader you writeWhatever your store does. Content reaches visitors when the loader returns a new revision; if it can go stale, implement update() (see Write your own loader).

      Revisions are immutable: change any entry and the hash changes. A revision is a safe cache key everywhere.

      One revision per response

      A client reads one revision for its lifetime: its first call loads, and every later list and resolve reuses that content, even concurrent ones. Two clients can see different revisions, so make one client per request and do everything the response needs through it.

      -

      When the browser requests more data for a page it has already rendered, send the page's revision along so the data matches the HTML. A revision ID picks content; it doesn't grant access. Preview access is the draft pointer's signed grant and the site's preview hosts on decocms, see Preview.

      +

      When the browser requests more data for a page it has already rendered, send the page's revision along so the data matches the HTML. A revision ID picks content; it doesn't grant access. Preview access is the draft pointer's signed grant and the site's preview hosts in the hosted Deco CMS, see Preview.

      Publishing

      -

      Publishing is committing. A developer or an agent edits .deco/blocks and pushes; an editor saves in Studio, which commits to the same repository. Either way the production branch moves, and the Deco API turns that commit into a release. remoteLoader resolves the production channel to a revision once per load, caches immutable releases indefinitely, and polls for a new revision every five minutes, so a change reaches every visitor within about five minutes of the commit. Because content and code share one history, a deploy and a content change are the same kind of event: a commit. Rolling content back is reverting one.

      +

      With the hosted Deco CMS, publishing is committing. A developer or an agent edits the entry files in your content directory (.deco/blocks by default) and pushes; an editor saves in Studio, which commits to the same repository. (If you load from your own store, see Content and loaders.) Either way the production branch moves, and the Deco API turns that commit into a release. remoteLoader checks the production channel's revision in the background, on first use and then every five minutes, and caches immutable releases indefinitely, so a change reaches every visitor within about five minutes of the commit. Because content and code share one history, a deploy and a content change are the same kind of event: a commit. Rolling content back is reverting one.

      The API skips a release whose schema doesn't match the running app's, so content can't get ahead of code: if a commit adds a block type the deployed build doesn't have, the loader keeps serving the previous release until the build catches up.

      Never waiting on the network

      A production request never waits for the API. load() returns from memory, immediately, every time, and the loader checks for newer content in the background every five minutes. Publishing reaches visitors within about that long. How that works, step by step, is under the hood.

      Fallback

      -

      The fallback loader is the only fallback, and it's always available: it's the content the build shipped with, which is the content the deploy was tested against, so it's always compatible with the code. It always swaps whole revisions: mixing entries from different revisions breaks references and can bring back deleted content. The loader reports the revision it actually served.

      +

      The fallback loader is the only fallback. With the default, localLoader, it's the content the build shipped with, which is the content the deploy was tested against, so it's always available and always compatible with the code. If you pass your own loader as the fallback (see Write your own loader), those guarantees are yours to keep. It always swaps whole revisions: mixing entries from different revisions breaks references and can bring back deleted content. The loader reports the revision it actually served.

      To point the CMS at a different source, or to write a loader for your own store, see Content and loaders.

      @@ -1266,6 +1279,7 @@

      Fallback

      + @@ -1275,13 +1289,13 @@

      Fallback

      A block type alone is never a cache key. Include every input that can change the result. A cache hit doesn't bypass authorization. Keep state-changing operations out of content resolution entirely.

      Tracing

      -

      Make each request span the parent of block resolution spans and outbound HTTP spans. Record the block type, revision, duration, errors, and cache outcome, and keep credentials and customer data out of attributes. Export telemetry asynchronously, so an unavailable collector never blocks rendering. OpenTelemetry covers traces, metrics, and logs.

      -

      Studio and MCP tools build authoring and publishing workflows on these sources. They're not part of a storefront's request path.

      +

      Make each request span the parent of block resolution spans and outbound HTTP spans. Record the revision, the entry name (when the block came from a saved entry), the block type, duration, errors, and cache outcome, and keep credentials and customer data out of attributes. Export telemetry asynchronously, so an unavailable collector never blocks rendering. OpenTelemetry covers traces, metrics, and logs.

      +

      Studio and MCP tools build authoring and publishing workflows on the content your loader serves (see Content and loaders). They're not part of a storefront's request path.

      Production

      Upgrading and compatibility

      -

      Block type names and input shapes are persistent interfaces: saved content depends on them. Moving a file is free. Renaming a type or removing an input affects every block that uses it.

      +

      Block type names, saved entry names, and input shapes are persistent interfaces: saved content refers to them by name. Moving a function's source file is free, because content stores the block map key, not the path. Renaming a type or removing an input affects every block that uses it, and renaming a saved entry breaks every reference to it (NOT_FOUND).

      Rename a type with an alias

      An alias is another key pointing at the same thing: the same function in the block map, or the same type in Content. Content that stores the old name keeps working, and routing is unaffected because it looks at path, not the type name:

      @@ -1305,7 +1319,7 @@

      Upgrade checklist

      1. Keep existing names. Register existing path-like types as they are, or alias them.
      2. Check inputs. Regenerate the schema and validate saved content. Review new required fields, narrowed unions, and changed defaults.
      3. -
      4. Migrate into a new revision. Keep unknown fields for inspection, and report unresolved references instead of dropping them.
      5. +
      6. Migrate into a new revision. Keep unknown fields for inspection, and report references to missing entries (NOT_FOUND) instead of dropping them.
      7. Test the integration. Check routing, metadata, streamed sections, error fallbacks, and client navigation against the migrated content.
      8. Keep a rollback pair. Keep the previous build with compatible content. Rolling back content alone can't restore removed code.
      @@ -1314,21 +1328,21 @@

      Upgrade checklist

      Production

      Troubleshooting

      -

      Start with three facts: the revision served, the entry name, and the block type. Call client.resolve(target, { resolve: false }) to see the content before any code runs, and the map your loader returns to see an entry exactly as stored.

      +

      Start with three facts: the revision served, the entry name, and the block type. Call client.resolve(target, { run: false }) to see the content before any code runs, and the map your loader returns to see an entry exactly as stored.

      LayerLifetimeYour responsibility
      Content mapCached in the loader: releases by revision, indefinitely; drafts by version. See Content and loaders and how remoteLoader stays current.Nothing, unless you write a loader: then key by revision.
      Dynamically imported modulesCached by the JavaScript runtime after the first import(), for the life of the process.Keep per-request state out of module globals.
      Resolved blocksOne client: one request.Make a client per request and resolve everything the response renders through it.
      Cross-request resultsYour policy.Key by revision, app version, inputs, and relevant request context. Define TTL and invalidation.
      - + - + - +
      SymptomWhat to check
      UNKNOWN_BLOCKCompare __resolveType with your block map's keys, including case. Add an alias for renamed types. If the function does a dynamic import(), confirm the imported module has a default export.
      NOT_FOUNDA string target is always an entry name, never a block type. Confirm the entry exists in the served revision. To run a type directly, pass an inline block: client.resolve({ __resolveType: "seo", … }).
      NOT_FOUNDA string target is always an entry name, never a block type. Confirm the entry exists in the served revision, and that the loader reads the content you expect (see Configuring the loader). To run a type directly, pass an inline block: client.resolve({ __resolveType: "seo", … }).
      My saved entry is ignoredA block type probably has the same name, and the function wins. Run deco schema, which reports the collision, and rename the entry.
      Content comes back unresolvedYou passed { resolve: false }, or the client was created without a block map.
      Blocks come back as JSON instead of runningYou called client.list without { run: true } (it returns saved entries by default), or you passed { run: false } to resolve. To run listed entries, pass run: true, or resolve the fields you need with client.resolve.
      A function runs more than once per requestUse one client for the whole request, and keep the block map at module level. Each new client, and each new map object, gets its own cache.
      CYCLEFollow the references in error.path and remove the one that leads back to an entry already being expanded.
      Wrong fields in the editorRegenerate the schema from the right blocks.ts. Check the first parameter's type and its property JSDoc.
      A result can't cross the networkJSON can't carry functions or React trees. Return serializable descriptors, or use an RSC integration.
      Hydration mismatchCompare revisions and request context between server and client. Look for time-dependent or random rendering, and send the page's revision with follow-up requests.
      Content changes mid-responseUse one client for the whole response; a client never changes revision. Check that the loader never rewrites an existing revision.
      Stale or fallback contentCheck the revision the loader served, channel freshness, and release availability. Never mix entries from different revisions.
      Stale or fallback contentCheck the revision the loader served, channel freshness, and release availability: a release whose schema doesn't match the app is skipped, and on Workers update() only runs if you call it. Never mix entries from different revisions. See Releases and deployment and how remoteLoader stays current.
      @@ -1356,9 +1370,9 @@

      Contributing

      Under the hood

      How resolution works

      -

      Step through the registry rule for one saved entry. Switch between reading and resolving, and between a descriptor and a React tree as the function output. This is an illustration, so it doesn't run any application code.

      +

      Step through the rule for one saved entry. Switch between a read with { run: false } and a full resolve, and between a descriptor and a React tree as the function output. This is an illustration, so it doesn't run any application code.

      -
      ProductCard · one saved entry
      +
      ProductCard · one saved entry
      Function output
      {
         "__resolveType": "product-card",
      @@ -1366,7 +1380,7 @@ 

      Contributing

      "product": { "__resolveType": "CurrentProduct" } }

      The saved entry. Its product input names CurrentProduct, another saved entry.

      -

      CurrentProduct is a saved catalog-product block, so looking it up is a data hit followed by a function hit. Once CurrentProduct expands, the stored entry means productCard({ title: "Summer collection", product: catalogProduct({ slug: "summer-shirt" }) }). A descriptor names a view for the browser to render. A React tree is consumed by an RSC-capable framework. Either way, the CMS returns it untouched.

      +

      CurrentProduct is a saved entry, { "__resolveType": "catalog-product", "slug": "summer-shirt" }, so looking it up is a data hit followed by a function hit. Once CurrentProduct expands, the stored entry means productCard({ title: "Summer collection", product: catalogProduct({ slug: "summer-shirt" }) }). A descriptor names a view for the browser to render. A React tree is consumed by an RSC-capable framework. Either way, the CMS returns it untouched.

      @@ -1376,22 +1390,23 @@

      Contributing

      Requestclient.list or client.resolve
      load() from memoryNewest fetched release, or the fallback loader's content
      -
      Your blocks runSame revision for the whole request
      +
      Content resolvesSame revision for the whole request

      Background path · on first use, then every ~5 minutes

      Ask for the hashA few bytes from the Deco API
      -
      CompareSame as memory: done. Same as disk: use disk, no download.
      +
      CompareSame as memory: done. Same as the fallback: use it, no download.
      Fetch, verify, swapWhole revision at once. Any error: keep what's there.
      1. Serve from memory. load() returns the newest release this process has fetched, or, if it hasn't fetched one yet, the fallback loader's content. 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 five minutes, update() fetches the current revision hash from the API. That's a few bytes. Each wait is jittered by up to 30 seconds, so a fleet of servers that started together doesn't poll together.
      3. -
      4. Compare. If the hash equals what's in memory, there's nothing to do. If it equals the fallback loader's revision, the disk is current: the loader uses that content and never downloads it. This is the common case right after a deploy.
      5. -
      6. Fetch and swap. Otherwise it fetches that revision, checks that the content hashes to it and that its schema matches the running app, and swaps it in whole. Requests in flight keep the revision they started with.
      7. +
      8. Compare. If the hash equals what's in memory, there's nothing to do. If it equals the fallback loader'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.
      9. +
      10. Fetch and swap. Otherwise it fetches that revision, checks that the content hashes to it, and swaps it in whole (the API has already skipped releases whose schema doesn't match the running app). Requests in flight keep the revision they started with.
      11. Fail quietly. Any network or validation error leaves memory as it was. Nothing reaches a request.
      -

      So production content is eventually consistent, by design, with the polling interval as the bound: a commit is live everywhere within about five minutes, and one process only ever does one tiny request per interval, however many requests it serves. Drafts are the opposite: a preview must show exactly the version Studio asked for, so load(pointer) fetches that version and waits for it, cached by version afterwards. If the draft can't be fetched, it serves published content, never the disk.

      +

      So production content is eventually consistent, by design, with the polling interval as the bound: a commit is live everywhere within about five minutes, and one process only ever does one tiny request per interval, however many requests it serves. Drafts are the opposite: a preview must show exactly the version Studio asked for, so load(pointer) fetches that version and waits for it, cached by version afterwards. If the draft can't be fetched, it serves published content, never the fallback.

      +

      To configure remoteLoader, see Configuring the loader; for what a release is, see Releases and deployment.

      @@ -1428,7 +1443,7 @@

      What Studio reads

      sectionsBlock functions that return JSX or a render descriptorThe section catalog when composing a page matchersBlock functions that return booleanThe rule picker on a variant - loadersEvery other block functionData fields that can point at a function + loadersEvery other block function (data functions, not the content loaders)Data fields that can point at a function pagesContent types that extend RouteThe page list, "new page", the URL field redirectsRedirectThe redirects screen contentEvery other Content typePlain entries: navigation, settings, posts @@ -1446,7 +1461,7 @@

      What Studio reads

      }, "definitions": { "hero": { "type": "object", "properties": { "title": { "type": "string", "title": "Heading" } } } } }
      -

      Three conventions follow from this, and they're the ones the rest of the doc already uses. A key is a module type if it's in the manifest and a saved entry if it isn't, so short names like hero are fine. A variant is { "variants": [{ "rule", "value" }] } with the default first. A redirect is a flat { from, to, permanent } entry.

      +

      Three conventions follow from this, and they're the ones the rest of the doc already uses. A key is a block type if it's in the manifest and a saved entry if it isn't, so short names like hero are fine, as long as no saved entry uses the same name (see Blocks). A variant is { "variants": [{ "rule", "value" }] } with the default first. A redirect is a flat { from, to, permanent } entry.

      Studio compatibility today

      Today's Studio predates this design and hardcodes a few of the things above: it tells modules from saved entries by whether the key has a file extension, and it looks for pages, the default and hidden rules, and variant wrappers by their old module paths (website/pages/Page.tsx, website/matchers/always.ts, website/flags/multivariate.ts, …). Until it reads the manifest instead, the CLI bridges the gap: for every key in a framework-known group it also emits the legacy path as an alias, and content Studio saves under a legacy name resolves through the same alias mechanism your own renames use. Your code uses page and always; the manifest carries both spellings; nothing in the runtime changes.

      @@ -1461,13 +1476,13 @@

      Studio compatibility today

      DecisionWhy The CMS is 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 job. Request state comes from the runtime's own context, where it already lives. - Git is the source of truth. Publishing is committing; the API serves commits as releases.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. - One registry rule. A name is data or code; data merges and re-applies, code resolves inputs then runs; outputs are opaque.Saved entries become presets rather than a second concept, identity functions can't loop, and the only recursion is over data, where a cycle check ends it. + For content edited in Studio, Git is the source of truth. Publishing is committing; the API serves commits as releases. (Any other store is a loader you write.)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. + One registry rule. A name is data or code; data merges and re-applies, code resolves inputs then runs; outputs are opaque.Saved entries are named compositions, not a second concept, identity functions can't loop, and the only recursion is over data, where a cycle check ends it. The CMS at module scope, a client per request, pinned to one revision.Everything a response renders agrees with itself, sections can stream as separate calls, and the next request sees the next revision. Clients are cheap because every cache lives in the loader. - Loaders are one interface: load(pointer?) and update().Production, drafts, and staying current are three uses of one method pair, so localLoader and remoteLoader are interchangeable and a custom loader is a few lines. + Loaders are one interface: load(pointer?), plus an optional update().Production, drafts, and staying current are uses of the same interface, so localLoader and remoteLoader are interchangeable and a custom loader is a few lines. A production request never waits for the network.Serve from memory, poll for a hash in the background, fall back to the content the build shipped with. Eventual consistency for visitors, strict consistency for drafts. The CMS doesn't route. matchRoute 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. - Data-only types live in Content, not the block map. No built-in entries.A map entry means "run this". Pages, posts, and redirects have nothing to run; they need a schema, which is the CLI's job. Nothing ships that a site couldn't write in one line. + Data-only types live in the Content type, not the block map. No built-in entries.A block map entry means "run this". Pages, posts, and redirects have nothing to run; they need a schema, which is the CLI's job. Nothing ships that a site couldn't write in one line. Lazy loading belongs to the framework.What's heavy is the component, and React and Next already load components lazily. The block map stays a plain object the CLI can read. Three matchers ship; the rest is app code.always, never, and date depend on nothing but the clock. A device or cookie matcher is an opinion about the request, and the app owns those. Short type names, aliases for legacy paths.A type name is a column name, not a file path. Existing content keeps working through aliases while new code uses names that survive a refactor. diff --git a/site/src/landing.html b/site/src/landing.html index b340c1d3..abdadc30 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -145,7 +145,7 @@

      Native for AI

      Built for stability

      Your site ships with its content. Nothing stands between it and your visitors.

      -

      Your pages, banners and settings are files in your own repository, and every deploy carries them alongside the code. The content your visitors see is already inside your site, even when you publish through the optional hosted Deco CMS.

      +

      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.

      @@ -170,7 +170,7 @@

      Your site ships with its content.
    23. {{icon:check}}No content servers to runNo content database or content server to host, scale or keep online.
    24. {{icon:check}}Fast on every pageEvery page is built from content your site already holds, so it loads as fast as your own code.
    25. -
    26. {{icon:check}}Stable by designYour site depends on no other service to show its content. Every deploy carries a tested copy of it, so what visitors see is always a version you shipped.
    27. +
    28. {{icon:check}}Stable by designYour 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.
    For developers: how content loads{{icon:arrow-right}} @@ -300,7 +300,7 @@

    Small on purpose. Nothing

    Core concepts

    Content and loaders

    -

    Resolving takes two maps. The block map holds your functions, and it ships with your code. The content map holds your saved entries: each name points at a piece of JSON. A loader supplies the content map, and it's the only part of the runtime that knows where content lives. Your functions, the resolver, and your pages never do. (This isn't the same thing as the data-fetching block functions some Deco apps call loaders, like catalogProduct in Blocks. Those are ordinary functions in your block map.)

    +

    Resolving takes two maps. The block map holds your functions, and it ships with your code. The content map holds your saved entries: each name points at a piece of JSON. A loader supplies the content map, and it's the only part of the CMS that knows where content lives. Your functions, the resolver, and your pages never do.

    +

    Other meanings of "loader". In these docs, a loader on its own always means a content loader: the object that supplies the content map. Two other things share the name. In Studio, and in sites built on Deco's earlier framework, "loader" also means a block function that fetches data, like catalogProduct in Blocks; those are ordinary functions in your block map. And frameworks such as TanStack Router and Remix call the function that fetches a route's data a route loader. Nothing on this page applies to either.

    A loader returns the whole map at once, with a revision that identifies it:

    {
       "revision": "b1946ac92492d2347c6235b4d2611184",
    @@ -235,25 +292,28 @@ 

    The rule in detail

    "SummerSEO": { "__resolveType": "seo", "title": "Sunny!", "description": "Light layers for long days." }, "pages-summer": { "seo": { "__resolveType": "SummerSEO", "title": "Summer sale" }, - "sections": [{ "__resolveType": "Carousel", "banners": [] }] + "sections": [{ "__resolveType": "carousel", "banners": [] }] } } }
    -

    Each key under blocks is an entry name. It's the name a __resolveType refers to, and the name you pass to client.resolve("SummerSEO"). Despite the field name, these are saved entries: any JSON, never functions. Functions only ever come from the block map you give createCMS. The revision names this exact set of entries, so any change to any entry must give a new revision. The built-in loaders use a hash of the whole map. How releases use it is in Releases and deployment.

    +

    Each key under blocks is an entry name. It's the name a __resolveType refers to, and the name you pass to client.resolve("SummerSEO"). The field is called blocks for compatibility with Studio's file format, but it holds saved entries: any JSON, never functions. Functions only ever come from the block map you give createCMS.

    +

    The revision names this exact set of entries. The CMS and your app use it like a version number: to tell when content changed, to keep cached data from different versions apart, and to tell one release from the next. The one hard rule: any change to any entry must produce a new revision, and a revision must never be reused for different content. The built-in loaders, the CLI and the Deco API all use the same content hash of the whole map, so two copies of the same content get the same revision wherever they're computed. A loader you write can use any string that follows the rule; Releases and deployment shows what matching the hash buys you.

    +

    To read an entry exactly as stored, with references in place, index the map: (await localLoader("./.deco").load()).blocks["SummerSEO"].

    The map itself isn't a call: it's what the names in your content refer to. What each entry means is in Blocks.

    Where content comes from

    +

    When you edit with Studio or an agent, content is authored as files: they write to .deco/blocks in your repository, and every change is a commit. The loader only decides where your app reads that content from at runtime. A release is one published version of the whole content map. A draft is content an editor has changed in Studio but not published yet (see Preview). With the hosted Deco CMS, each commit to the production branch becomes a release that reaches your app without a deploy, and the files the build shipped with are the fallback (see Releases and deployment). If you load from your own store, how content gets there is up to you.

    +

    There is no publish step. Content is published by changing it: commit a file in .deco/blocks and push, or save in Studio, which commits for you. With the hosted Deco CMS, that commit becomes the current release without a deploy; without it, the content goes live with your next deploy. See Publishing. (Running deco publish only prints this explanation, so an agent that tries it learns to commit instead.)

    The content map can come from anywhere that can return JSON. These are the usual sources:

    - +
    Content inLoaderUse it for
    Files in your repository (the default)localLoader("./.deco"): one JSON file per entry in .deco/blocks; the file name without .json is the entry name (.deco/blocks/SummerSEO.json → "SummerSEO")Development, tests, and the fallback of remoteLoader in production
    JSON bundled into the buildlocalLoader({ revision, blocks })Cloudflare Workers, which have no filesystem
    The hosted Deco CMSremoteLoader(fallback, { token })Production: publish without a deploy, and preview drafts
    The hosted Deco CMSremoteLoader(fallback, { token })Production: reads the current release from the Deco API without a deploy, and falls back to the loader you pass (usually your files) when it can't. Also serves drafts for preview
    KV, a database, or your own APIA Loader you writeAnything else
    -

    When you edit with Studio or an agent, content is authored as files: they write to .deco/blocks in your repository, and every change is a commit. The loader only decides where your app reads that content from at runtime. With the hosted Deco CMS, each commit becomes a release, a published version of your content that reaches your app without a deploy, and the files the build shipped with are the fallback (see Releases and deployment). If you load from your own store, how content gets there is up to you.

    Configuring the loader

    Most apps never pass a loader: createCMS reads the ./.deco directory by default. Pass an object to keep your content somewhere else, to connect the hosted Deco CMS, or both. These are shortcuts for the loaders in the table above: { root } is localLoader(root), and adding token wraps it as remoteLoader(localLoader(root), { token }).

    @@ -272,10 +332,10 @@

    Configuring the loader

    loader: { root: "./content/.deco", token: process.env.DECO_SITE_TOKEN }, blocks, });
    -

    token is the site's API key for the Deco API. Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads the files only, so the same line works everywhere. For anything else, pass a Loader: localLoader({ revision, blocks }) for content bundled into a Workers build, or your own for any other store.

    +

    token is your site's API key from the hosted Deco CMS, used to call the Deco API. (Issuing tokens for this API isn't available yet; see the Roadmap.) Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads the files only, so the same line works everywhere. For anything else, pass a Loader: localLoader({ revision, blocks }) for content bundled into a Workers build, or your own for any other store.

    Write your own loader

    -

    The contract is one method. A KV-backed loader for a Worker:

    +

    The contract is one required method, load(pointer?), which returns { revision, blocks }. Here is one backed by Cloudflare Workers KV, Workers' key-value store (KVNamespace comes from @cloudflare/workers-types):

    kv-loader.ts
    import type { Loader } from '@decocms/blocks'
     
     export function kvLoader(kv: KVNamespace, key = 'release:current'): Loader {
    @@ -287,7 +347,8 @@ 

    Write your own loader

    }, } }
    -

    Pass it as createCMS's loader, or as the fallback of remoteLoader. Store the revision with the content, and change it whenever any entry changes. If your store has drafts, honor the draft pointer passed to load(pointer). If it can go stale, add update(). The client only ever sees { revision, blocks }.

    +

    Pass it as createCMS's loader, or as the fallback of remoteLoader. Store the revision with the content, and change it whenever any entry changes (see the rule above).

    +

    Two optional extras. If your store has drafts, use the pointer argument, the draft pointer that names which draft to read. If content can change while the process runs, add update(), which checks for newer content (see Loader internals). Either way, the client only ever sees { revision, blocks }.

    @@ -296,10 +357,44 @@

    Write your own loader

    Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. Point it at the file that exports your block map, the blocks.ts from the quickstart:

    npx deco schema --entry ./blocks.ts --out ./.deco/schema.json

    For each block type in the map, the CLI reads the type of the function's first parameter and turns it into a form definition. Commit .deco/schema.json with your code, and run the command in CI so the schema never drifts from your types.

    -

    The CLI also checks your content model: it fails if a saved entry in .deco/blocks has the name of a block type, or if an alias collides with either.

    +

    The CLI also checks your content model: it fails if a saved entry in .deco/blocks has the name of a block type, or if an alias (a second name for a block type, used when you rename one; see Renaming and migrating) collides with either.

    + +

    Annotate fields with JSDoc

    +

    The TypeScript type decides what kind of field each input gets: a string is a text box, a number a number field, a union of string literals a select, an array a list editors can add to and reorder. JSDoc tags on a field refine the form:

    +
    + + + + + + + + + + +
    TagEffect in the editorExample
    @titleThe field's label (the default is the property name)@title Headline
    @descriptionHelp text under the field@description Shown above the fold
    @defaultThe value a new block starts with, parsed as JSON when it can be@default 10
    @minimum / @maximumThe allowed range of a number@maximum 100
    @minLength / @maxLengthThe allowed length of a string@maxLength 60
    @formatA specialized input for a string, such as date, date-time, rich-text, textarea, color or image-uri@format rich-text
    @ignoreLeaves the field out of the form@ignore
    +

    Put each tag on its own line. This type:

    +
    export interface HeroProps {
    +  /**
    +   * @title Headline
    +   * @maxLength 60
    +   */
    +  title: string;
    +  /**
    +   * @title Body
    +   * @format rich-text
    +   */
    +  body?: string;
    +  /**
    +   * @title Ends on
    +   * @format date
    +   */
    +  endsOn?: string;
    +}
    +

    becomes a form with a Headline text box limited to 60 characters, an optional Body rich-text editor, and an optional Ends on date picker.

    Content that is only data

    -

    A page, a blog post, a redirect: these have a shape but no function. They're never resolved themselves: your app reads them (with list or matchRoute) and resolves the blocks inside them. They still need a schema so Studio can create them, so declare them for the CLI in a type-level map next to the block map:

    +

    A page, a blog post, a redirect: these have a shape but no function. They're never resolved themselves: your app reads them, by type with client.list("post") or by URL with matchRoute (see Routing), and resolves the blocks inside them. They still need a schema so Studio can create them, so declare them for the CLI in a type-level map next to the block map. Page and Post are ordinary types you write; Redirect ships with @decocms/blocks.

    blocks.ts
    import type { Blocks, Redirect } from "@decocms/blocks";
     import type { Page } from "./page";
     import type { Post } from "./post";
    @@ -313,18 +408,17 @@ 

    Content that is only data

    post: Post; redirect: Redirect; };
    -

    The CLI reads both exports. Keys of the default export get a schema from each function's parameter type; keys of Content get one from the type itself. The two share one namespace, and a key in both is an error. The runtime never sees Content: list("page") works because the entries exist, and resolving a page means resolving its fields, not the page.

    - -

    Studio reads the schema this command writes, and nothing else about your code. What it looks for, and how the CLI bridges today's Studio conventions, is covered under the hood.

    +

    The CLI reads both exports. Keys of the default export get a schema from each function's parameter type; keys of Content get one from the type itself. The two share one namespace, and a key in both is an error. The CMS never sees Content: list("page") works because the entries exist, and resolving a page means resolving its fields, not the page.

    -

    There is no publish command. Content is published by changing it: commit a file in .deco/blocks and push, or save in Studio, which commits for you. With the hosted Deco CMS, that commit becomes the current release without a deploy; without it, the content goes live with your next deploy. See Publishing. If an agent runs deco publish, the CLI only says this.

    +

    Studio learns the shape of your content only from the schema this command writes. What it looks for, which endpoints it calls on your site, and how the CLI bridges today's Studio conventions are covered under the hood.

    Core concepts

    Preview

    -

    Editors want to see a change before it's published. Because block functions ship with your app and only content changes, previewing a draft needs no deploy: the app renders the same code against different content.

    +

    A draft is content an editor has changed in Studio but not published yet, kept on a branch until someone publishes it. Editors want to see a draft before it's published. Because block functions ship with your app and only content changes, previewing a draft needs no deploy: the app renders the same code against different content.

    Here's the whole mechanism. Studio opens your site with a draft pointer in the URL:

    https://store.example.com/summer?__draft=studio.decocms.com/api/acme/decofile/store/feat-summer%3Ftoken%3D…@9f3c1a
    +

    The part after __draft= is URL-encoded, which is why ? and = show as %3F and %3D. Decoded, studio.decocms.com/api/…/feat-summer?token=… is where the draft lives: the Deco API's address for this site's feat-summer branch, plus a token signed by Studio. Treat the path as opaque. 9f3c1a, after the @, is the draft's version.

    The pointer is a string, <host><path>@<version>: where the draft content lives, and which version. draftPointer(request) reads it, from ?__draft= or from the cookie described below, and the CMS gives you a client over that draft. Everything after that line is the same code that serves visitors:

    A plain request handler
    import { draftPointer } from '@decocms/blocks'
     import { cms } from './cms'
    @@ -333,9 +427,9 @@ 

    Content that is only data

    const pointer = draftPointer(request) // null on an ordinary request const client = pointer ? cms.forDraft(pointer) : cms.forRelease() - return render(client, request) // list, matchRoute and resolve as usual + return render(client, request) // your own function: list, matchRoute and resolve as usual }
    -

    forDraft hands the pointer to the loader. remoteLoader fetches that exact version from Studio's content API, waits for it, and caches it by version, so the second request for the same draft is a memory read. A draft replaces the whole revision, deletions included, so a page in preview looks exactly as it will after publishing. localLoader ignores the pointer: in development you're already looking at the files. A loader you write honors it if your store has drafts (see Write your own loader). That's why the code above never checks which loader it has.

    +

    forDraft hands the pointer to the loader. remoteLoader fetches that exact version from the Deco API, waits for it, and caches it by version, so the second request for the same draft is a memory read. A draft replaces the whole revision, deletions included, so a page in preview looks exactly as it will after publishing. localLoader ignores the pointer: in development you're already looking at the files. A loader you write honors it if your store has drafts (see Write your own loader). That's why the code above never checks which loader it has.

    Keep the draft across navigation

    The pointer is only in the URL of the first page Studio opens. draftCookie(request) gives you the Set-Cookie value that response needs, and null on every other request. Append it, and draftPointer reads the cookie from then on, so every link the editor clicks stays in preview:

    @@ -351,7 +445,7 @@

    Keep the draft across navigation

    if (cookie) response.headers.append('Set-Cookie', cookie) return response }
    -

    Studio ends a preview by opening the site with an empty ?__draft=; draftCookie returns an expiring cookie for that, so the same two lines cover leaving preview. The parameter and cookie names never appear in your code. In Next.js the two steps split across proxy.ts, which appends the cookie, and a server helper that reads it with cookies(). The Next.js guide has both files.

    +

    Studio ends a preview by opening the site with an empty ?__draft=; draftCookie returns an expiring cookie for that, so the same two lines cover leaving preview. The parameter and cookie names never appear in your code. In Next.js the two steps split across proxy.ts (Next.js 16's name for middleware.ts), which appends the cookie, and a server helper that reads it with cookies(). The Next.js guide has both files.

    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 Studio opens, or from a "preview" field on a debug screen, and keeps it in app state:

    @@ -361,12 +455,15 @@

    Not a website

    A script passes it as an argument. To look inside a pointer, or build one from parts, use parseDraftPointer and formatDraftPointer from the reference; forDraft parses on its own, so normally you don't have to.

    Who may preview

    -

    Two checks, and neither needs configuration in your app. remoteLoader uses its token to learn the site's content API from the Deco API, and forDraft ignores any pointer whose authority isn't that API, so a pointer can never turn into a request to an arbitrary origin. The pointer's path carries a signed grant that the content API verifies, so a draft is only served to someone Studio let in.

    +

    Anyone can put ?__draft= in a URL, so the CMS checks two things, and neither needs configuration in your app. First, the pointer must name your site's own Deco API host, which remoteLoader looks up with your token. Any other host is ignored, so a crafted pointer can't make your server fetch from somewhere else. Second, the pointer carries a token signed by Studio, which the Deco API checks before serving the draft.

    If a website wants to show drafts on its preview domain only, it checks the request's host before calling forDraft:

    const host = new URL(request.url).host
     const pointer = host === 'preview.store.example.com' ? draftPointer(request) : null
     const client = pointer ? cms.forDraft(pointer) : cms.forRelease()

    The token is the only secret. The site holds no other credential. Invalid or unexpected pointers are ignored and the request falls back to published content, never to an error page.

    + +

    Connect Studio to your site

    +

    Studio opens previews on your site's URL, so the site has to be set up in the hosted Deco CMS with your token (creating a site and a token for this API is on the Roadmap). Support for sites on this API, including previews inside Studio's frame, is still being built; see the Roadmap. To try the mechanism locally: with only local files, forDraft returns the release, because localLoader ignores the pointer. Testing a real draft needs a token.

    @@ -384,7 +481,7 @@

    Who may preview

    } satisfies Blocks;

    Each key is a block type. Each value is a function (inputs) => value, where inputs is the block without its __resolveType: saved entries already expanded and merged, and nested calls already run, so the function receives values, never a pending call. Use satisfies Blocks rather than a type annotation: it checks the shape while keeping the concrete keys and function types, which is what the schema is generated from.

    Keep the map a plain object at module level. The CLI reads the file without running it, so build it once and give it to createCMS. Only functions go in the map. Types that are pure data, such as pages, posts, and redirects, have nothing to run; they're declared for the CLI instead, see content that is only data.

    -

    The CMS is pure. It passes your function the block and nothing else: no context object, no request, no hidden state. When a block needs the current request, a cookie, or a route parameter, read it the way the rest of your app does, usually through the AsyncLocalStorage your server or framework already provides (next/headers in Next.js, getRequest() in TanStack Start). That keeps block functions ordinary functions you can call and test directly.

    +

    The CMS is pure. It passes your function the block and nothing else: no context object, no request, no hidden state. When a block needs the current request, a cookie, or a route parameter, read it the way the rest of your app does, usually through request-scoped storage your framework already provides (Node's AsyncLocalStorage, which next/headers and TanStack Start's getRequest() use under the hood). That keeps block functions ordinary functions you can call and test directly.

    Lazy loading

    The map above imports every block at the top, so they all load with the map. That's fine for functions like seo. It's not what you want for a section that drags in a charting library, or for the two hundred sections a large store has when any page uses five.

    @@ -404,35 +501,36 @@

    Lazy loading

    product: (input: ProductProps) => <Product {...input} />, hero: (input: HeroProps) => <Hero {...input} />, } satisfies Blocks
    -

    Swap dynamic for React's lazy on TanStack RSC, with the sections rendered under a Suspense boundary. In TanStack's data mode the blocks return descriptors and the browser-side view registry is the map to do this in: const views = { product: lazy(() => import('./sections/Product')) }. In every case the section files know nothing about the CMS, the map stays a plain object the CLI can read, and the bundler splits on the import().

    +

    With TanStack Start, use React's lazy instead of dynamic. Where it goes depends on which of the two rendering modes you use (see Rendering), and the TanStack guides show the code. In every case the section files know nothing about the CMS, the map stays a plain object the CLI can read, and the bundler splits on the import().

    For a block that's heavy for a non-UI reason, say a data function that pulls in a large SDK, a dynamic import inside the function body is enough. It's one line and needs no helper:

    "catalog-product": async (input: ProductQuery) => {
    -  const { fetchProduct } = await import("./commerce")   // loaded on first call, cached by the runtime
    +  const { fetchProduct } = await import("./commerce")   // loaded on first call; the JS module system caches it after that
       return fetchProduct(input)
     },

    Keep import paths literal in both cases. A bundler can split import("./sections/Product") into its own chunk; it can't split import(`./sections/${name}`).

    Choose type names that last

    -

    A block type's name is what content stores in __resolveType to call it, so pick it like a database column, not like a file path: short (seo, hero) or namespaced when a site has many (commerce/product). Moving a file only means updating an import; the name doesn't change. Renaming a type means migrating every block that uses it, or adding an alias. Type names share one namespace with saved entry names (see Blocks), and deco schema fails on a clash, so prefer names an editor wouldn't give an entry.

    +

    A block type's name is what content stores in __resolveType to call it, so pick it like a database column, not like a file path: short (seo, hero) or namespaced when a site has many (commerce/product). Moving a file only means updating an import; the name doesn't change. Renaming a type means migrating every block that uses it, or adding an alias, a second name for the same block type (see Renaming and migrating). Type names share one namespace with saved entry names (see Blocks), and deco schema fails on a clash. A simple convention avoids clashes: lowercase or kebab-case for block types (seo, product-card), capitalized names for entries (SummerSEO).

    Advanced

    Matchers and variants

    -

    Editors often want content that depends on a condition: a banner that only shows during a campaign, a title that changes on a date, an experiment. Two small ideas cover all of it, and neither knows what a website is, so they work the same in a storefront, a mobile app, or a job.

    -

    A matcher is a block function that returns a boolean. A multivariate is a block function that takes a list of { rule, value } pairs, where each rule is a matcher, and returns the first value whose rule matched. Because the CMS resolves inputs before calling a function, the multivariate never sees a matcher, only booleans.

    +

    Editors often want content that depends on a condition: a banner that only shows during a campaign, a title that changes on a date, an experiment. Two small ideas cover all of it, and neither knows what a website is, so they work the same in a website, a mobile app, or a background job (a script or cron task).

    +

    A variant is one of several alternative values for the same input, together with the rule that selects it. A matcher is a block function that returns a boolean, used as a variant's rule. A multivariate (after multivariate testing) is a block function that takes a list of variants, { rule, value } pairs, and returns the first value whose rule is true. It's three lines, so you own it and can change how it picks. Because the CMS resolves inputs before calling a function, the multivariate never sees a matcher, only booleans.

    The three matchers that ship

    They depend on nothing but the clock, so they're the only ones the framework provides:

    import { always, never, date } from "@decocms/blocks/matchers";
     
     // always()                              => true    the default rule of a variant
    -// never()                               => false   how Studio hides a section without deleting it
    +// never()                               => false   Studio hides a section by wrapping it in a variant whose rule is never()
     // date({ start?: string; end?: string }) => boolean, true when now is inside [start, end]
    +

    start and end are ISO 8601 strings. A date without a time, like "2026-11-30", means midnight UTC, so add a time and offset ("2026-11-30T23:59:59-03:00") to end on your own clock.

    Everything else that could gate content, a device, a cookie, a country, a user's plan, depends on where your app runs and how it sees its environment. Those are yours to write, below.

    The multivariate is three lines you own

    multivariate.ts
    export function multivariate<T>({ variants }: { variants: { rule: boolean; value: T }[] }) {
    -  return variants.find((v) => v.rule)?.value;   // first match wins; put the always() variant first as the default
    +  return variants.find((v) => v.rule)?.value;   // first match wins, so put the always() variant last, as the fallback
     }
    blocks.ts
    import type { Blocks } from "@decocms/blocks";
     import { always, never, date } from "@decocms/blocks/matchers";
    @@ -440,7 +538,7 @@ 

    The multivariate is three lines you own

    import hero from "./hero"; export default { always, never, date, multivariate, hero } satisfies Blocks;
    -

    Editors compose them in Studio: a variant is a tab, its rule is picked from the matchers in your map, and the first tab is the default. A hero whose title changes for a campaign is a hero whose title is a multivariate:

    +

    Editors compose them in Studio. Each variant is a tab, and its rule is picked from the matchers in your map. Variants are checked in order, so the one with the always rule goes last and catches everything else. A hero whose title changes for a campaign is a hero whose title is a multivariate:

    Saved as HomeHero
    {
       "__resolveType": "hero",
       "title": {
    @@ -473,11 +571,11 @@ 

    Write your own matcher

    }
    matchers.ts (app)
    // A mobile app: the user's plan from app state. No request, no headers.
     export const plan = ({ plan }: { plan: "free" | "pro" }) => session.current().plan === plan;
    -

    Add them to the map and Studio's rule picker shows them beside date: it lists every function in the matchers group of the schema's manifest, which the CLI fills with every block function that returns a boolean. Combinators are matchers too: and, or, and not are one-liners over booleans, if you want them.

    +

    Add them to the map and Studio's rule picker shows them next to date. The CLI lists every block function that returns a boolean as a matcher, which is how Studio knows to offer it in the rule picker (see what Studio reads). Combinators are matchers too: and, or, and not are one-liners over booleans, if you want them.

    Every variant resolves

    Inputs resolve before a function runs, so every variant's value resolves before multivariate picks one. For a title that's free. For two product queries it isn't, and a failure in a variant you didn't pick still fails the whole thing.

    -

    The lazy form is to make the values entry names instead of inline blocks. A string isn't a block, so nothing runs; the multivariate returns a name, and whoever holds the client resolves it:

    +

    The lazy form is to make the values entry names instead of inline blocks. A string isn't a block, so nothing runs; the multivariate returns a name, and your app resolves it:

    {
       "__resolveType": "multivariate",
       "variants": [
    @@ -493,16 +591,19 @@ 

    Every variant resolves

    ], }) // the values are entry names, not calls: only the rules and multivariate run -// With the client, pass the stored JSON above as section -const [name] = await client.resolve(section); // "BlackFridayHero" -const [hero] = await client.resolve(name); // only this variant ran
    +// section is the JSON above, e.g. from a page's sections array; +// client.resolve accepts an entry name or an inline block +const [name, err] = await client.resolve(section); // "BlackFridayHero" +if (err) throw err; +const [hero] = await client.resolve(name); // only this variant ran

    A variant is not access control. It chooses between values that are all fine to compute and show. 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.

    -

    Anything a matcher looks at is a cache dimension. If a page varies by device, the device belongs in the cache key.

    +

    If you cache rendered pages, at a CDN for example, anything a matcher looks at must be part of the cache key. Otherwise the first visitor's variant is cached and served to everyone: a page that varies by device would show the cached desktop hero to mobile visitors. See Caching and observability.

    +

    Nothing so far has assumed a website. The next part adds URLs.

    Websites

    Routes, redirects, and content types

    -

    The framework ships two content types. Everything that has a URL is built from them.

    +

    A content type is a kind of saved entry that holds data and runs no function, such as a page, a blog post or a redirect. Its name is the value of __resolveType, and you declare it in Content (see Content that is only data). The framework ships two building blocks for anything with a URL: the Route interface, which any content type can extend to get a URL, and a ready-made redirect content type.

    /** An entry that lives at a URL. Extend it to make a type routable. */
     interface Route {
       /** @title Name */
    @@ -517,11 +618,12 @@ 

    Every variant resolves

    to: string; permanent: boolean; // 301 or 302 }
    -

    A page is a route with SEO and sections. It's not special to the router; it's just the first type that extends Route. It's shaped like the pages-summer entry in Blocks, with three additions: "__resolveType": "page", so the CMS can list it by type, and a name and path, so the router can find it. Like every data-only type, it's declared in Content, not in the block map:

    +

    A page is a route with SEO and a list of sections. It's not special to the router; it's just the first type that extends Route. It's shaped like the pages-summer entry in Blocks, with three additions: "__resolveType": "page", so the CMS can list it by type, and a name and path, so the router can find it. Like every data-only type, it's declared in Content, not in the block map:

    interface Page extends Route {
    -  seo: PageSeo;
    -  sections: VariantOf<Array<VariantOf<Block>>>;
    +  seo: PageSeo;                                     // an SEO block, or a reference to a saved one
    +  sections: VariantOf<Array<VariantOf<Block>>>;   // a list of section blocks; the whole list, or any one section, can be a variant
     }
    +

    PageSeo is your SEO input type. Block is a block as stored: JSON whose __resolveType names a function or a saved entry. VariantOf<T> accepts a T or a multivariate block that picks one.

    Saved as SummerPage
    {
       "__resolveType": "page",
       "name": "Summer campaign",
    @@ -531,7 +633,7 @@ 

    Every variant resolves

    { "__resolveType": "hero", "title": "Summer starts here", "image": "https://cdn.example.com/summer.jpg" } ] }
    -
    // Means: page names no function (it's a Content type), so resolve the fields, not the page
    +
    // What its fields mean. page isn't a function, so the object stays data and you resolve each field separately.
     {
       __resolveType: "page",
       name: "Summer campaign",
    @@ -545,11 +647,12 @@ 

    Every variant resolves

    "to": "/summer", "permanent": true }
    -

    matchRoute's rule is one line: an entry with a string path matches at that path. Route is what makes that intentional. The CLI checks that any type declaring path extends Route, so a stray path field on an unrelated block is a schema error rather than a surprise URL. Editors see name and path as ordinary fields on every routable thing, in Studio, which is how a marketing team gets /summer-sale live without a developer. Keep path and from plain strings, not blocks, so the router can match without running code.

    +

    Two SDK functions turn these entries into URLs. client.list("page") returns every saved entry whose __resolveType is page. matchRoute(url, { routes, redirects }) picks the entry that matches a URL. Routing puts them together.

    +

    The router's rule is one line: an entry with a string path matches at that path. Route is what makes that intentional. The CLI checks that any type declaring path extends Route, so a stray path field on an unrelated block is a schema error rather than a surprise URL. Editors see name and path as ordinary fields on every routable thing, in Studio, which is how a marketing team gets /summer-sale live without a developer. Keep path and from plain strings, not blocks, so the router can match without running code.

    Your own routable types

    -

    A blog doesn't want a page entry per post. Extend Route and every saved post routes on its own:

    -
    post.ts
    import type { Route } from "@decocms/blocks";
    +  

    A blog doesn't want a page entry per post. Make Post extend Route, declare it in Content as post: Post, and pass the saved posts to matchRoute along with your pages (see Routing). Each post is then served at its own path:

    +
    src/post.ts
    import type { Route } from "@decocms/blocks";
     
     export interface Post extends Route {
       /** @title Published on @format date */
    @@ -558,69 +661,57 @@ 

    Your own routable types

    body: string; }
    Saved as HelloWorld
    { "__resolveType": "post", "name": "Hello, world", "path": "/blog/hello-world", "date": "2026-09-01", "body": "…" }
    -

    Studio pre-fills path from a per-type template such as /blog/{slug}, so editors get consistent URLs by default and can still change one. Two entries with the same path, or two templates that can match the same URL, are rejected by the CLI and by matchRoute.

    +

    Two entries with the same path, or two templates that can match the same URL, are rejected by the CLI and by matchRoute (see Path templates and match order).

    Pages are data; resolve their fields

    -

    A page has no function, so you don't resolve the page. You resolve the blocks it holds: client.resolve(page.seo), and one client.resolve(section) per section, so sections can stream as they finish (see the framework guides). A redirect is never resolved at all; matchRoute reads it. If you do want code to run for a whole page, such as validation or a layout step, add a page function to the block map and move Page out of Content; then client.resolve(page) runs it after every section has resolved. Until you do, resolving the page itself, client.resolve("SummerPage") or client.list("page", { run: true }), fails with UNKNOWN_BLOCK: page names no function, and the runtime never sees Content (see the rule). To read a page, list it, or pass { run: false }.

    +

    A page runs no function, so you never resolve the page itself. Read it, then resolve the blocks inside it: client.resolve(page.seo), and one client.resolve(section) per section. Separate calls let each section render as soon as its own data is ready (see Rendering). A redirect is never resolved; matchRoute only reads it.

    +

    client.resolve("SummerPage") and client.list("page", { run: true }) fail with UNKNOWN_BLOCK, because page names no function, and the CMS never sees Content (see the rule). To read a page, list it, or pass { run: false }.

    +

    Running code for a whole page. If you want code to run for a whole page, such as validation or a layout step, add a page function to the block map and move Page out of Content. Then client.resolve(page) runs it after every section has resolved.

    Content types without a URL

    -

    Navigation, email templates, and campaign settings work the same way, minus Route: declare the type in Content if it's data, or register a function if something should run, and save entries of that type. Block is a block as stored: JSON whose __resolveType names a function or a saved entry. VariantOf<T> accepts a T or a multivariate block that picks one.

    +

    Navigation, email templates, and campaign settings work the same way, minus Route: declare the type in Content if it's data, or register a function if something should run, and save entries of that type.

    -
    -

    Websites

    Routing

    -

    The CMS doesn't route. It lists and resolves. Routing is three steps in your request handler: list the types you want at URLs, hand them to matchRoute, resolve the winner. Because the client is made per request, all three see the same revision.

    -
    import { matchRoute, type Redirect } from "@decocms/blocks";
    -
    -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":
    -    switch (match.entry.__resolveType) {
    -      case "page": {
    -        const sections = match.entry.sections.map((s) => client.resolve(s));   // start them all
    -        const [seo] = await client.resolve(match.entry.seo);                   // then await
    -        return render(seo, sections);
    -      }
    -      case "post":
    -        return renderPost(match.entry);                                        // data: nothing to resolve
    -    }
    -}
    +
    +

    Websites

    Rendering

    +

    A block function can return anything. For a website it usually returns something that becomes UI, and there are two ways to get that to the browser.

    +
      +
    • Descriptors (data mode): the block returns plain JSON such as { component: "product", props: {…} }, and a view registry in your app (an object mapping 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.
    • +
    +

    The CMS doesn't care which you pick; it returns whatever your block function returns. Each integration uses one of the two:

    - + - - - + + +
    kindFields
    IntegrationBlock returnsStreamingShips to the browser
    "match"entry (the stored route you passed in) and params from the path template.
    "redirect"location with parameters filled in, and status (301 or 302).
    "not-found"None
    Next.js App RouterJSXServer Component Suspense boundariesClient Components
    TanStack Start, dataSerializable descriptorsDeferred promises with AwaitThe view registry and its components
    TanStack Start, RSCJSXRSC stream (Flight)Client references and the RSC runtime
    -

    matchRoute is a helper, not a requirement. The CMS has no idea what a URL is. If your framework already routes, or you'd rather read a page by name (client.resolve("SummerPage", { run: false }), then resolve its fields) or find it by a field of your own, skip it. It exists because "an editor typed a path into Studio and that page is now live" is the common case, and getting precedence and parameters right by hand is tedious.

    -

    Which types route is your decision, per handler: a blog lists post, a docs site lists doc. match.params holds the URL parameters; hand them to your code however your app shares request state. The CMS never injects them into blocks.

    -

    Matching is a lookup, not a scan: entries are compiled into a segment trie once per list, so a match costs the URL's depth, not the catalogue's size. The algorithm is described under the hood.

    - -

    Path templates and match order

    -

    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". Redirects are checked before routes, so a redirect overrides a route at the same path. Within each group, exact paths win over templates. Two entries with the same path, or two templates that can match the same URL, make matchRoute throw, and the CLI reports them at schema time. Serve framework endpoints, API routes, and static assets before the CMS catch-all; those paths don't belong to the CMS.

    +

    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.

    -

    Linking and listing

    -

    Because the URL is a field, linking to an entry is reading it, and listing a routable type gives you an index for free:

    -
    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"
    +  

    Block functions run on the server

    +

    In all three integrations, 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 directly. On the server, importing it gives you a placeholder React can render but your code can't call, and the CMS calls every block function. Wrap it instead: cart: (input: CartProps) => <CartButton {...input} />.

    -const match = matchRoute("/blog/hello-world", { routes: posts }); -// { kind: "match", entry: { __resolveType: "post", name: "Hello, world", path: "/blog/hello-world", … }, params: {} }
    +

    Errors, streaming, and cancellation

    +
      +
    • Start every section first. Await critical data, such as SEO, only after all sections have started, so sections fetch in parallel instead of waiting behind the SEO call.
    • +
    • One Suspense boundary per section lets each section appear as soon as it's ready.
    • +
    • Suspense handles loading, not errors. Each client.resolve(section) returns [value, error]; render a fallback when error is set. Errors thrown later while React renders need an error boundary.
    • +
    • Cancellation is your app's job. The 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 result) before you stream.
    • +
    +
    -

    Example project

    -

    The framework guides share these files: the model and two content entries, stored as files in .deco/blocks, where the default loader reads them (see Content and loaders). Each guide's block map (src/blocks.tsx or src/blocks.ts) declares seo, its own product, and the Content type.

    +
    +

    Websites

    Routing

    +

    The CMS doesn't route. It lists and resolves. Routing is three steps in your request handler: list the types you want at URLs, hand them to matchRoute, resolve the winner. Do all three with one client, so they see the same content even if editors publish mid-request (see One revision per response).

    +

    The example used here and in the guides

    +

    This page and the framework guides share these files: the model in src/model.ts, the post type in src/post.ts (from Your own routable types), and two content entries, stored as files in .deco/blocks, where the default loader reads them (see Content and loaders). Each guide's block map (src/blocks.tsx or src/blocks.ts) declares seo, its own product, and Content, the map of data-only types from Schema generation.

    +

    In src/model.ts, SectionData is the descriptor a product block returns in data mode (see Rendering), and StoredPage is a page exactly as saved, before any of its blocks run.

    src/model.ts
    import type { Block, Route } from '@decocms/blocks'
     
     export interface SEO { title: string; description: string }
    @@ -628,10 +719,12 @@ 

    Example project

    export interface SectionData { component: 'product'; props: ProductProps } // A saved page, as client.list returns it by default (nothing run). +// This example keeps sections a plain array: no variant around the whole list. export interface StoredPage extends Route { seo: SEO | Block sections: Block[] }
    +

    StoredPage is simpler than the Page type in Routes and redirects: it doesn't allow a variant around the whole sections list. If yours does, resolve the list first, const [list] = await client.resolve(page.sections), which picks the variant and also runs every section in it, or store the variant's values as entry names as in Every variant resolves.

    .deco/blocks/ProductPage.json
    {
       "__resolveType": "page",
       "name": "Product page",
    @@ -646,7 +739,7 @@ 

    Example project

    { "__resolveType": "product", "id": "related", "name": "Related shirt", "price": 39, "currency": "USD" } ] }
    -
    // Means: page names no function (it's a Content type), so resolve the fields, not the page
    +
    // Means (page isn't a function, so the object stays data; the blocks inside it are calls)
     {
       __resolveType: "page",
       name: "Product page",
    @@ -665,47 +758,74 @@ 

    Example project

    "permanent": true }

    The redirect sends /old-products/summer to /summer/p. The page uses literal product values to keep the example small. A real page would use a block that fetches the product for the slug, reading match.params from your app's request state (see Block maps).

    -
    -
    -

    Websites

    Rendering

    -

    The CMS returns whatever your block function returns. Your framework decides how that value reaches the browser:

    +

    Route a request

    +

    With those files in place, this is the whole request handler:

    +
    import { matchRoute, type Redirect } from "@decocms/blocks";
    +// 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, and catch matchRoute's throw (see below)
    +
    +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":
    +    switch (match.entry.__resolveType) {
    +      case "page": {
    +        const sections = match.entry.sections.map((s) => client.resolve(s));   // start them all
    +        const [seo] = await client.resolve(match.entry.seo);                   // then await
    +        return render(seo, sections);
    +      }
    +      case "post":
    +        return renderPost(match.entry);                                        // data: nothing to resolve
    +    }
    +}
    +

    Mount this handler as your app's catch-all route, the one that runs when no other route matches.

    - + - - - + + +
    IntegrationBlock returnsStreamingShips to the browser
    kindFields
    Next.js App RouterJSXServer Component Suspense boundariesClient Components
    TanStack Start, dataSerializable descriptorsDeferred promises with AwaitThe view registry and its components
    TanStack Start, RSCJSXReact FlightClient references and the RSC runtime
    "match"entry (the stored route you passed in) and params from the path template.
    "redirect"location with parameters filled in, and status (301 or 302).
    "not-found"None
    +

    matchRoute is a helper, not a requirement. The CMS has no idea what a URL is. If your framework already routes, or you'd rather read a page by name (client.resolve("SummerPage", { run: false }), then resolve its fields) or find it by a field of your own, skip it. It exists because "an editor typed a path into Studio and that page is now live" is the common case, and getting precedence and parameters right by hand is tedious.

    +

    Which types route is your decision, per handler: a blog lists post, a docs site lists doc. match.params holds the URL parameters; hand them to your code however your app shares request state. The CMS never injects them into blocks.

    +

    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. The Block maps page covers request-scoped state in more detail.

    -

    Block functions run on the server

    -

    In all three integrations, 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.

    -

    Render interactive components as elements, such as <CartButton />. Don't register a Client Component as a block function: the "use client" boundary preserves a reference, not a callable function.

    +

    Matching is a lookup, not a scan: entries are compiled into a segment trie (a tree keyed by path segment), so a lookup costs the URL's depth, not the number of routes. The trie is cached per routes array object, so passing the same array again skips the build. The handler above builds a new array per request, which rebuilds the trie each time; that's one pass over the paths, fine for most sites. Reusing one array per revision needs the client to expose its revision, which is on the Roadmap. The algorithm is described under the hood.

    -

    Errors, streaming, and cancellation

    -
      -
    • Start every section first. Await critical data, such as SEO, only after all sections have started.
    • -
    • One Suspense boundary per section lets each section appear as soon as it's ready.
    • -
    • Suspense handles loading, not errors. Check each section's error and render a fallback. Errors thrown later while React renders need an error boundary.
    • -
    • Cancellation is your app's job. The 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. A later error can't change it.
    • -
    +

    Path templates and match order

    +

    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". Redirects are checked before routes, so a redirect overrides a route at the same path. Within each group, exact paths win over templates. Two entries with the same path, or two templates that can match the same URL, make matchRoute throw. deco schema reports the same conflict, so run it in CI on every commit, including the content commits Studio makes. Content published without that check can still make matchRoute throw at request time, so catch the error and return a 500 rather than crash (removing the throw is on the Roadmap). Serve framework endpoints, API routes and static assets from their own routes, so they never reach this catch-all.

    -

    Choose a transport

    -

    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.

    +

    Linking and listing

    +

    Because the URL is a field, linking to an entry is reading it, and listing a routable type gives you an index for free:

    +
    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>
    +
    +const match = matchRoute("/blog/hello-world", { routes: posts });
    +// { kind: "match", entry: { __resolveType: "post", name: "Hello, world", path: "/blog/hello-world", … }, params: {} }

    Reference

    API reference

    -

    Everything is exported from @decocms/blocks.

    +

    Everything on this page is exported from @decocms/blocks. The built-in matchers (always, never, date) are in @decocms/blocks/matchers.

    createCMS(config)

    -

    Two levels. A CMS is a loader plus a block map, created once at module scope. It hands out clients, one per request, for the release or for a draft, and keeps content current. A client resolves and lists, and that's all. Both are cheap: content caches in the loader, and dynamically imported modules are cached by the runtime.

    +

    createCMS returns a CMS: your block map plus a loader. Create it once, at module scope. For each request, ask it for a client: forRelease() for visitors, forDraft(pointer) for a preview. A client loads one revision on first use and reads only that revision afterwards, so everything in one response agrees. Every later call, list or resolve, reuses that content, so sections can stream as separate calls; the next request's client sees the next revision, so don't share a client across requests. A client also remembers what it has already resolved: an entry referenced three times runs its function once per client. That memory is tied to the client and to the block map object (results are kept per function of the map you passed), so a map rebuilt inside your handler starts with an empty cache; build the map once at module scope. Clients are cheap to create, because the loader caches the content.

    function createCMS(config: {
       loader?:                               // optional: defaults to "./.deco"
         | string                             // a content directory: localLoader(path)
    -    | { root?: string; token?: string }  // local content in root (default "./.deco"); with a token, remote releases over it
    +    | { root?: string; token?: string }  // files in root (default "./.deco"); add a token to read hosted releases, with those files as the fallback
         | Loader;                            // anything else: bundled content, KV, your own
       blocks: Blocks;
     }): CMS;
    @@ -720,7 +840,7 @@ 

    createCMS(config)

    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[]>>; }
    -

    The loader option takes a directory path, { root?, token? }, or a Loader; leave it out to read ./.deco. What each shape does is in Configuring the loader. Create the CMS once, at module scope, and ask it for a client per request: forRelease() for visitors, forDraft(pointer) when the request carries one. Clients are free to make and hold nothing you'd want to share. Everything either does is a few lines over a loader, and the rule from Blocks is how it resolves; those internals aren't part of the public API.

    +

    The loader option takes a directory path, { root?, token? }, or a Loader; leave it out to read ./.deco. What each shape does is in Configuring the loader. The rule from Blocks is how a client resolves.

    Loaders

    One interface. load() with no argument is production; load(pointer) is a draft. update() checks for newer content. Which one to use, and how to write your own, is in Content and loaders.

    @@ -735,13 +855,14 @@

    Loaders

    remoteLoader(fallback, { token })The current release from the Deco API over a fallback loader, or the draft a pointer names. The token identifies the site; everything else comes from the Deco API.Production -

    remoteLoader(localLoader("./.deco"), { token }): load() never waits on the network. It returns what's in memory, the newest release fetched or else the fallback loader's content, and update() checks for newer content in the background. See how it stays current. load(pointer) fetches the draft a pointer names and caches it by version; drafts never fall back. See Preview.

    -

    update() is rate-limited to the polling interval, never throws, and resolves at once if a check ran recently. On Node, remoteLoader calls it on its own timer. Cloudflare Workers have no timers between requests, so call it yourself, off the request path, with ctx.waitUntil(cms.update()) or from a Cron Trigger. A webhook or an admin "refresh now" button calls the same method. A loader without update() is always current by construction, like the filesystem.

    +

    remoteLoader(localLoader("./.deco"), { token }): load() never waits on the network. It returns what's in memory, the newest release fetched or else the fallback loader's content, and update() checks for newer content in the background. See how it stays current. load(pointer) fetches the draft a pointer names and caches it by version. If the draft can't be fetched, the client serves what a normal request would get, never a mix of draft and published entries. See Preview.

    +

    update() is rate-limited to the polling interval, every five minutes, never throws, and resolves at once if a check ran recently. On Node, remoteLoader calls it on its own timer. Cloudflare Workers have no timers between requests, so call it yourself, off the request path, with ctx.waitUntil(cms.update()) (ctx is the Worker's execution context; waitUntil lets work finish after the response is sent) or from a Cron Trigger (a Worker run on a schedule). A webhook or an admin "refresh now" button calls the same method. A loader without update() is always current by construction, like the filesystem.

    +

    Caching, ETags, and fallbacks live inside the loader, which may be shared by any number of clients. The client only ever sees { revision, blocks }.

    Draft pointers

    A pointer is a string, <host[:port]><path[?query]>@<version>, that names where a draft lives and which version it is. Studio makes them; forDraft reads them. Two helpers cover everything in between:

    interface DraftPointer {
    -  host: string;      // content-API authority, e.g. "studio.decocms.com"
    -  path: string;      // rooted path, with the signed grant in its query
    +  host: string;      // host[:port] of the Deco API, e.g. "studio.decocms.com"
    +  path: string;      // starts with "/"; its query holds a token signed by Studio
       version: string;   // opaque, immutable: the branch head or ETag
     }
     
    @@ -761,9 +882,9 @@ 

    Draft pointers

    const pointer = parseDraftPointer(cookie);
     if (pointer) console.log(`previewing ${pointer.version} from ${pointer.host}`);
     
    +// the path is opaque: copy it, don't build it
     formatDraftPointer({ host: "studio.decocms.com", path: "/api/acme/decofile/store/main?token=…", version: "9f3c1a" });
     // "studio.decocms.com/api/acme/decofile/store/main?token=…@9f3c1a"
    -

    Caching, ETags, and fallbacks live inside the loader, which may be shared by any number of clients. The client only ever sees { revision, blocks }.

    client.resolve(target, options?)

    Loads the content once and applies the registry rule to the target: saved entries expand, and each function the target names in your block map runs with its resolved inputs. With { run: false } it reads without running: entries expand, but no function runs.

    @@ -777,11 +898,10 @@

    client.resolve(target, options?)

    resolve({ title: "Store" })The same object: it contains no blocks -

    A string target is always an entry 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; it isn't validated at runtime.

    -

    A client reads one revision for its lifetime. Its first call loads; every later call, list or resolve, reuses that content and shares a cache. That's why a client is made per request: everything a response renders agrees with itself, sections can stream as separate calls, and the next request sees the next revision. Two clients can see different revisions, so don't share one across requests.

    +

    A string target is always an entry 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 an entry name can't carry a static type; it isn't validated at runtime.

    client.list(type, options?)

    -

    Every saved entry whose __resolveType is type or an alias of it. 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 must name a function in your block map, or it fails with UNKNOWN_BLOCK.

    +

    Every saved entry whose __resolveType is type or an alias 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 must name a function in your block map, or it fails with UNKNOWN_BLOCK.

    interface ListOptions<T> {
       where?: (entry: T) => boolean;
       sort?: (a: T, b: T) => number;
    @@ -789,6 +909,7 @@ 

    client.list(type, options?)

    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), @@ -807,10 +928,11 @@

    matchRoute(url, items)

    | { kind: "match"; entry: T; params: Record<string, string> } | { kind: "redirect"; location: string; status: 301 | 302 } | { kind: "not-found" };
    -

    Redirects win over routes, exact paths win over templates, and two entries that can match the same URL throw. Entries are compiled into a segment trie, cached per items array, so a lookup costs the URL's depth, not the catalogue's size; see how it matches.

    +

    Redirects win over routes, exact paths win over templates, and two entries that can match the same URL throw. 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.

    Types

    type Block = { __resolveType: string; [input: string]: unknown };
    +type VariantOf<T> = T | Block;   // T, or a block (usually a multivariate) that resolves to one
     
     // A block map: what blocks.ts exports and the CLI reads.
     type BlockFunction = (inputs: any) => unknown | Promise<unknown>;
    @@ -831,10 +953,17 @@ 

    Types

    | { kind: "redirect"; location: string; status: 301 | 302 } | { kind: "not-found" }; -type Result<T> = [T, null] | [null, CMSError];
    +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. Every call returns [value, null] or [null, error]. Check the error, not the value, because a block function can legitimately return null.

    +

    Client calls never throw: resolve and list return [value, null] or [null, error]. The one exception in the SDK is matchRoute, which throws when two entries can match the same URL. That's a content bug deco schema also reports, so run it in CI on every commit, including Studio's, and catch the throw in your handler (see Path templates and match order). Check the error, not the value, because a block function can legitimately return null.

    @@ -851,9 +980,11 @@

    Errors

    Framework guides

    Next.js App Router

    In this guide, you'll render CMS pages with Server Components, stream each section under its own Suspense boundary, and generate metadata from the page's SEO block.

    -

    Prerequisites: an App Router app on the Node runtime with a root layout, plus the example project files under src.

    +

    Each of a page's sections resolves on its own, so a slow section never holds up the rest. Editors also choose page URLs in Studio, so your app can't know its routes ahead of time. Instead, one catch-all route receives every URL, asks matchRoute which page or redirect owns it, and renders that page's sections.

    +

    Prerequisites: an App Router app on the Node runtime (the default loader reads .deco from disk, which the Edge runtime can't), with a root layout; npm install @decocms/blocks server-only and the CLI from Quickstart; and the example project: src/model.ts plus the entries in .deco/blocks at the project root.

    -

    1. Add the views

    +

    1. Add the components

    +

    Product is the component a product section renders. CartButton is a Client Component with state, there to show that interactive components keep working inside sections the CMS renders on the server. Neither knows about the CMS.

    src/CartButton.tsx
    'use client'
     import { useState } from 'react'
     
    @@ -893,6 +1024,7 @@ 

    2. Create the CMS

    } satisfies Blocks export type Content = { page: StoredPage; redirect: Redirect }
    +

    seo just returns its input. Registering it lets editors save SEO as a reusable entry with its own form. Content declares the data-only types (pages and redirects) for the schema CLI.

    src/cms.ts
    import { createCMS } from '@decocms/blocks'
     import blocks from './blocks'
     
    @@ -906,12 +1038,18 @@ 

    2. Create the CMS

    import { cms } from './cms' // The client for this request: a draft when the cookie holds a valid pointer, production otherwise. -// Without a token, in development, a draft client reads the same files as the release: localLoader ignores the pointer, and only remoteLoader fetches drafts. +// In development (no token), drafts and releases read the same files. export async function client() { const pointer = (await cookies()).get(DRAFT_COOKIE)?.value return pointer ? cms.forDraft(pointer) : cms.forRelease() }
    -

    Generate the schema from the map file: npx deco schema --entry ./src/blocks.tsx --out ./.deco/schema.json. For preview, let proxy.ts store the draft pointer in a cookie on the request Studio opens, so navigation keeps it:

    +

    import 'server-only' makes the build fail if a Client Component imports this file, which keeps the token and the CMS out of the browser.

    + +

    3. Generate the schema

    +

    Run npx deco schema --entry ./src/blocks.tsx --out ./.deco/schema.json so Studio has a form for each block type (see Schema generation).

    + +

    4. Keep preview working across pages

    +

    Studio opens your site with the draft pointer in the URL. proxy.ts (called middleware.ts before Next.js 16) runs before every request and turns that pointer into a cookie, so the editor stays in preview while clicking around. client() reads the cookie.

    src/proxy.ts
    import { NextResponse, type NextRequest } from 'next/server'
     import { draftCookie } from '@decocms/blocks'
    @@ -923,7 +1061,8 @@ 

    2. Create the CMS

    return response }
    -

    3. Open the page once per request

    +

    5. Match the URL to a page

    +

    openPage lists pages and redirects, matches the pathname, and starts resolving every section without waiting for any of them. Listing is cheap: the loader holds the content in memory. Section keys include the pathname, so React mounts fresh Suspense boundaries when you navigate.

    generateMetadata and the page component both need the page. Wrap the lookup in React's cache so they share one client, one match, and the same section promises.

    src/open-page.server.ts
    import 'server-only'
     import { cache, type ReactNode } from 'react'
    @@ -959,7 +1098,8 @@ 

    3. Open the page once per request

    export const pathnameFor = (segments: string[] = []) => '/' + segments.map(encodeURIComponent).join('/')
    -

    4. Render the catch-all route

    +

    6. Render the catch-all route

    +

    Each section is a promise of a Result, [value, error]. Section awaits it inside its own Suspense boundary and shows a fallback if that one section failed.

    src/app/[[...path]]/page.tsx
    import { Suspense, type ReactNode } from 'react'
     import type { Metadata } from 'next'
     import type { Result } from '@decocms/blocks'
    @@ -996,11 +1136,30 @@ 

    4. Render the catch-all route

    ) }
    -

    5. List another content type

    +

    7. List another content type

    +

    Add src/post.ts from Your own routable types, add post: Post to Content in src/blocks.tsx, and save an entry such as .deco/blocks/HelloWorld.json. 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, since a post is data with nothing to resolve:

    +
    src/open-page.server.ts (changes)
    import type { Result } from '@decocms/blocks'
    +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 (match.entry.__resolveType === 'post') {
    +    const post = match.entry as Post
    +    const seo: Result<SEO> = [{ title: post.name, description: '' }, null]
    +    return { post, sections: [], seo: Promise.resolve(seo) }
    +  }
    +  const page = match.entry as StoredPage
    +  return { post: null, sections: …, seo: … }   // sections and seo exactly as before
    +

    In Page, render the post when page.post is set (for example <article><h1>{page.post.name}</h1>…</article>), and the sections otherwise. generateMetadata keeps working unchanged.

    A blog index needs no page entry. List the post type; each entry carries its own path:

    src/app/blog/page.tsx
    import Link from 'next/link'
     import { client } from '../../client.server'
    -import type { Post } from '../../../post'
    +import type { Post } from '../../post'
     
     export default async function BlogIndex() {
       const c = await client()
    @@ -1019,15 +1178,16 @@ 

    How it works

  • Preview is a different client. client() is pointer ? cms.forDraft(pointer) : cms.forRelease(), and the pointer only exists while the cookie does. Nothing else in the guide knows about preview.
  • 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.
  • Metadata waits only for SEO. Next can stream metadata separately. A metadata error after streaming starts can't change headers that were already sent.
  • -
  • Redirect status codes. Next's navigation helpers send 308 and 307. For an exact 301 or 302, match redirects in proxy.ts with matchRoute and return NextResponse.redirect(url, match.status). Proxy runs separately and doesn't share the page's revision.
  • +
  • Redirect status codes. Next's navigation helpers send 308 and 307. For an exact 301 or 302, match redirects in proxy.ts with matchRoute and return NextResponse.redirect(url, match.status). Proxy makes its own client, so if a new release lands between the two, the redirect check and the page can briefly see different versions of the content.
  • -

    These files use the default App Router configuration. Cache Components adds caching and Suspense requirements of its own. See the Next.js docs on metadata and proxy.

    +

    These files use the default App Router configuration. Cache Components adds caching and Suspense requirements of its own. See the Next.js docs on metadata and proxy.

    -
    +

    Framework guides

    TanStack Start with data descriptors

    -

    In this guide, block functions return serializable descriptors, and a component registry renders them. Start streams each section's result, server-renders the views, and hydrates the same views in the browser. It deploys to Cloudflare Workers.

    -

    Prerequisites: a Start app with its route tree, router, and document shell, plus the example project files and the Product and CartButton views from the Next.js guide. Enable resolveJsonModule in tsconfig.json.

    +

    There are two ways to render with TanStack Start: this guide, which works with any React setup, and the experimental RSC guide; Which mode to use compares them. Here, block functions don't return JSX. They return a descriptor, a plain object such as { component: 'product', props } (see Rendering). Start sends a route's loader data to the browser as serialized data, and JSX can't make that trip, but a descriptor can. (Here "loader" is TanStack Router's route loader, the function that fetches a route's data. It isn't a content loader from Content and loaders.) In the browser, a view registry (const views = { product: Product }) maps each name to a component. Start streams each section's descriptor as it resolves, renders the views on the server, and hydrates them in the browser. It deploys to Cloudflare Workers.

    +

    Editors choose page URLs in Studio, so your app can't know its routes ahead of time. One catch-all route receives every URL, asks matchRoute which page or redirect owns it, and renders that page's sections.

    +

    Prerequisites: a TanStack Start app (see the Start quick start); npm install @decocms/blocks zod and npm install -D @cloudflare/vite-plugin wrangler, plus the CLI from Quickstart; the example project; and Product and CartButton from step 1 of the Next.js guide. Enable resolveJsonModule in tsconfig.json so you can import the content JSON.

    1. Return descriptors from blocks

    src/blocks.ts
    import type { Blocks, Redirect } from '@decocms/blocks'
    @@ -1039,33 +1199,43 @@ 

    1. Return descriptors from blocks

    } satisfies Blocks export type Content = { page: StoredPage; redirect: Redirect }
    +

    Generate the schema from the map file: npx deco schema --entry ./src/blocks.ts --out ./.deco/schema.json.

    2. Create the CMS

    -

    Workers have no filesystem, so local content is the JSON imported at build time, keyed by entry name (see Content and loaders), and remoteLoader serves releases over it. wrangler dev reads the token from .dev.vars.

    +

    DECO_SITE_TOKEN is your site's API key for the Deco API. On Workers, environment variables come from env in cloudflare:workers; locally, wrangler dev reads them from a .dev.vars file you keep out of Git. In production, set it with npx wrangler secret put DECO_SITE_TOKEN.

    src/cms.ts
    import { createCMS, localLoader, remoteLoader, draftPointer } from '@decocms/blocks'
    -import { env } from 'cloudflare:workers'
    -import ProductPage from '../.deco/blocks/ProductPage.json'
    -import LegacyProduct from '../.deco/blocks/LegacyProduct.json'
    +import { env, waitUntil } from 'cloudflare:workers'
     import blocks from './blocks'
     
    -// The content this build shipped with. remoteLoader falls back to it if the API is unreachable.
    -// revision: any string that changes whenever this JSON does, e.g. a content hash your build defines
    -const local = localLoader({ revision: import.meta.env.VITE_CONTENT_REVISION, blocks: { ProductPage, LegacyProduct } })
    +// The content this build shipped with, keyed by entry name (the file name without .json).
    +const files = import.meta.glob<{ default: unknown }>('../.deco/blocks/*.json', { eager: true })
    +const entries = Object.fromEntries(
    +  Object.entries(files).map(([file, mod]) => [file.slice(file.lastIndexOf('/') + 1, -'.json'.length), mod.default]),
    +)
    +const local = localLoader({ revision: import.meta.env.VITE_CONTENT_REVISION, blocks: entries })
     
    +// Production reads releases from the Deco API and falls back to the bundled content.
     export const cms = createCMS({ loader: remoteLoader(local, { token: env.DECO_SITE_TOKEN }), blocks })
     
     // The client for this request: the draft it points at, or the current release.
    -// Workers have no timers between requests, so each request also kicks the update check;
    -// it returns at once unless five minutes have passed, and never blocks the response.
    -// Not passed to waitUntil here, so the check can be cut short after the response; see Loaders in the API reference.
     export const client = (request: Request) => {
    -  cms.update()
    +  waitUntil(cms.update())   // Check for a newer release (at most every five minutes; never blocks).
       const pointer = draftPointer(request)
       return pointer ? cms.forDraft(pointer) : cms.forRelease()
     }
    +

    Workers have no filesystem, so the content is bundled into the build (see Content and loaders). Workers also stop running timers once a response is sent, so instead of polling on a timer, each request asks cms.update() to check; it returns at once unless five minutes have passed, and waitUntil lets the check finish after the response.

    +

    VITE_CONTENT_REVISION is the bundled content's revision. Any string that changes whenever the JSON does is correct (see the revision rule). Define it in vite.config.ts with define (step 7 does), for example from a hash of .deco/blocks. This hash isn't the one the Deco API computes, so remoteLoader can't recognize the bundled content as the current release: each new Worker instance downloads the current release once instead of skipping it (see Loader internals). That costs one fetch per instance, not correctness.

    +
    content-revision.ts
    import { createHash } from 'node:crypto'
    +import { readdirSync, readFileSync } from 'node:fs'
    +
    +export function contentRevision(dir = '.deco/blocks') {
    +  const hash = createHash('sha256')
    +  for (const file of readdirSync(dir).sort()) hash.update(file).update(readFileSync(`${dir}/${file}`))
    +  return hash.digest('hex').slice(0, 16)
    +}
    -

    3. Open the page

    -

    This helper is shared by both TanStack guides. The server function hands the incoming request to client(), so preview needs no framework support. Each section promise resolves to the value, or to null if the section failed, so no internal error details reach the browser.

    +

    3. Match the URL to a page

    +

    This helper is shared by both TanStack guides. T is what your product block returns: SectionData here, ReactNode in the RSC guide, which reuses this file unchanged. The server function hands the incoming request to client(), and draftPointer finds the pointer in the URL or in the cookie from step 6. Each section promise resolves to the value, or to null if the section failed, so no internal error details reach the browser.

    src/open-page.server.ts
    import { notFound, redirect } from '@tanstack/react-router'
     import { matchRoute, type Redirect } from '@decocms/blocks'
     import { client } from './cms'
    @@ -1098,6 +1268,7 @@ 

    3. Open the page

    }

    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, your token 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 /.

    src/page.functions.ts
    import { createServerFn } from '@tanstack/react-start'
     import { getRequest } from '@tanstack/react-start/server'
     import { z } from 'zod'
    @@ -1146,17 +1317,39 @@ 

    5. Render the catch-all route

    </main> ) }
    -

    Keep section promises unawaited. Start streams nested promises through its own transport and Await consumes them. A plain JSON endpoint can't do this: it has to await every value first.

    -

    Import the server function statically from the route, so Start can replace it with an RPC stub in the client build. The component registry and its views ship to the browser. A block that needs the request calls getRequest() from @tanstack/react-start/server. Drafts need no authorization code here: the pointer carries a signed grant (see Who may preview). See Start server functions.

    +

    Keep section 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 section.

    +

    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.
    • +
    • What ships to the browser. The view registry and its views, and the descriptors as data. The CMS, the block map and the token 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.
    • +
    • Drafts need no authorization code. The pointer carries a token signed by Studio, and the Deco API checks it (see Who may preview).
    • +
    + +

    6. Keep preview across navigation

    +

    Studio opens your site with the draft pointer in the URL, but only on the first page. Append the draft cookie to the document response so every link the editor clicks stays in preview. Start's request middleware runs around every request (see Keep the draft across navigation):

    +
    src/start.ts
    import { createMiddleware, createStart } from '@tanstack/react-start'
    +import { draftCookie } from '@decocms/blocks'
    +
    +const draftCookieMiddleware = createMiddleware().server(async ({ request, next }) => {
    +  const result = await next()
    +  const cookie = draftCookie(request)   // only set on the request Studio opens; expiring when leaving preview
    +  if (cookie) result.response.headers.append('Set-Cookie', cookie)
    +  return result
    +})
     
    -  

    6. Configure Cloudflare Workers

    +export const startInstance = createStart(() => ({ requestMiddleware: [draftCookieMiddleware] }))
    + +

    7. Configure Cloudflare Workers

    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 { contentRevision } from './content-revision'
     
     export default defineConfig({
       plugins: [cloudflare({ viteEnvironment: { name: 'ssr' } }), tanstackStart(), react()],
    +  define: { 'import.meta.env.VITE_CONTENT_REVISION': JSON.stringify(contentRevision()) },
     })
    wrangler.jsonc
    {
       "$schema": "./node_modules/wrangler/config-schema.json",
    @@ -1165,18 +1358,31 @@ 

    6. Configure Cloudflare Workers

    "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 content fetch is shared by every request. See Cloudflare's compatibility flags.

    + +

    Deploying checklist

    +
      +
    • Bundle the content with localLoader({ revision, blocks }).
    • +
    • Wrap it with remoteLoader so releases arrive without a deploy.
    • +
    • Set the token with npx wrangler secret put DECO_SITE_TOKEN.
    • +
    • Call waitUntil(cms.update()) on each request, or from a Cron Trigger (a Worker run on a schedule).
    • +
    • Expect each new Worker instance (Cloudflare calls it an isolate) to serve the build's content until its first check.
    • +
    • Keep both compatibility flags.
    • +
    • The Next.js guide targets Node, not Workers.
    • +

    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.

    -
    +

    Framework guides

    TanStack Start with React Server Components

    -

    Experimental. TanStack Start's RSC APIs are experimental and need an RSC compiler configuration.

    -

    This guide reuses the data guide's content, CMS, and openPage helper, but the product block returns JSX instead of a descriptor. The server renders the tree through React Flight, so the browser needs no view registry.

    +

    Experimental. TanStack Start's RSC support is experimental and needs the extra Vite plugin configured in step 4.

    +

    This guide reuses the data guide's content, CMS and openPage, but the product block returns JSX instead of a descriptor. The server renders it as React Server Components and streams the result in React's wire format (Flight). Because the browser receives rendered output, it needs no view registry, and view code used only on the server never ships.

    1. Swap the product block

    -

    Replace src/blocks.ts with the Next.js guide's src/blocks.tsx. Keep src/cms.ts as it is.

    +

    Replace src/blocks.ts with the Next.js guide's src/blocks.tsx. Keep src/cms.ts, src/open-page.server.ts and src/start.ts (the preview cookie) from the data guide as they are. Update the schema command's --entry to ./src/blocks.tsx.

    2. Render sections on the server

    +

    Replace src/page.functions.ts with this file. It's .tsx now, because it contains JSX.

    src/page.functions.tsx
    import { Suspense, type ReactNode } from 'react'
     import { createServerFn } from '@tanstack/react-start'
     import { getRequest } from '@tanstack/react-start/server'
    @@ -1219,15 +1425,16 @@ 

    3. Render the route

    pendingComponent: () => <p>Opening page…</p>, errorComponent: () => <p>The page could not be loaded.</p>, })
    -

    renderServerComponent serializes the tree through Flight, including references to Client Components like CartButton. Adding "use client" alone doesn't enable this. Start's custom serializers don't apply inside the server tree. If you cache RSC values with TanStack Query, set structuralSharing: false. See Start Server Components.

    +

    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.

    4. Configure Cloudflare Workers

    -

    Make rsc a child environment of the ssr Worker, and keep wrangler.jsonc from the data guide.

    +

    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 and content-revision.ts from the data guide.

    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'
    +import { contentRevision } from './content-revision'
     
     export default defineConfig({
       plugins: [
    @@ -1236,69 +1443,72 @@ 

    4. Configure Cloudflare Workers

    rsc(), react(), ], + define: { 'import.meta.env.VITE_CONTENT_REVISION': JSON.stringify(contentRevision()) }, environments: { rsc: { build: { outDir: 'dist/server/rsc' } }, }, })
    -

    Also requires @vitejs/plugin-rsc 0.5.30 and react-server-dom-webpack 19.2.7. If one repository maintains both integrations, give each its own build configuration.

    +

    Also requires @vitejs/plugin-rsc 0.5.30 and react-server-dom-webpack 19.2.7. If one repository builds both the data and the RSC version, give each its own Vite config: the RSC plugins change how every component is bundled.

    Production

    Releases and deployment

    -

    Every loader returns a revision with its content (see Content and loaders). localLoader, remoteLoader, the CLI, and the Deco API all compute it as the same hash of the whole content map, so "is what I have current?" is one string comparison. A loader you write must keep the same guarantee (see Write your own loader).

    +

    Your code ships when you deploy. Your content can ship the same way, or on its own. This page covers how a content change reaches visitors, how each response stays consistent, and what happens when the network fails. It rests on one idea from Content and loaders: a revision identifies one exact content map. localLoader, remoteLoader, the CLI and the Deco API all compute it as the same hash of the whole map, so two copies of the content are the same exactly when their revisions match. If your fallback's revision uses that hash too, remoteLoader can recognize it and skip a download. A loader you write only has to change its revision whenever content changes (see Write your own loader).

    CodeWhen
    - + - - + +
    Content fromDeployment
    Content fromHow a content change reaches visitors
    localLoaderThe content that ships with the app: a directory on Node, or JSON bundled into the build on Workers. On its own, changing content means redeploying (or, on Node, editing files); as the fallback of remoteLoader, it's what the app serves when the API is down.
    remoteLoader(fallback, …)The Deco API serves each commit of the production branch as an immutable release. The loader reads the current one at request time, cached by revision, with the content the build shipped with as the fallback. A content change reaches visitors without a deploy. This is what "instant deploys" means.
    localLoaderThe content ships with the app: a directory on Node, or JSON bundled into the build on Workers. On its own, changing content means redeploying (or, on Node, editing the files). As the fallback of remoteLoader, it's what a server serves until it has fetched its first release.
    remoteLoader(fallback, …)The Deco API serves each commit of the production branch as a release that never changes. The loader keeps the current one in memory, with the content the build shipped with as the fallback. A content change reaches visitors without a deploy.
    A Loader you writeWhatever your store does. Content reaches visitors when the loader returns a new revision; if it can go stale, implement update() (see Write your own loader).
    -

    Revisions are immutable: change any entry and the hash changes. A revision is a safe cache key everywhere.

    - -

    One revision per response

    -

    A client reads one revision for its lifetime: its first call loads, and every later list and resolve reuses that content, even concurrent ones. Two clients can see different revisions, so make one client per request and do everything the response needs through it.

    -

    When the browser requests more data for a page it has already rendered, send the page's revision along so the data matches the HTML. A revision ID picks content; it doesn't grant access. Preview access is the draft pointer's signed grant and the site's preview hosts in the hosted Deco CMS, see Preview.

    +

    A revision never changes once it exists: change any entry and you get a new revision. That makes a revision a safe cache key everywhere.

    Publishing

    -

    With the hosted Deco CMS, publishing is committing. A developer or an agent edits the entry files in your content directory (.deco/blocks by default) and pushes; an editor saves in Studio, which commits to the same repository. (If you load from your own store, see Content and loaders.) Either way the production branch moves, and the Deco API turns that commit into a release. remoteLoader checks the production channel's revision in the background, on first use and then every five minutes, and caches immutable releases indefinitely, so a change reaches every visitor within about five minutes of the commit. Because content and code share one history, a deploy and a content change are the same kind of event: a commit. Rolling content back is reverting one.

    -

    The API skips a release whose schema doesn't match the running app's, so content can't get ahead of code: if a commit adds a block type the deployed build doesn't have, the loader keeps serving the previous release until the build catches up.

    - -

    Never waiting on the network

    -

    A production request never waits for the API. load() returns from memory, immediately, every time, and the loader checks for newer content in the background every five minutes. Publishing reaches visitors within about that long. How that works, step by step, is under the hood.

    +

    With the hosted Deco CMS, publishing is committing. A developer or an agent edits the entry files in your content directory (.deco/blocks by default) and pushes; an editor saves in Studio, which commits to the same repository. (If you load from your own store, see Content and loaders.) Either way, the production branch (the branch the hosted Deco CMS publishes from, usually main) moves.

    +

    Publishing takes seconds: the Deco API serves that commit as the current release as soon as it lands. Each server picks it up on its next check, every five minutes by default, or at once if you call cms.update() when a publish happens, for example from a webhook (see Loaders). Requests never wait on this: load() always returns from memory, and the check runs in the background. Step by step: under the hood.

    +

    Because content and code share one history, a deploy and a content change are the same kind of event: a commit. Rolling content back is reverting one.

    +

    Content can't get ahead of code. remoteLoader tells the Deco API which schema your build shipped with (a hash of the committed .deco/schema.json), and the API serves only releases whose content fits that schema. That's why the schema must be regenerated and committed with every code change; run deco schema in CI. If a commit adds a block type the deployed build doesn't have, visitors see that commit only after you deploy the code that defines the type; until then, the loader keeps serving the previous release. (Sending the schema hash is part of the Deco API release service, still on the Roadmap.)

    Fallback

    -

    The fallback loader is the only fallback. With the default, localLoader, it's the content the build shipped with, which is the content the deploy was tested against, so it's always available and always compatible with the code. If you pass your own loader as the fallback (see Write your own loader), those guarantees are yours to keep. It always swaps whole revisions: mixing entries from different revisions breaks references and can bring back deleted content. The loader reports the revision it actually served.

    +

    Until a server has fetched its first release (a new server, or one that has never reached the Deco API), remoteLoader serves its fallback loader's content. If the Deco API becomes unreachable later, the server keeps serving the newest release it already has. With the default, localLoader, that's the content the build shipped with, the content this deploy was tested against, so it's always there and always compatible with the code. If you pass a different fallback (see Write your own loader), keeping those guarantees is up to you. remoteLoader always switches between whole revisions, never mixing entries from two of them, because a mix can break references or bring back deleted entries. The loader reports the revision it actually served.

    -

    To point the CMS at a different source, or to write a loader for your own store, see Content and loaders.

    +

    One revision per response

    +

    Because new content can arrive in the background at any moment, two reads a few milliseconds apart could see different revisions; a header from one and a footer from the other make an inconsistent page. A client 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. (A way to read and pin the served revision is on the Roadmap.)

    +

    A revision only selects content; it isn't a secret and unlocks nothing. Unpublished drafts are protected by the signed token in the draft pointer (see Preview).

    + +

    Deploying on Workers

    +

    Cloudflare Workers have no filesystem and no timers between requests, so the content is bundled into the build and each request asks for an update with waitUntil(cms.update()). The deploying checklist in the TanStack Start guide lists every step.

    Production

    Caching and observability

    -

    Each cache layer has its own lifetime and key:

    +

    A request passes through several caches. Deco Blocks manages the first two, the JavaScript runtime the third, and the last two are yours, and getting their keys wrong is how a site shows one customer's cart to another or serves last week's prices.

    - - - - + + + +
    LayerLifetimeYour responsibility
    Content mapCached in the loader: releases by revision, indefinitely; drafts by version. See Content and loaders and how remoteLoader stays current.Nothing, unless you write a loader: then key by revision.
    Dynamically imported modulesCached by the JavaScript runtime after the first import(), for the life of the process.Keep per-request state out of module globals.
    Resolved blocksOne client: one request.Make a client per request and resolve everything the response renders through it.
    Cross-request resultsYour policy.Key by revision, app version, inputs, and relevant request context. Define TTL and invalidation.
    Upstream dataFollows the upstream API.Include tenant, locale, currency, or customer in the key. Never cache private data as public.
    Resolved blocksFor one client. A client runs each block once and reuses the result for the rest of the request.Make a client per request and resolve everything the response renders through it.
    Dynamically imported modulesBlock functions you load lazily with import() (see Block maps) 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.
    Cross-request resultsAnything 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.
    Upstream dataResponses from the APIs your blocks call (a commerce platform, a search service). Follows the upstream API.Include tenant, locale, currency, or customer in the key. Never cache private data as public.
    -

    A block type alone is never a cache key. Include every input that can change the result. A cache hit doesn't bypass authorization. Keep state-changing operations out of content resolution entirely.

    +

    A block type alone is never a cache key. Include every input that can change the result. A cache hit doesn't bypass authorization.

    +

    Block functions run whenever content is resolved: for a visitor, in a Studio preview, for an agent, possibly more than once. So they should only read. Put anything that changes state, like adding to a cart, in your own route handlers.

    Tracing

    -

    Make each request span the parent of block resolution spans and outbound HTTP spans. Record the revision, the entry name (when the block came from a saved entry), the block type, duration, errors, and cache outcome, and keep credentials and customer data out of attributes. Export telemetry asynchronously, so an unavailable collector never blocks rendering. OpenTelemetry covers traces, metrics, and logs.

    -

    Studio and MCP tools build authoring and publishing workflows on the content your loader serves (see Content and loaders). They're not part of a storefront's request path.

    +

    If you use OpenTelemetry, make each request span the parent of block resolution spans and outbound HTTP spans. One way to get block spans is to wrap each function in your block map in a span named after its block type. Record the entry name (when the block came from a saved entry), the block type, duration, errors, and cache outcome, and keep credentials and customer data out of attributes. Recording the served revision needs the client to expose it, which is on the Roadmap. Export telemetry asynchronously, so an unavailable collector never blocks rendering.

    -
    -

    Production

    Upgrading and compatibility

    -

    Block type names, saved entry names, and input shapes are persistent interfaces: saved content refers to them by name. Moving a function's source file is free, because content stores the block map key, not the path. Renaming a type or removing an input affects every block that uses it, and renaming a saved entry breaks every reference to it (NOT_FOUND).

    +
    +

    Production

    Renaming and migrating

    +

    Saved content refers to your code by name: every block stores a type name in __resolveType, references store entry names, and inputs are stored by field name. Treat these names like database columns: once content uses them, changing one breaks that content. Renaming a type or removing an input affects every block that uses it, and renaming a saved entry breaks every reference to it (NOT_FOUND).

    +

    This page covers renaming safely, and bringing in content from a site built on Deco's earlier framework, where type names were file paths such as site/sections/Product.tsx. Moving source files is free; only names matter.

    Rename a type with an alias

    -

    An alias is another key pointing at the same thing: the same function in the block map, or the same type in Content. Content that stores the old name keeps working, and routing is unaffected because it looks at path, not the type name:

    +

    An alias is another key pointing at the same thing: the same function in the block map, or the same type in Content. Content that stores the old name keeps working, and routing is unaffected because matchRoute looks at each entry's path field (see Routes and redirects), not its type name. Here, Page is your page type and Redirect ships with @decocms/blocks:

    blocks.ts
    import type { Blocks, Redirect } from "@decocms/blocks";
     import type { Page } from "./page";
     import productCard from "./product-card";
    @@ -1313,36 +1523,44 @@ 

    Rename a type with an alias

    "website/pages/Page.tsx": Page; // same type, legacy name; entries still route redirect: Redirect; };
    -

    An alias only works while both names take the same inputs. If the old shape differs, register a function that converts it, or migrate the content first. deco schema rejects an alias that collides with an entry name.

    +

    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:

    +
    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 schema rejects an alias that collides with an entry name.

    -

    Upgrade checklist

    +

    Checklist for migrating existing content

      -
    1. Keep existing names. Register existing path-like types as they are, or alias them.
    2. +
    3. Keep existing names. If the content stores file-path type names, register them as they are or alias them.
    4. Check inputs. Regenerate the schema and validate saved content. Review new required fields, narrowed unions, and changed defaults.
    5. -
    6. Migrate into a new revision. Keep unknown fields for inspection, and report references to missing entries (NOT_FOUND) instead of dropping them.
    7. -
    8. Test the integration. Check routing, metadata, streamed sections, error fallbacks, and client navigation against the migrated content.
    9. -
    10. Keep a rollback pair. Keep the previous build with compatible content. Rolling back content alone can't restore removed code.
    11. +
    12. 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 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.
    13. +
    14. Test the integration. Load real pages against the migrated content: routes resolve, titles and SEO tags are right, sections that load later (if your framework streams them) appear, failing blocks show their fallback, and navigation works.
    15. +
    16. 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.

    Production

    Troubleshooting

    -

    Start with three facts: the revision served, the entry name, and the block type. Call client.resolve(target, { run: false }) to see the content before any code runs, and the map your loader returns to see an entry exactly as stored.

    +

    Most problems come down to three facts: which revision this server is serving, which entry was asked for, and which block type it names. Until the client exposes its revision (see the Roadmap), build the loader yourself, pass it to createCMS, and log (await loader.load()).revision; that's the revision a new client would read right now. To see an entry with references expanded but no function run, call client.resolve(target, { run: false }). To see it exactly as stored, open .deco/blocks/<name>.json.

    + - - - - - - - + + + + + +
    SymptomWhat to check
    UNKNOWN_BLOCKCompare __resolveType with your block map's keys, including case. Add an alias for renamed types. If the function does a dynamic import(), confirm the imported module has a default export.
    NOT_FOUNDA string target is always an entry name, never a block type. Confirm the entry exists in the served revision, and that the loader reads the content you expect (see Configuring the loader). To run a type directly, pass an inline block: client.resolve({ __resolveType: "seo", … }).
    CYCLEFollow the references in error.path and remove the one that leads back to an entry already being expanded.
    My saved entry is ignoredA block type probably has the same name, and the function wins. Run deco schema, which reports the collision, and rename the entry.
    Blocks come back as JSON instead of runningYou called client.list without { run: true } (it returns saved entries by default), or you passed { run: false } to resolve. To run listed entries, pass run: true, or resolve the fields you need with client.resolve.
    A function runs more than once per requestUse one client for the whole request, and keep the block map at module level. Each new client, and each new map object, gets its own cache.
    CYCLEFollow the references in error.path and remove the one that leads back to an entry already being expanded.
    Wrong fields in the editorRegenerate the schema from the right blocks.ts. Check the first parameter's type and its property JSDoc.
    A result can't cross the networkJSON can't carry functions or React trees. Return serializable descriptors, or use an RSC integration.
    Hydration mismatchCompare revisions and request context between server and client. Look for time-dependent or random rendering, and send the page's revision with follow-up requests.
    Content changes mid-responseUse one client for the whole response; a client never changes revision. Check that the loader never rewrites an existing revision.
    Stale or fallback contentCheck the revision the loader served, channel freshness, and release availability: a release whose schema doesn't match the app is skipped, and on Workers update() only runs if you call it. Never mix entries from different revisions. See Releases and deployment and how remoteLoader stays current.
    A function runs more than once per requestA 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.
    Wrong fields in the editorRegenerate the schema from the right blocks.ts. Check the first parameter's type and its property JSDoc (see Schema generation).
    Serialization error when sending a block's result to the browserJSON can't carry functions or React elements. Return plain data (descriptors, see Rendering), or use an integration that renders on the server, like TanStack RSC or Next.js.
    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.
    Content changes mid-responseUse one client for the whole response; a client never changes revision. If you wrote the loader, check that it never returns different content under an existing revision.
    Stale or fallback contentCompare the revision your server serves (see above) with the revision of the latest release. A server that hasn't fetched a release yet reports its fallback's revision, which matches only if the fallback uses the same content hash. If they differ: the newest release may be held back because its schema doesn't match this build (deploy the matching code), or, on Workers, update() may never run (call waitUntil(cms.update())). See Releases and deployment and how remoteLoader stays current.
    @@ -1350,37 +1568,50 @@

    Upgrade checklist

    -

    Under the hood

    Under the hood

    -

    The developer docs describe how to use Deco Blocks. This part describes how it works as a whole: the resolution algorithm, how the production loader stays current without ever blocking a request, how the router matches, what Deco Studio reads and how the framework stays compatible with it, and the decisions behind all of that. It's for contributors, for anyone integrating a new framework or store, and for the curious.

    +

    Under the hood

    How Deco Blocks works inside

    +

    The developer docs describe how to use Deco Blocks. This part describes how it works inside. It's for contributors, for anyone connecting a new framework or content store, and for the curious:

    +

    Contributing

    -

    The framework is a Bun workspace at decocms/blocks. Five packages, one-way dependency graph, and that direction is load-bearing:

    +

    The framework lives in one repository, decocms/blocks, as a Bun workspace (Bun is the package manager and test runner). Five core packages make up the framework; the other folders under packages/, such as apps-*, are integrations with commerce and other services built on top of them. The core packages depend on each other in one direction only:

    packages/
    -├── blocks/        @decocms/blocks         the SDK: loaders, client, resolution, router. Zero deco dependencies.
    -├── blocks-admin/  @decocms/blocks-admin   the Studio protocol handlers.            depends on: blocks
    -├── blocks-cli/    @decocms/blocks-cli     schema generation and migrations.        depends on: blocks
    -├── tanstack/      @decocms/tanstack       TanStack Start + Cloudflare binding.     depends on: blocks, admin, cli
    -└── next/          @decocms/nextjs         Next.js App Router binding.              depends on: blocks, admin
    -

    blocks never imports from the others, and the two framework bindings never import from each other. When a concern needs both sides, split it rather than adding a back-edge. Cross-package imports resolve to .ts source, so no package bundles another's output; that's the guarantee the split exists for.

    +├── blocks/ @decocms/blocks the SDK: loaders, client, resolution, router. +│ Depends on no other @decocms package. +├── blocks-admin/ @decocms/blocks-admin the HTTP endpoints Studio calls on your site. +│ depends on: @decocms/blocks +├── blocks-cli/ @decocms/blocks-cli deco schema, plus scripts that move sites off +│ the previous framework. +│ depends on: @decocms/blocks +├── tanstack/ @decocms/tanstack the adapter for TanStack Start on Cloudflare Workers. +│ depends on: @decocms/blocks, @decocms/blocks-admin, @decocms/blocks-cli +└── nextjs/ @decocms/nextjs the adapter for the Next.js App Router. + depends on: @decocms/blocks, @decocms/blocks-admin
    +

    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. blocks never imports from the others, and the two adapters never import from each other; if a feature needs both sides, split it into two functions rather than import in reverse. Apps following these docs need only @decocms/blocks at runtime, plus @decocms/blocks-cli as a dev dependency. The two adapters serve sites on Studio's current protocol; apps written against the API in these docs don't need them.

    bun install
     bun run check     # typecheck + lint
     bun run test      # vitest, per package
    -

    Two rules worth knowing before a first PR. A site must never need a local shim for something a package should export: if a symbol is missing from a package's public surface, add the export. And if a test named after a production incident fails, don't relax the assertion; it's there because that exact invariant broke once.

    +

    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 one request's layout data from leaking into another's; if one fails, fix the code, not the assertion.

    Under the hood

    How resolution works

    -

    Step through the rule for one saved entry. Switch between a read with { run: false } and a full resolve, and between a descriptor and a React tree as the function output. This is an illustration, so it doesn't run any application code.

    +

    Here's the setup. Your block map registers two functions, product-card and catalog-product. Your content map holds two saved entries: CurrentProduct, which is { "__resolveType": "catalog-product", "slug": "summer-shirt" }, and SummerCard, shown in step 1, which uses it. As code, SummerCard means productCard({ title: "Summer collection", product: catalogProduct({ slug: "summer-shirt" }) }).

    +

    Step through client.resolve("SummerCard") to watch the rule at work, or switch to { run: false } to see what a read returns. product-card can return a descriptor 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.

    -
    ProductCard · one saved entry
    +
    SummerCard · one saved entry
    Function output
    -
    {
    +    
    {
       "__resolveType": "product-card",
       "title": "Summer collection",
       "product": { "__resolveType": "CurrentProduct" }
     }

    The saved entry. Its product input names CurrentProduct, another saved entry.

    -

    CurrentProduct is a saved entry, { "__resolveType": "catalog-product", "slug": "summer-shirt" }, so looking it up is a data hit followed by a function hit. Once CurrentProduct expands, the stored entry means productCard({ title: "Summer collection", product: catalogProduct({ slug: "summer-shirt" }) }). A descriptor names a view for the browser to render. A React tree is consumed by an RSC-capable framework. Either way, the CMS returns it untouched.

    @@ -1399,20 +1630,22 @@

    Contributing

    Fetch, verify, swapWhole revision at once. Any error: keep what's there.
      -
    1. Serve from memory. load() returns the newest release this process has fetched, or, if it hasn't fetched one yet, the fallback loader's content. A cold start serves the content the build shipped with on its very first request.
    2. -
    3. Ask for the hash. In the background, on first use and then every five minutes, update() fetches the current revision hash from the API. That's a few bytes. Each wait is jittered by up to 30 seconds, so a fleet of servers that started together doesn't poll together.
    4. -
    5. Compare. If the hash equals what's in memory, there's nothing to do. If it equals the fallback loader'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.
    6. -
    7. Fetch and swap. Otherwise it fetches that revision, checks that the content hashes to it, and swaps it in whole (the API has already skipped releases whose schema doesn't match the running app). Requests in flight keep the revision they started with.
    8. -
    9. Fail quietly. Any network or validation error leaves memory as it was. Nothing reaches a request.
    10. +
    11. Serve from memory. load() returns the newest release this process has fetched, or, if it hasn't fetched one yet, the fallback loader's content. A freshly started server (a cold start) serves the content the build shipped with on its very first request.
    12. +
    13. Ask for the hash. In the background, on first use and then every five minutes, update() fetches the current revision hash from the API. That's a few bytes. Each wait gets a random extra delay of up to 30 seconds, so servers that started together don't poll together. On Node, remoteLoader runs this on its own timer. Cloudflare Workers can't run timers between requests, so there you call cms.update() yourself, for example with waitUntil(cms.update()); calls more frequent than the interval return at once.
    14. +
    15. Compare. If the hash equals what's in memory, there's nothing to do. If it equals the fallback loader'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, as long as the fallback's revision is the same content hash the API uses; a fallback with its own revision scheme just gets downloaded once.
    16. +
    17. Fetch and swap. Otherwise it fetches that revision, checks that the content hashes to it, and swaps it in whole. The API answers only with releases whose content fits the schema the build shipped with (see Publishing), so content never gets ahead of code. Requests in flight keep the revision they started with.
    18. +
    19. Fail quietly. Any network or validation error leaves memory as it was, so a server that loses the API keeps serving the newest release it has. Nothing reaches a request.
    -

    So production content is eventually consistent, by design, with the polling interval as the bound: a commit is live everywhere within about five minutes, and one process only ever does one tiny request per interval, however many requests it serves. Drafts are the opposite: a preview must show exactly the version Studio asked for, so load(pointer) fetches that version and waits for it, cached by version afterwards. If the draft can't be fetched, it serves published content, never the fallback.

    +

    So different servers can briefly serve different releases, by design. A published change reaches each server on its next check, within five minutes by default or at once when you call cms.update(), and each server sends one tiny request per interval, however much traffic it serves. (This is called eventual consistency.)

    +

    Drafts are the opposite: a preview must show exactly the version Studio asked for, so load(pointer) fetches that version and waits for it, cached by version afterwards. If the draft can't be fetched, the client serves what a normal request would get: the newest release in memory, or the build's content if none has been fetched yet. It never mixes draft and published entries.

    To configure remoteLoader, see Configuring the loader; for what a release is, see Releases and deployment.

    Under the hood

    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 instead compiles the entries into a segment trie, once per list, and matches in time proportional to the URL's length, not the catalogue's size.

    +

    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 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.

    +

    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.

    /summer                 /:slug/p                /blog/:slug             /blog/archive
                                         ┌── root ──┐
                                         │           │
    @@ -1423,27 +1656,29 @@ 

    Contributing

    "archive" :slug │ │ │ [ProductPage] [BlogArchive] [Post …]
    +

    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.

      -
    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. 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 request with the same list skips this entirely.
    2. -
    3. Reject ambiguity while building. Two entries reaching the same leaf, or two parameter children under one node (/:slug/p and /:id/p), throw with both names in the message. The CLI runs the same build at schema time, so this surfaces in CI, not in production.
    4. -
    5. 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. If a branch dead-ends, backtrack once to the parameter child of the last node that had one. Backtracking is bounded by the URL's depth, so a lookup is O(segments), typically four or five steps, regardless of how many entries exist.
    6. +
    7. 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. 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.
    8. +
    9. Reject 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), throw with both names in the message. The CLI runs the same build at schema time, so running deco schema in CI on every commit, including Studio's, catches it before it ships.
    10. +
    11. 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. If a branch dead-ends, go back up and try the parameter child you skipped. Each node has at most one literal match and one parameter, so a lookup visits about as many nodes as the URL has segments, however many entries exist.
    12. Precedence falls out of the walk order. Trying the literal child before the parameter child at every node is exactly "exact paths win over templates", per segment rather than per path: /blog/archive beats /blog/:slug for /blog/archive, and /blog/:slug still serves /blog/hello-world. No sorting, no regex.
    13. -
    14. Redirects are a second trie, checked first, with the same rules. A hit fills the parameters into to and returns { kind: "redirect" }.
    15. +
    16. Redirects are a second trie, checked first, with the same rules. A hit fills the parameters into the redirect's to path and returns { kind: "redirect", location, status }.
    -

    Before matching, the URL is normalised: percent-decoded, query and fragment dropped, trailing slash removed except for /. Parameters capture one segment and never contain a slash. That's the whole algorithm; it's the same shape as the routers in Fastify and Hono, and it's about a hundred lines.

    +

    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. That's the whole algorithm; it's the same shape as the routers in Fastify and Hono, and it's about a hundred lines.

    Under the hood

    Deco Studio compatibility

    What Studio reads

    -

    Studio never calls the SDK. It reads two things: the schema the CLI wrote, served as /live/_meta, and the content files. So everything Studio knows about your site is inferred from your types, and the CLI does the inferring. It groups every key into a manifest by what the type says:

    +

    Studio learns about your site from your site. Your app serves a few HTTP endpoints for it. The main one, /live/_meta, returns the schema the CLI wrote; Studio also calls your app to render previews (/live/previews), to run functions for pickers and the Run button (/deco/invoke), and to encrypt secret fields (/live/invoke). In today's packages, these handlers come from @decocms/blocks-admin; how the new API mounts them is on the Roadmap. Studio also reads your content files in .deco/blocks. Everything Studio knows about the shape of your content comes from the schema, so it's all inferred from your types; the other endpoints run your code, they don't describe it.

    +

    The schema file has two parts. definitions holds one JSON Schema per type: the form Studio shows. manifest lists every key of your block map and your Content type, grouped by kind, so Studio knows which keys are sections, which are rules, and so on. Each manifest entry points at its definition with a JSON Schema $ref. The CLI picks the group from the type:

    - + - + @@ -1461,11 +1696,19 @@

    What Studio reads

    }, "definitions": { "hero": { "type": "object", "properties": { "title": { "type": "string", "title": "Heading" } } } } } -

    Three conventions follow from this, and they're the ones the rest of the doc already uses. A key is a block type if it's in the manifest and a saved entry if it isn't, so short names like hero are fine, as long as no saved entry uses the same name (see Blocks). A variant is { "variants": [{ "rule", "value" }] } with the default first. A redirect is a flat { from, to, permanent } entry.

    +

    Three conventions follow from this, and they're the ones the rest of the doc already uses. A key is a block type if it's in the manifest and a saved entry if it isn't, so short names like hero are fine, as long as no saved entry uses the same name (see Blocks). A variant is { "variants": [{ "rule": …, "value": … }, …] }. 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.

    Studio compatibility today

    -

    Today's Studio predates this design and hardcodes a few of the things above: it tells modules from saved entries by whether the key has a file extension, and it looks for pages, the default and hidden rules, and variant wrappers by their old module paths (website/pages/Page.tsx, website/matchers/always.ts, website/flags/multivariate.ts, …). Until it reads the manifest instead, the CLI bridges the gap: for every key in a framework-known group it also emits the legacy path as an alias, and content Studio saves under a legacy name resolves through the same alias mechanism your own renames use. Your code uses page and always; the manifest carries both spellings; nothing in the runtime changes.

    -

    Two gaps the bridge doesn't cover, both Studio-side and both small: redirects created in Studio are saved in the old nested shape, which matchRoute accepts as well; and Studio's module-or-entry check needs to become a manifest lookup before a site can drop the aliases. Both are tracked against Studio, not against sites.

    +

    Deco's previous framework named 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. Today's Studio was built for that framework and still expects those names. It decides whether a key is a block type or a saved entry by checking for a file extension, and it finds pages, the always rule (the default variant), the never rule (how it hides a section) and multivariate (website/flags/multivariate.ts) by their old paths.

    +

    Until Studio reads the manifest instead, the CLI bridges the gap: for the types Studio looks up by name, it also writes the old path into the manifest as an alias (a second key for the same type). Content Studio saves under an old name resolves through the same alias mechanism your own renames use. Your code uses page and always; the manifest carries both spellings; nothing in the SDK changes.

    +

    Two gaps remain. Redirects created in Studio are saved in the old nested shape, not the flat one from Routes and redirects:

    +
    // Saved by today's Studio
    +{ "__resolveType": "website/loaders/redirect.ts",
    +  "redirect": { "from": "/campaigns/summer", "to": "/summer", "type": "permanent" } }
    +
    +// The shape these docs use
    +{ "__resolveType": "redirect", "from": "/campaigns/summer", "to": "/summer", "permanent": true }
    +

    matchRoute accepts both. And Studio's file-extension check must become a manifest lookup before a site can drop the aliases. Both are Studio work, tracked in the Roadmap, not something sites do.

    @@ -1475,17 +1718,18 @@

    Studio compatibility today

    Manifest groupWhat lands thereStudio uses it for
    sectionsBlock functions that return JSX or a render descriptorThe section catalog when composing a page
    sectionsBlock functions that return JSX or a render descriptorThe section catalog: the blocks that render UI, which an editor can add to a page
    matchersBlock functions that return booleanThe rule picker on a variant
    loadersEvery other block function (data functions, not the content loaders)Data fields that can point at a function
    loadersEvery other block function. Studio calls these loaders, after the old framework's name for data functions; they're not the content loaders from Content and loaders.Data fields that can point at a function
    pagesContent types that extend RouteThe page list, "new page", the URL field
    redirectsRedirectThe redirects screen
    contentEvery other Content typePlain entries: navigation, settings, posts
    - - - - + + + + - - + + - + +
    DecisionWhy
    The CMS is 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 job. Request state comes from the runtime's own context, where it already lives.
    For content edited in Studio, Git is the source of truth. Publishing is committing; the API serves commits as releases. (Any other store is a loader you write.)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.
    One registry rule. A name is data or code; data merges and re-applies, code resolves inputs then runs; outputs are opaque.Saved entries are named compositions, not a second concept, identity functions can't loop, and the only recursion is over data, where a cycle check ends it.
    The CMS at module scope, a client per request, pinned to one revision.Everything a response renders agrees with itself, sections can stream as separate calls, and the next request sees the next revision. Clients are cheap because every cache lives in the loader.
    The CMS is 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.
    For content edited in Studio, Git is the source of truth. Publishing is committing; the Deco API serves commits as releases. (Any other store is a loader you write.)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.
    One registry rule. A name is a saved entry or a block type. An entry is merged in and checked again; a function gets its inputs resolved, then runs, and its result is returned as is.Saved entries are just named compositions, 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 entries, and a cycle check stops it.
    The CMS at module scope, a client per request, pinned to one revision.Everything a response renders agrees with itself, sections can be resolved and streamed to the browser one at a time, and the next request sees the next revision. Clients are cheap because every cache lives in the loader.
    Loaders are one interface: load(pointer?), plus an optional update().Production, drafts, and staying current are uses of the same interface, so localLoader and remoteLoader are interchangeable and a custom loader is a few lines.
    A production request never waits for the network.Serve from memory, poll for a hash in the background, fall back to the content the build shipped with. Eventual consistency for visitors, strict consistency for drafts.
    The CMS doesn't route. matchRoute 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.
    Data-only types live in the Content type, not the block map. No built-in entries.A block map entry means "run this". Pages, posts, and redirects have nothing to run; they need a schema, which is the CLI's job. Nothing ships that a site couldn't write in one line.
    Lazy loading belongs to the framework.What's heavy is the component, and React and Next already load components lazily. The block map stays a plain object the CLI can read.
    Data-only types live in the Content type, not the block map, and the framework ships no ready-made page or post types.A block map entry means "run this". Pages, posts, and redirects have nothing to run; they need a schema, which is the CLI's job. Nothing ships that a site couldn't write in one line. Redirect is the one shared type, because matchRoute reads it.
    Lazy loading belongs to your web framework, not the CMS.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.
    Three matchers ship; the rest is app code.always, never, and date depend on nothing but the clock. A device or cookie matcher is an opinion about the request, and the app owns those.
    Short type names, aliases for legacy paths.A type name is a column name, not a file path. Existing content keeps working through aliases while new code uses names that survive a refactor.
    Short type names; aliases for the old file-path names.A type name is a column name, not a file path. Content saved under the previous framework's names, like website/pages/Page.tsx, keeps working through aliases, while new code uses names that survive a refactor.
    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.
    diff --git a/site/src/landing.html b/site/src/landing.html index abdadc30..cc221906 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -98,14 +98,14 @@

    Every change is a commit, 01

    Headless for developers

    -

    Write a function, type its inputs, and it's a block. Deco Blocks 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.

    +

    Write a function, type its inputs, and add it to your block map. Content can now call it. Deco Blocks 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.

    @@ -232,8 +232,8 @@

    Publish in seconds, not on

    Your function stays. Its inputs move into a file.

    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.

    - - + +
    @@ -271,6 +271,7 @@

    Your function stays. Its i // Production (with a token) reads the hosted Deco CMS; // development reads the files in .deco. export const cms = createCMS({ + // your site's API key for the hosted Deco CMS loader: { token: process.env.DECO_SITE_TOKEN }, blocks, }); @@ -300,7 +301,7 @@

    Small on purpose. Nothing diff --git a/site/src/style.css b/site/src/style.css index 4aeb6a76..cb75fcad 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -463,6 +463,10 @@ body[data-page="roadmap"] .sidebar-foot .sf-link { display: none; } .doc-section li { padding-left: 6px; } .doc-section li + li { margin-top: 8px; } .doc-section ul > li::marker { color: var(--olive-ring); content: "– "; } +.doc-section dl.terms { margin: 18px 0; } +.doc-section dl.terms dt { margin-top: 14px; color: var(--fg); font-size: 16px; line-height: 1.6; font-weight: 600; } +.doc-section dl.terms dt:first-child { margin-top: 0; } +.doc-section dl.terms dd { margin: 2px 0 0; color: var(--fg-body); font-size: 16px; line-height: 1.72; } /* Ordered lists: the site's numbered hairline rows */ .doc-section ol { list-style: none; padding-left: 0; counter-reset: n; border-bottom: 1px solid var(--hairline); } .doc-section ol > li { position: relative; counter-increment: n; margin: 0; padding: 14px 0 14px 44px; border-top: 1px solid var(--hairline); } From 171ffcb9dc3747dab1cfdee2948ca4b4440df2ca Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 16:44:09 -0300 Subject: [PATCH 013/132] docs(site): drop "section" as a framework concept A block that returns JSX is just a block. The glossary entry, definitions, "section" wording in guides and examples, the home page block map comment and Roadmap titles now say "block" (or "a block that returns JSX"). Kept: the page's `sections` field, described as a list of blocks, and Studio's legacy names, labelled as such. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/data/roadmap.json | 128 ++++++++++++++++----------------- site/src/content.html | 160 ++++++++++++++++++++--------------------- site/src/landing.html | 6 +- 3 files changed, 147 insertions(+), 147 deletions(-) diff --git a/site/data/roadmap.json b/site/data/roadmap.json index cda59ae3..5a9e310b 100644 --- a/site/data/roadmap.json +++ b/site/data/roadmap.json @@ -179,7 +179,7 @@ "id": "roadmap-blockers--make-draft-preview-work-inside-studio-s-iframe", "title": "Make draft preview work inside Studio's iframe", "today": "The SameSite=Lax cookie is rejected cross-site, the TanStack guide never sets it, and ?__draft=off is ignored. There's no no-store rule, and no StudioBridge or data-manifest-key, so click-to-select is lost.", - "plan": "Ship withDeco()/decoProxy(), which set the cookie as Secure; SameSite=None; Partitioned, treat off/empty as exit and mark drafts private/no-store. Add <StudioBridge/>, and have <Sections> emit section[data-manifest-key].", + "plan": "Ship withDeco()/decoProxy(), which set the cookie as Secure; SameSite=None; Partitioned, treat off/empty as exit and mark drafts private/no-store. Add <StudioBridge/>, and have <BlockList> emit Studio's section[data-manifest-key] marker.", "features": [ "studio-preview", "worker-server-entry", @@ -233,7 +233,7 @@ "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 Resolved<T> can't be expressed.", - "plan": "Add one deferral API: a Deferred<T> input type bound to the client's revision, which <Sections> renders lazily. A multivariate over Deferred values resolves only the winner.", + "plan": "Add one deferral API: a Deferred<T> input type bound to the client's revision, which <BlockList> renders lazily. A multivariate over Deferred values resolves only the winner.", "features": [ "lazy-deferred-sections", "matchers-and-variants", @@ -246,7 +246,7 @@ { "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 Studio writes Lazy, SeoV2, site/apps/site.ts, redirect, secret and the multi matcher. UNKNOWN_BLOCK now fails the parent section, and list() can't see Content aliases.", + "today": "Filename decoding is unspecified, and the alias bridge covers only pages, matchers and multivariate, while Studio writes Lazy, SeoV2, site/apps/site.ts, redirect, secret and the multi matcher. UNKNOWN_BLOCK now fails the parent block, and list() can't see Content aliases.", "plan": "Specify single-decode filenames, widen the alias bridge, ship deco check and deco bundle, and have the CLI emit a runtime alias table.", "features": [ "block-type-discriminator", @@ -313,7 +313,7 @@ { "id": "roadmap-studio-new--keep-gallery-theme-and-in-place-previews-working", "title": "Keep gallery, theme and in-place previews working", - "today": "Section thumbnails, global and theme previews, and Fast Preview in-place render all go through /live/previews.", + "today": "Studio's section thumbnails (its legacy name for block previews), global and theme previews, and Fast Preview in-place render all go through /live/previews.", "features": [ "section-catalog", "theming-and-styling", @@ -366,7 +366,7 @@ { "id": "roadmap-studio-new--restore-click-to-select", "title": "Restore click-to-select", - "today": "There's no editor::inject listener, and the guides' renderers emit no section[data-manifest-key].", + "today": "There's no editor::inject listener, and the guides' renderers emit no Studio section[data-manifest-key] marker.", "features": [ "root-document-layout", "interactive-ui-widgets" @@ -420,7 +420,7 @@ { "id": "roadmap-studio-new--stop-studio-reading-short-type-names-as-saved-entries", "title": "Stop Studio reading short type names as saved entries", - "today": "Studio classifies keys by file extension, so site sections must keep site/sections/X.tsx keys until it does a manifest lookup.", + "today": "Studio classifies keys by file extension, so a site's UI blocks must keep legacy site/sections/X.tsx keys until it does a manifest lookup.", "features": [ "block-type-discriminator", "section-registration-conventions" @@ -510,8 +510,8 @@ }, { "id": "roadmap-studio-legacy--stop-hidden-sections-from-running", - "title": "Stop hidden sections from running", - "today": "'Every variant resolves': a section hidden with a never-rule still runs its loaders, and its undefined renders 'temporarily unavailable'.", + "title": "Stop hidden blocks from running", + "today": "'Every variant resolves': a block hidden with a never-rule still runs its loaders, and its undefined renders 'temporarily unavailable'.", "features": [ "matchers-and-variants" ], @@ -523,7 +523,7 @@ { "id": "roadmap-studio-legacy--drop-hidden-array-items-instead-of-leaving-holes", "title": "Drop hidden array items instead of leaving holes", - "today": "Studio hides a banner or link by wrapping it in website/flags/multivariate.ts with a never rule (array-item-hidden.ts:35-40). Today's resolver drops undefined array items (resolve.ts:849-860). The proposed API's rule and resolve(page.sections) ('An array of results, one per section') keep them, so components receive undefined entries.", + "today": "Studio hides a banner or link by wrapping it in website/flags/multivariate.ts with a never rule (array-item-hidden.ts:35-40). Today's resolver drops undefined array items (resolve.ts:849-860). The proposed API's rule and resolve(page.sections) ('An array of results, one per block in the list') keep them, so components receive undefined entries.", "features": [ "matchers-and-variants" ], @@ -659,8 +659,8 @@ { "id": "roadmap-api--add-a-section-renderer-and-a-deferral-api", "group": "api", - "title": "Add a section renderer and a deferral API", - "plan": "

    <Sections> per binding: per-section Suspense, error boundary, clientOnly, sized fallbacks, eager above the fold, SEO routing, and a section[data-manifest-key] frame on every section for Studio. One deferral API: a Deferred<T> input type passes a ref bound to the client's revision instead of resolving it. <Sections> renders the ref lazily, and a multivariate whose values are Deferred<T> resolves only the winning variant.

    ", + "title": "Add a block renderer and a deferral API", + "plan": "

    <BlockList> per binding: per-block Suspense, error boundary, clientOnly, sized fallbacks, eager above the fold, SEO routing, and Studio's section[data-manifest-key] frame on every block. One deferral API: a Deferred<T> input type passes a ref bound to the client's revision instead of resolving it. <BlockList> renders the ref lazily, and a multivariate whose values are Deferred<T> resolves only the winning variant.

    ", "features": [ "section-registration-conventions", "lazy-deferred-sections", @@ -949,7 +949,7 @@ "id": "roadmap-api--add-observability-seams", "group": "api", "title": "Add observability seams", - "plan": "

    A createCMS onEvent/instrument hook, instrumentRequest(handler) outside createDecoWorkerEntry, a Node/Next meter bootstrap and a traced <Section>.

    ", + "plan": "

    A createCMS onEvent/instrument hook, instrumentRequest(handler) outside createDecoWorkerEntry, a Node/Next meter bootstrap and a traced <BlockSlot>.

    ", "features": [ "otel-observability", "upstream-fetch-instrumentation" @@ -1128,8 +1128,8 @@ { "id": "roadmap-cli--support-typed-section-references", "group": "cli", - "title": "Support typed section references", - "plan": "

    Section / BlockOf<'hero' | 'alert'> becomes an anyOf or __SECTION_REF__. Also a SectionDescriptor<P> marker, @singleton and @catalog false.

    ", + "title": "Support typed block references", + "plan": "

    BlockOf<'hero' | 'alert'> becomes an anyOf or Studio's legacy __SECTION_REF__. Also a Descriptor<P> marker, @singleton and @catalog false.

    ", "features": [ "nested-section-props", "per-page-section-whitelists", @@ -1226,7 +1226,7 @@ "id": "roadmap-platform--use-manifest-lookup-instead-of-path-heuristics", "group": "studio", "title": "Use manifest lookup instead of path heuristics", - "plan": "

    Replace the file-extension module-or-entry check and the '/sections/' heuristics, and refuse to save an entry whose name is a manifest key.

    ", + "plan": "

    Replace the file-extension module-or-entry check and Studio's legacy '/sections/' path heuristics, and refuse to save an entry whose name is a manifest key.

    ", "features": [ "block-type-discriminator", "section-registration-conventions", @@ -1303,7 +1303,7 @@ "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": "

    Use a page type's sections anyOf as the catalog, and render the remaining page props as a form.

    ", + "plan": "

    Use the anyOf on a page type's sections list as Studio's catalog, and render the remaining page props as a form.

    ", "features": [ "per-page-section-whitelists", "page-level-settings-fields" @@ -1350,7 +1350,7 @@ "id": "roadmap-docs--resolve-contradictions-in-these-docs", "group": "docs", "title": "Resolve contradictions in these docs", - "plan": "

    Also: make Page.seo optional, drop array elements that resolve to undefined (Studio's hidden array items), make a hidden section's undefined render nothing, say what a reference to a Content entry resolves to, and fix the credential sentence in Preview.

    ", + "plan": "

    Also: make Page.seo optional, drop array elements that resolve to undefined (Studio's hidden array items), make a hidden block's undefined render nothing, say what a reference to a Content entry resolves to, and fix the credential sentence in Preview.

    ", "features": [ "cms-page-routing", "codegen-pipeline", @@ -1439,7 +1439,7 @@ "id": "roadmap-docs--write-the-post-cutover-recipes", "group": "docs", "title": "Write the post-cutover recipes", - "plan": "

    Page-level data shared by sections, client data fetching, server-only upstream proxies, overriding an app's block, tabs over pre-resolved data, head tags from sections, locale, and robots/static SEO files.

    ", + "plan": "

    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.

    ", "features": [ "implicit-page-data-context", "product-comparison", @@ -1457,7 +1457,7 @@ "id": "roadmap-docs--write-a-faststore-to-blocks-guide", "group": "docs", "title": "Write a FastStore-to-Blocks guide", - "plan": "

    Decision: port @faststore/core's 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.

    ", + "plan": "

    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.

    ", "features": [ "faststore-cli-overlay", "design-system-library", @@ -1512,8 +1512,8 @@ { "id": "roadmap-docs--add-notes-to-the-rendering-section", "group": "docs", - "title": "Add notes to the Rendering section", - "plan": "

    Add four statements to Rendering. Inline <script> in a streamed boundary runs on parse, before the boundary is revealed, so use effects. <Sections> keeps above-the-fold sections eager, with sized skeletons below. 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.

    ", + "title": "Add notes to the Rendering page", + "plan": "

    Add four statements to Rendering. Inline <script> in a streamed boundary runs on parse, before the boundary is revealed, so use effects. <BlockList> keeps above-the-fold blocks eager, with sized skeletons below. 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.

    ", "features": [ "interactive-ui-widgets", "core-patches", @@ -1625,7 +1625,7 @@ "id": "roadmap-storefront--do-the-mechanical-port", "kind": "work", "title": "Do the mechanical port", - "text": "Hand-write the block map (27 sections, 12 loaders) with the path-shaped keys Studio needs, add the ~8-line requestToParam shim (app code), and replace Image/Picture (~20 files). The content fixes below register types into this map.", + "text": "Hand-write the block map (27 UI blocks, 12 loaders) with the path-shaped keys Studio 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", @@ -1638,7 +1638,7 @@ "id": "roadmap-storefront--fix-the-content-before-cutover", "kind": "content", "title": "Fix the content before cutover", - "text": "Rewrite the Category page path /* to /:collection, guard the PDP's seo: null and hoist SEO sections out of sections[]. Register in the block map, or remove, ~20 unknown types, including the resolved built-in that Header.json's searchbar uses. Fix the Newsletter and Header drift, and hold MIGRATION_NEXT_STEPS' *Config Props regrouping until a content validator exists.", + "text": "Rewrite the Category page path /* to /:collection, guard the PDP's seo: null and hoist SEO blocks out of sections[]. Register in the block map, or remove, ~20 unknown types, including the resolved built-in that Header.json's searchbar uses. Fix the Newsletter and Header drift, and hold MIGRATION_NEXT_STEPS' *Config Props regrouping until a content validator exists.", "features": [ "cms-page-routing", "commerce-seo-sections-in-sections", @@ -1771,13 +1771,13 @@ "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, the native sections and the session model all come before the missing pieces in the proposed API even come into play.", + "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 the native sections get rebuilt. Its code imports @faststore/core internals, and most of its hook files, and the files that use its translation hook, lack 'use client'.", + "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 @faststore/core internals, and most of its hook files, and the files that use its translation hook, lack 'use client'.", "features": [ "faststore-cli-overlay", "framework-import-surface", @@ -1790,7 +1790,7 @@ "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. There's no Headless CMS-to-.deco/blocks importer, the JSONC schemas must become TS types, and per-page-type section whitelists and singletons have no TS or Studio expression.", + "text": "The content lives in VTEX Headless CMS. There's no Headless CMS-to-.deco/blocks importer, the JSONC schemas must become TS types, and per-page-type whitelists of allowed blocks and singletons have no TS or Studio expression.", "features": [ "faststore-cli-overlay", "content-storage-and-delivery", @@ -1804,7 +1804,7 @@ "id": "roadmap-faststore--rebuild-what-faststore-core-renders", "kind": "blocker", "title": "Rebuild what @faststore/core renders", - "text": "Its native sections, including the stateful cart, search and session ones, the global sections and the 404/500 pages. No UI kit exists.", + "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", @@ -1877,13 +1877,13 @@ }, { "id": "sections", - "title": "Sections", - "description": "Section registration, the section catalog, platform-native section overrides, and lazy, deferred or viewport-gated rendering." + "title": "UI blocks", + "description": "Registering blocks that render UI, Studio's block catalog, overrides of platform-native components, and lazy, deferred or viewport-gated rendering." }, { "id": "data", "title": "Data", - "description": "Loaders, query props, section loaders, invoke/actions/BFF endpoints and search, plus site overrides of app loaders." + "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", @@ -2140,7 +2140,7 @@ "blog-tanstack", "faststore" ], - "summary": "Aliases are documented, but the filename-to-entry-name rule isn't (Studio writes every key through `encodeURIComponent`), Studio still classifies keys by their path shape, and an unregistered legacy type now fails its parent section with UNKNOWN_BLOCK.", + "summary": "Aliases are documented, but the filename-to-entry-name rule isn't (Studio writes every key through `encodeURIComponent`), Studio still classifies keys by their path shape, and an unregistered legacy type now fails its parent block with UNKNOWN_BLOCK.", "docs": [ "quickstart", "model", @@ -2156,7 +2156,7 @@ "unconfirmed_sub_claim": false }, "global-layout-sections": { - "name": "Global/shared layout sections (header, footer)", + "name": "Global/shared layout blocks (header, footer)", "category": "content-model", "status": "to-finish", "effort": "M", @@ -2187,7 +2187,7 @@ "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 section.", + "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": [ "model", "manifest", @@ -2210,7 +2210,7 @@ "storefront-tanstack", "blog-tanstack" ], - "summary": "A dangling reference used to return null; now it fails the parent section, 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.", + "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": [ "model", "standalone", @@ -2267,14 +2267,14 @@ "unconfirmed_sub_claim": false }, "nested-section-props": { - "name": "Section-typed props (nested sections)", + "name": "Block-typed props (nested blocks)", "category": "content-model", "status": "to-finish", "effort": "S", "sites": [ "storefront-tanstack" ], - "summary": "There's no documented way to type an input that takes a section so the CLI emits a section picker; today's schema uses `__SECTION_REF__`. Nested sections also resolve eagerly, with no Suspense boundary of their own.", + "summary": "There's no documented way to type an input that takes a block so the CLI emits a block picker; today's schema uses Studio's legacy `__SECTION_REF__`. Nested blocks also resolve eagerly, with no Suspense boundary of their own.", "docs": [ "model", "rendering", @@ -2346,14 +2346,14 @@ "unconfirmed_sub_claim": false }, "per-page-section-whitelists": { - "name": "Per-page-type section whitelists", + "name": "Per-page-type block whitelists", "category": "content-model", "status": "to-build", "effort": "M", "sites": [ "faststore" ], - "summary": "A sections field can't be typed as a subset of block types, because `Block` is untyped. Studio's add-section catalog is the union of all sections anyway, so a narrowed schema wouldn't restrict it.", + "summary": "A page's sections list can't be typed as a subset of block types, because `Block` is untyped. Studio's add-section catalog (its legacy name) is the union of all UI blocks anyway, so a narrowed schema wouldn't restrict it.", "docs": [ "content-types", "standalone", @@ -2366,14 +2366,14 @@ "unconfirmed_sub_claim": false }, "page-level-settings-fields": { - "name": "Page-level settings outside sections", + "name": "Page-level settings outside the block list", "category": "content-model", "status": "to-finish", "effort": "S", "sites": [ "faststore" ], - "summary": "Studio edits only a page's name, path, SEO and sections, so typed page settings get no form. Only one page type can carry the `website/pages/Page.tsx` alias.", + "summary": "Studio 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": [ "content-types", "routing", @@ -2395,7 +2395,7 @@ "blog-tanstack", "faststore" ], - "summary": "A section that references a Content-typed menu entry fails with UNKNOWN_BLOCK, which contradicts the advice to declare navigation in Content. Nothing says whether `deco schema` handles the intersections and recursion in `SiteNavigationElement`.", + "summary": "A block that references a Content-typed menu entry fails with UNKNOWN_BLOCK, which contradicts the advice to declare navigation in Content. Nothing says whether `deco schema` handles the intersections and recursion in `SiteNavigationElement`.", "docs": [ "model", "content-types", @@ -2476,7 +2476,7 @@ "blog-tanstack", "faststore" ], - "summary": "Every variant resolves, so a section Studio hides with a never rule still runs its loaders, and its `undefined` renders 'temporarily unavailable'. Device and random matchers no longer ship, and which Studio matcher paths get aliases is unstated.", + "summary": "Every variant resolves, so a block Studio hides with a never rule still runs its loaders, and its `undefined` renders 'temporarily unavailable'. Device and random matchers no longer ship, and which Studio matcher paths get aliases is unstated.", "docs": [ "variants", "studio", @@ -2488,7 +2488,7 @@ "unconfirmed_sub_claim": false }, "per-path-section-visibility": { - "name": "Per-path section visibility", + "name": "Per-path block visibility", "category": "content-model", "status": "site-code", "effort": "S", @@ -2531,7 +2531,7 @@ "unconfirmed_sub_claim": false }, "section-registration-conventions": { - "name": "Section registration and file conventions", + "name": "UI block registration and file conventions", "category": "sections", "status": "to-finish", "effort": "M", @@ -2540,7 +2540,7 @@ "blog-tanstack", "faststore" ], - "summary": "Registration works, but none of the 8 convention flags or section loaders has a home, so each site rebuilds the ~737-line DecoPageRenderer. There's no descriptor type, and `getRequest()` gives URL-dependent loaders the wrong URL on SPA navigation.", + "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. There's no descriptor type, and `getRequest()` gives URL-dependent loaders the wrong URL on SPA navigation.", "docs": [ "manifest", "tanstack-data", @@ -2558,7 +2558,7 @@ "unconfirmed_sub_claim": false }, "section-catalog": { - "name": "Section catalog exposed to editors", + "name": "Block catalog exposed to editors (Studio's section catalog)", "category": "sections", "status": "to-finish", "effort": "M", @@ -2567,7 +2567,7 @@ "blog-tanstack", "faststore" ], - "summary": "The catalog works for path-keyed sections, 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.", + "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", "schema", @@ -2582,14 +2582,14 @@ "unconfirmed_sub_claim": false }, "native-section-overrides": { - "name": "Overrides of platform-native sections", + "name": "Overrides of platform-native components", "category": "sections", "status": "to-finish", "effort": "M", "sites": [ "faststore" ], - "summary": "Overrides collapse into plain composition, but the core sections, 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.", + "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": [ "manifest", "rendering", @@ -2604,14 +2604,14 @@ "unconfirmed_sub_claim": false }, "native-platform-sections": { - "name": "Native platform sections used via schema only", + "name": "Native platform components used via schema only", "category": "sections", "status": "to-build", "effort": "L", "sites": [ "faststore" ], - "summary": "Nothing in the next major renders a section without site code, and UI stays out of `@decocms/apps`. Replacing the core sections, the stateful ones included, is real engineering, and FastStore's global-section and 404/500 content types have no layout pattern.", + "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": [ "decisions", "standalone", @@ -2624,7 +2624,7 @@ "unconfirmed_sub_claim": false }, "code-composed-pdp": { - "name": "Code-composed monolithic PDP section", + "name": "Code-composed monolithic PDP block", "category": "sections", "status": "to-finish", "effort": "M", @@ -2653,7 +2653,7 @@ "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, and a Content-typed card-config entry nested in a section fails with UNKNOWN_BLOCK.", + "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, and a Content-typed card-config entry nested in a block fails with UNKNOWN_BLOCK.", "docs": [ "manifest", "model", @@ -2668,7 +2668,7 @@ "unconfirmed_sub_claim": false }, "lazy-deferred-sections": { - "name": "Lazy / deferred / viewport-gated sections", + "name": "Lazy / deferred / viewport-gated blocks", "category": "sections", "status": "to-finish", "effort": "L", @@ -2677,7 +2677,7 @@ "blog-tanstack", "faststore" ], - "summary": "A wrapper's `section` input resolves fully before Lazy runs, so Lazy defers nothing (43 wrappers on the storefront). Viewport-gated second hops need a revision the client doesn't expose, and today's per-prop deferral has no counterpart.", + "summary": "A legacy Lazy wrapper's `section` input resolves fully before Lazy runs, so Lazy defers nothing (43 wrappers on the storefront). Viewport-gated second hops need a revision the client doesn't expose, and today's per-prop deferral has no counterpart.", "docs": [ "manifest", "rendering", @@ -2694,7 +2694,7 @@ "unconfirmed_sub_claim": false }, "section-loaders": { - "name": "Section loaders (server-side prop enrichment)", + "name": "Prop-enrichment loaders (legacy section loaders)", "category": "data", "status": "to-finish", "effort": "M", @@ -2744,7 +2744,7 @@ "unconfirmed_sub_claim": true }, "inline-loader-props": { - "name": "Loaders and query descriptors in section props", + "name": "Loaders and query descriptors in block props", "category": "data", "status": "to-finish", "effort": "L", @@ -3319,7 +3319,7 @@ "unconfirmed_sub_claim": false }, "commerce-seo-sections-in-sections": { - "name": "Commerce SEO sections inside page sections", + "name": "Commerce SEO blocks inside a page's block list", "category": "seo", "status": "to-finish", "effort": "M", @@ -3342,14 +3342,14 @@ "unconfirmed_sub_claim": false }, "head-tags-from-sections": { - "name": "Head tags injected from sections", + "name": "Head tags injected from blocks", "category": "seo", "status": "site-code", "effort": "S", "sites": [ "faststore" ], - "summary": "React 19 hoisting lets a section emit head tags, but the per-section Suspense pattern defeats in-section LCP preloads, and nothing says so. Preload from the page component instead.", + "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", "architecture", @@ -3639,7 +3639,7 @@ "storefront-tanstack", "blog-tanstack" ], - "summary": "`createCMS` plus a block map replace registration, but nothing generates the map (27 sections and 12 loaders by hand on the storefront), bundled content never matches the API hash, and there's no lenient mode or Studio-shaped schema.", + "summary": "`createCMS` plus a block map replace registration, but nothing generates the map (27 UI blocks and 12 loaders by hand on the storefront), bundled content never matches the API hash, and there's no lenient mode or Studio-shaped schema.", "docs": [ "quickstart", "manifest", @@ -3667,7 +3667,7 @@ "blog-tanstack", "faststore" ], - "summary": "Today the binding resolves the site's theme, globals and page sections into every page. In the next major, SEO-default merging and global-section injection move to app code, there's no singleton concept, and `site/apps/site.ts` isn't in the alias bridge.", + "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": [ "content-types", "schema", @@ -3939,7 +3939,7 @@ "blog-tanstack", "faststore" ], - "summary": "The document shell is app code, but the Studio bridge and the section marker it needs are neither shipped nor documented. Dropping the DECO.events bootstrap also silently breaks OneDollarStats.", + "summary": "The document shell is app code, but the Studio 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-data", "nextjs", @@ -3984,7 +3984,7 @@ "storefront-tanstack", "blog-tanstack" ], - "summary": "The guide configs build, but nothing replaces decoVitePlugin's dev and Studio duties: schema regeneration, the Studio tunnel, the build hash and section preloads. Next also needs transpilePackages and file-tracing notes.", + "summary": "The guide configs build, but nothing replaces decoVitePlugin's dev and Studio duties: schema regeneration, the Studio tunnel, the build hash and block preloads. Next also needs transpilePackages and file-tracing notes.", "docs": [ "architecture", "tanstack-data", diff --git a/site/src/content.html b/site/src/content.html index 3b76387d..501b1929 100644 --- a/site/src/content.html +++ b/site/src/content.html @@ -26,13 +26,13 @@

    Key terms

    These terms come up on every page. Each is explained again where it's first used.

    -
    Block function
    One of your functions with a typed first parameter, registered in a block map. See Quickstart.
    +
    Block function
    One of your functions with a typed first parameter, registered in a block map. It can return anything: data, a boolean, a descriptor or JSX. The framework treats them all the same. See Quickstart.
    Block map
    A plain object of your block functions, such as { experiments }. See Quickstart.
    Block type
    A key in the block map: the name content uses to call that function.
    Block
    A piece of JSON whose __resolveType names a block type (or a saved entry) and whose other keys are inputs. People sometimes call the function a block too. In these docs, the function is a block function, and the JSON that calls it is a block. See Blocks.
    -
    Section
    A block whose function renders one stacked part of a page, such as a hero, a product shelf or a footer. A page is mostly an ordered list of sections that editors add, remove and reorder. See Blocks.
    Saved entry
    A block saved under a name: one JSON file in .deco/blocks, whose file name is the entry name. See Content and loaders.
    Content map
    All saved entries, keyed by name.
    +
    Page
    A saved entry with a path, SEO and an ordered list of blocks (its sections field) that editors add, remove and reorder. See Routes and redirects.
    Loader
    The small object that supplies the content map: its load() returns { revision, blocks }. See Content and loaders.
    Revision
    The ID of one exact content map, released or not, usually a hash of its contents. Change any entry and you get a new revision.
    Release
    A published content map. With the hosted Deco CMS, each commit to the production branch becomes one. A release never changes, and its revision identifies it. See Releases and deployment.
    @@ -43,11 +43,11 @@

    Key terms

    Authoring: from types to an editor

    -
    Your TypeScriptFunctions with typed inputs: page sections and data fetchers alike
    +
    Your TypeScriptFunctions with typed inputs: UI and data fetchers alike
    CLI → JSON Schemadeco schema writes .deco/schema.json
    StudioForms and previews for editors, built from the schema
    -

    You write functions with typed inputs, for example a hero section that takes a title and an image. The CLI turns those types into a schema, and Studio turns the schema into forms. Editors, and AI agents, save content as JSON files in .deco/blocks, in your repository.

    +

    You write functions with typed inputs, for example a hero block that takes a title and an image. The CLI turns those types into a schema, and Studio turns the schema into forms. Editors, and AI agents, save content as JSON files in .deco/blocks, in your repository.

    Delivery: from files to your app

    @@ -108,7 +108,7 @@

    Small on purpose

    experiments({ newCheckout: 10, stickyHeader: 50, freeShippingBanner: 0 }); // e.g. { newCheckout: false, stickyHeader: true, freeShippingBanner: false }

    The split is hardcoded at the call site. Ramping the new checkout from 10% to 25% means a pull request and a deploy. In this guide you'll move those numbers into content: a JSON file that you, an AI agent, or an editor in Deco Studio can change, while the function stays exactly as it is. It runs in plain Node, with no framework.

    -

    This function doesn't render anything; it returns flags. Functions that render part of a page are called sections. They work the same way, and Blocks shows how.

    +

    This function doesn't render anything; it returns flags. A block function can just as well return JSX, and Blocks shows how.

    Prerequisites:

    • Node.js 24 or later, and tsx to run TypeScript.
    • @@ -174,7 +174,7 @@

      4. Read the content from your code

      Next steps

        -
      • Blocks: how blocks nest and refer to each other, which is how a page is built from sections (blocks that render part of a page).
      • +
      • Blocks: how blocks nest and refer to each other, which is how a page is built from a list of blocks.
      • Content and loaders: where saved entries come from, and how to read them from somewhere other than .deco/blocks.
      • Schema generation: how your types become editor forms, and the JSDoc tags that shape them.
      • Preview: render drafts in your real pages, no deploy.
      • @@ -214,7 +214,7 @@

        A block is that composition, stored as data

        title: "Summer collection", product: catalogProduct({ slug: "summer-shirt" }), }) -

        Now an editor can change the title or the slug in Studio, and an agent can even point product at a different function, and the composition changes without a deploy. The functions are whatever your block map registers, and each one's input type is what deco schema turns into the editor form. Use the same model for anything your site needs: UI sections, product data, SEO, navigation, campaign settings. The CMS doesn't reserve any directories or categories.

        +

        Now an editor can change the title or the slug in Studio, and an agent can even point product at a different function, and the composition changes without a deploy. The functions are whatever your block map registers, and each one's input type is what deco schema turns into the editor form. Use the same model for anything your site needs: UI, product data, SEO, navigation, campaign settings. The CMS doesn't reserve any directories or categories.

        Name a composition, reuse it

        In code, you'd give a composition you use often a name and reuse it. Saved entries do that. Alongside the block map of functions, the CMS reads a second map, the content map, where each name holds a piece of saved JSON. { "__resolveType": "SummerSEO" } means whatever is saved under the name SummerSEO, written in place. The map can come from files in your repository, the hosted Deco CMS, or your own store; see Content and loaders.

        @@ -229,11 +229,11 @@

        Name a composition, reuse it

        seo({ title: "Summer sale", description: "Light layers for long days." })
        • Overrides are shallow. The merge is { ...entry, ...inputs }, so an override replaces the whole top-level property, and the saved entry itself doesn't change. It isn't currying or partial application: the reference is expanded into one complete call.
        • -
        • An entry doesn't have to be a block. It can be any JSON that contains blocks, like a page with a list of sections: an object literal with calls inside. Resolving it calls each block inside and leaves the rest of the JSON as it is.
        • +
        • An entry doesn't have to be a block. It can be any JSON that contains blocks, like a page with a list of blocks: an object literal with calls inside. Resolving it calls each block inside and leaves the rest of the JSON as it is.

        Resolving a page, step by step

        -

        Resolving a block is evaluating its composition: named entries expand, then calls run from the inside out. Here's a page saved as pages-summer. It uses the SummerSEO entry with an override, and one carousel section: a block whose function renders one stacked part of a page (a hero, a product shelf, a footer). A page is mostly an ordered list of sections that editors add, remove and reorder. The block map has two functions. seo returns its inputs; that's still worth registering, because it gives editors a form for SEO and lets them save it as a reusable entry. carousel returns a descriptor, { component, props }: a plain object that says which component to render with which props, for your app to render later. A block function can also return JSX directly; Rendering compares the two.

        +

        Resolving a block is evaluating its composition: named entries expand, then calls run from the inside out. Here's a page saved as pages-summer. It uses the SummerSEO entry with an override, and one carousel block in its sections list. A page is mostly an ordered list of blocks that editors add, remove and reorder. The block map has two functions. seo returns its inputs; that's still worth registering, because it gives editors a form for SEO and lets them save it as a reusable entry. carousel returns a descriptor, { component, props }: a plain object that says which component to render with which props, for your app to render later. A block function can also return JSX directly; Rendering compares the two.

        // Saved as pages-summer
         {
           "seo": { "__resolveType": "SummerSEO", "title": "Summer sale" },
        @@ -484,30 +484,30 @@ 

        Connect Studio to your site

        The CMS is pure. It passes your function the block and nothing else: no context object, no request, no hidden state. When a block needs the current request, a cookie, or a route parameter, read it the way the rest of your app does, usually through request-scoped storage your framework already provides (Node's AsyncLocalStorage, which next/headers and TanStack Start's getRequest() use under the hood). That keeps block functions ordinary functions you can call and test directly.

        Lazy loading

        -

        The map above imports every block at the top, so they all load with the map. That's fine for functions like seo. It's not what you want for a section that drags in a charting library, or for the two hundred sections a large store has when any page uses five.

        +

        The map above imports every block at the top, so they all load with the map. That's fine for functions like seo. It's not what you want for a component that drags in a charting library, or for the two hundred components a large store has when any page uses five.

        What's heavy is the component a block renders, and your framework already knows how to load components lazily. So do it in one place, the map, and let the block files stay plain components:

        blocks.tsx (Next.js)
        import dynamic from 'next/dynamic'
         import type { Blocks } from '@decocms/blocks'
         import seo from './seo'
        -import type { ProductProps } from './sections/Product'
        -import type { HeroProps } from './sections/Hero'
        +import type { ProductProps } from './components/Product'
        +import type { HeroProps } from './components/Hero'
         
        -// Each section is split into its own chunk and fetched on first render.
        -const Product = dynamic(() => import('./sections/Product'))
        -const Hero    = dynamic(() => import('./sections/Hero'))
        +// Each component is split into its own chunk and fetched on first render.
        +const Product = dynamic(() => import('./components/Product'))
        +const Hero    = dynamic(() => import('./components/Hero'))
         
         export default {
           seo,                                                    // eager: tiny, used everywhere
           product: (input: ProductProps) => <Product {...input} />,
           hero:    (input: HeroProps)    => <Hero {...input} />,
         } satisfies Blocks
        -

        With TanStack Start, use React's lazy instead of dynamic. Where it goes depends on which of the two rendering modes you use (see Rendering), and the TanStack guides show the code. In every case the section files know nothing about the CMS, the map stays a plain object the CLI can read, and the bundler splits on the import().

        +

        With TanStack Start, use React's lazy instead of dynamic. Where it goes depends on which of the two rendering modes you use (see Rendering), and the TanStack guides show the code. In every case the component files know nothing about the CMS, the map stays a plain object the CLI can read, and the bundler splits on the import().

        For a block that's heavy for a non-UI reason, say a data function that pulls in a large SDK, a dynamic import inside the function body is enough. It's one line and needs no helper:

        "catalog-product": async (input: ProductQuery) => {
           const { fetchProduct } = await import("./commerce")   // loaded on first call; the JS module system caches it after that
           return fetchProduct(input)
         },
        -

        Keep import paths literal in both cases. A bundler can split import("./sections/Product") into its own chunk; it can't split import(`./sections/${name}`).

        +

        Keep import paths literal in both cases. A bundler can split import("./components/Product") into its own chunk; it can't split import(`./components/${name}`).

        Choose type names that last

        A block type's name is what content stores in __resolveType to call it, so pick it like a database column, not like a file path: short (seo, hero) or namespaced when a site has many (commerce/product). Moving a file only means updating an import; the name doesn't change. Renaming a type means migrating every block that uses it, or adding an alias, a second name for the same block type (see Renaming and migrating). Type names share one namespace with saved entry names (see Blocks), and deco schema fails on a clash. A simple convention avoids clashes: lowercase or kebab-case for block types (seo, product-card), capitalized names for entries (SummerSEO).

        @@ -523,7 +523,7 @@

        The three matchers that ship

        import { always, never, date } from "@decocms/blocks/matchers";
         
         // always()                              => true    the default rule of a variant
        -// never()                               => false   Studio hides a section by wrapping it in a variant whose rule is never()
        +// never()                               => false   Studio hides a block by wrapping it in a variant whose rule is never()
         // date({ start?: string; end?: string }) => boolean, true when now is inside [start, end]

        start and end are ISO 8601 strings. A date without a time, like "2026-11-30", means midnight UTC, so add a time and offset ("2026-11-30T23:59:59-03:00") to end on your own clock.

        Everything else that could gate content, a device, a cookie, a country, a user's plan, depends on where your app runs and how it sees its environment. Those are yours to write, below.

        @@ -558,7 +558,7 @@

        The multivariate is three lines you own

        ], }), })
        -

        Resolution does the rest: each rule runs and yields a boolean, multivariate runs with those booleans and yields a string, hero runs with a plain title. Nothing in hero knows a variant was involved. The same shape wraps a whole section, or a page's whole sections array, which is how Studio does page-level variants.

        +

        Resolution does the rest: each rule runs and yields a boolean, multivariate runs with those booleans and yields a string, hero runs with a plain title. Nothing in hero knows a variant was involved. The same shape wraps any block in a page, or the page's whole sections list, which is how Studio does page-level variants.

        Write your own matcher

        A matcher is any block function that returns a boolean. It reads whatever your app knows about the current request or user the way every other block does, through the context your runtime already provides:

        @@ -591,9 +591,9 @@

        Every variant resolves

        ], }) // the values are entry names, not calls: only the rules and multivariate run -// section is the JSON above, e.g. from a page's sections array; +// block is the JSON above, e.g. from a page's sections list; // client.resolve accepts an entry name or an inline block -const [name, err] = await client.resolve(section); // "BlackFridayHero" +const [name, err] = await client.resolve(block); // "BlackFridayHero" if (err) throw err; const [hero] = await client.resolve(name); // only this variant ran

        A variant is not access control. It chooses between values that are all fine to compute and show. 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.

        @@ -618,10 +618,10 @@

        Every variant resolves

        to: string; permanent: boolean; // 301 or 302 }
        -

        A page is a route with SEO and a list of sections. It's not special to the router; it's just the first type that extends Route. It's shaped like the pages-summer entry in Blocks, with three additions: "__resolveType": "page", so the CMS can list it by type, and a name and path, so the router can find it. Like every data-only type, it's declared in Content, not in the block map:

        +

        A page is a route with SEO and a list of blocks (its sections field). It's not special to the router; it's just the first type that extends Route. It's shaped like the pages-summer entry in Blocks, with three additions: "__resolveType": "page", so the CMS can list it by type, and a name and path, so the router can find it. Like every data-only type, it's declared in Content, not in the block map:

        interface Page extends Route {
           seo: PageSeo;                                     // an SEO block, or a reference to a saved one
        -  sections: VariantOf<Array<VariantOf<Block>>>;   // a list of section blocks; the whole list, or any one section, can be a variant
        +  sections: VariantOf<Array<VariantOf<Block>>>;   // a list of blocks; the whole list, or any one block, can be a variant
         }

        PageSeo is your SEO input type. Block is a block as stored: JSON whose __resolveType names a function or a saved entry. VariantOf<T> accepts a T or a multivariate block that picks one.

        Saved as SummerPage
        {
        @@ -664,9 +664,9 @@ 

        Your own routable types

        Two entries with the same path, or two templates that can match the same URL, are rejected by the CLI and by matchRoute (see Path templates and match order).

        Pages are data; resolve their fields

        -

        A page runs no function, so you never resolve the page itself. Read it, then resolve the blocks inside it: client.resolve(page.seo), and one client.resolve(section) per section. Separate calls let each section render as soon as its own data is ready (see Rendering). A redirect is never resolved; matchRoute only reads it.

        +

        A page runs no function, so you never resolve the page itself. Read it, then resolve the blocks inside it: client.resolve(page.seo), and one client.resolve(block) per block in page.sections. Separate calls let each block render as soon as its own data is ready (see Rendering). A redirect is never resolved; matchRoute only reads it.

        client.resolve("SummerPage") and client.list("page", { run: true }) fail with UNKNOWN_BLOCK, because page names no function, and the CMS never sees Content (see the rule). To read a page, list it, or pass { run: false }.

        -

        Running code for a whole page. If you want code to run for a whole page, such as validation or a layout step, add a page function to the block map and move Page out of Content. Then client.resolve(page) runs it after every section has resolved.

        +

        Running code for a whole page. If you want code to run for a whole page, such as validation or a layout step, add a page function to the block map and move Page out of Content. Then client.resolve(page) runs it after every block in the page has resolved.

        Content types without a URL

        Navigation, email templates, and campaign settings work the same way, minus Route: declare the type in Content if it's data, or register a function if something should run, and save entries of that type.

        @@ -698,9 +698,9 @@

        Block functions run on the server

        Errors, streaming, and cancellation

          -
        • Start every section first. Await critical data, such as SEO, only after all sections have started, so sections fetch in parallel instead of waiting behind the SEO call.
        • -
        • One Suspense boundary per section lets each section appear as soon as it's ready.
        • -
        • Suspense handles loading, not errors. Each client.resolve(section) returns [value, error]; render a fallback when error is set. Errors thrown later while React renders need an error boundary.
        • +
        • 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) returns [value, error]; render a fallback when error is set. Errors thrown later while React renders need an error boundary.
        • Cancellation is your app's job. The 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 result) before you stream.
        @@ -711,20 +711,20 @@

        Errors, streaming, and cancellation

        The CMS doesn't route. It lists and resolves. Routing is three steps in your request handler: list the types you want at URLs, hand them to matchRoute, resolve the winner. Do all three with one client, so they see the same content even if editors publish mid-request (see One revision per response).

        The example used here and in the guides

        This page and the framework guides share these files: the model in src/model.ts, the post type in src/post.ts (from Your own routable types), and two content entries, stored as files in .deco/blocks, where the default loader reads them (see Content and loaders). Each guide's block map (src/blocks.tsx or src/blocks.ts) declares seo, its own product, and Content, the map of data-only types from Schema generation.

        -

        In src/model.ts, SectionData is the descriptor a product block returns in data mode (see Rendering), and StoredPage is a page exactly as saved, before any of its blocks run.

        +

        In src/model.ts, ProductDescriptor is the descriptor a product block returns in data mode (see Rendering), and StoredPage is a page exactly as saved, before any of its blocks run.

        src/model.ts
        import type { Block, Route } from '@decocms/blocks'
         
         export interface SEO { title: string; description: string }
         export interface ProductProps { id: string; name: string; price: number; currency: string }
        -export interface SectionData { component: 'product'; props: ProductProps }
        +export interface ProductDescriptor { component: 'product'; props: ProductProps }
         
         // A saved page, as client.list returns it by default (nothing run).
        -// This example keeps sections a plain array: no variant around the whole list.
        +// This example keeps the sections list a plain array: no variant around the whole list.
         export interface StoredPage extends Route {
           seo: SEO | Block
           sections: Block[]
         }
        -

        StoredPage is simpler than the Page type in Routes and redirects: it doesn't allow a variant around the whole sections list. If yours does, resolve the list first, const [list] = await client.resolve(page.sections), which picks the variant and also runs every section in it, or store the variant's values as entry names as in Every variant resolves.

        +

        StoredPage is simpler than the Page type in Routes and redirects: it doesn't allow a variant around the whole sections list. If yours does, resolve the list first, const [list] = await client.resolve(page.sections), which picks the variant and also runs every block in it, or store the variant's values as entry names as in Every variant resolves.

        .deco/blocks/ProductPage.json
        {
           "__resolveType": "page",
           "name": "Product page",
        @@ -780,9 +780,9 @@ 

        Route a request

        case "match": switch (match.entry.__resolveType) { case "page": { - const sections = match.entry.sections.map((s) => client.resolve(s)); // start them all + const blocks = match.entry.sections.map((b) => client.resolve(b)); // start them all const [seo] = await client.resolve(match.entry.seo); // then await - return render(seo, sections); + return render(seo, blocks); } case "post": return renderPost(match.entry); // data: nothing to resolve @@ -821,7 +821,7 @@

        Linking and listing

        Everything on this page is exported from @decocms/blocks. The built-in matchers (always, never, date) are in @decocms/blocks/matchers.

        createCMS(config)

        -

        createCMS returns a CMS: your block map plus a loader. Create it once, at module scope. For each request, ask it for a client: forRelease() for visitors, forDraft(pointer) for a preview. A client loads one revision on first use and reads only that revision afterwards, so everything in one response agrees. Every later call, list or resolve, reuses that content, so sections can stream as separate calls; the next request's client sees the next revision, so don't share a client across requests. A client also remembers what it has already resolved: an entry referenced three times runs its function once per client. That memory is tied to the client and to the block map object (results are kept per function of the map you passed), so a map rebuilt inside your handler starts with an empty cache; build the map once at module scope. Clients are cheap to create, because the loader caches the content.

        +

        createCMS returns a CMS: your block map plus a loader. Create it once, at module scope. For each request, ask it for a client: forRelease() for visitors, forDraft(pointer) for a preview. A client loads one revision on first use and reads only that revision afterwards, so everything in one response agrees. Every later call, list or resolve, reuses that content, so a page's blocks can stream as separate calls; the next request's client sees the next revision, so don't share a client across requests. A client also remembers what it has already resolved: an entry referenced three times runs its function once per client. That memory is tied to the client and to the block map object (results are kept per function of the map you passed), so a map rebuilt inside your handler starts with an empty cache; build the map once at module scope. Clients are cheap to create, because the loader caches the content.

        function createCMS(config: {
           loader?:                               // optional: defaults to "./.deco"
             | string                             // a content directory: localLoader(path)
        @@ -894,7 +894,7 @@ 

        client.resolve(target, options?)

        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 entry, with references expanded and nothing run resolve({ __resolveType: "seo", title: "Sale" })The result for an inline block: seo({ title: "Sale" }) - resolve(page.sections)An array of results, one per section + resolve(page.sections)An array of results, one per block in the list resolve({ title: "Store" })The same object: it contains no blocks
    @@ -979,12 +979,12 @@

    Errors

    Framework guides

    Next.js App Router

    -

    In this guide, you'll render CMS pages with Server Components, stream each section under its own Suspense boundary, and generate metadata from the page's SEO block.

    -

    Each of a page's sections resolves on its own, so a slow section never holds up the rest. Editors also choose page URLs in Studio, so your app can't know its routes ahead of time. Instead, one catch-all route receives every URL, asks matchRoute which page or redirect owns it, and renders that page's sections.

    +

    In this guide, you'll render CMS pages with Server Components, stream each of a page's blocks under its own Suspense boundary, and generate metadata from the page's SEO block.

    +

    Each of a page's blocks resolves on its own, so a slow block never holds up the rest. Editors also choose page URLs in Studio, so your app can't know its routes ahead of time. Instead, one catch-all route receives every URL, asks matchRoute which page or redirect owns it, and renders that page's blocks.

    Prerequisites: an App Router app on the Node runtime (the default loader reads .deco from disk, which the Edge runtime can't), with a root layout; npm install @decocms/blocks server-only and the CLI from Quickstart; and the example project: src/model.ts plus the entries in .deco/blocks at the project root.

    1. Add the components

    -

    Product is the component a product section renders. CartButton is a Client Component with state, there to show that interactive components keep working inside sections the CMS renders on the server. Neither knows about the CMS.

    +

    Product is the component the product block renders. CartButton is a Client Component with state, there to show that interactive components keep working inside blocks the CMS renders on the server. Neither knows about the CMS.

    src/CartButton.tsx
    'use client'
     import { useState } from 'react'
     
    @@ -1062,8 +1062,8 @@ 

    4. Keep preview working across pages

    }

    5. Match the URL to a page

    -

    openPage lists pages and redirects, matches the pathname, and starts resolving every section without waiting for any of them. Listing is cheap: the loader holds the content in memory. Section keys include the pathname, so React mounts fresh Suspense boundaries when you navigate.

    -

    generateMetadata and the page component both need the page. Wrap the lookup in React's cache so they share one client, one match, and the same section promises.

    +

    openPage lists pages and redirects, matches the pathname, and starts resolving every block without waiting for any of them. Listing is cheap: the loader holds the content in memory. Block keys include the pathname, so React mounts fresh Suspense boundaries when you navigate.

    +

    generateMetadata and the page component both need the page. Wrap the lookup in React's cache so they share one client, one match, and the same block promises.

    src/open-page.server.ts
    import 'server-only'
     import { cache, type ReactNode } from 'react'
     import { notFound, permanentRedirect, redirect } from 'next/navigation'
    @@ -1087,9 +1087,9 @@ 

    5. Match the URL to a page

    const page = match.entry return { - sections: page.sections.map((section, index) => ({ + blocks: page.sections.map((block, index) => ({ key: `${pathname}:${index}`, - result: c.resolve<ReactNode>(section), + result: c.resolve<ReactNode>(block), })), seo: c.resolve<SEO>(page.seo), } @@ -1099,7 +1099,7 @@

    5. Match the URL to a page

    '/' + segments.map(encodeURIComponent).join('/')

    6. Render the catch-all route

    -

    Each section is a promise of a Result, [value, error]. Section awaits it inside its own Suspense boundary and shows a fallback if that one section failed.

    +

    Each block is a promise of a Result, [value, error]. BlockSlot awaits it inside its own Suspense boundary and shows a fallback if that one block failed.

    src/app/[[...path]]/page.tsx
    import { Suspense, type ReactNode } from 'react'
     import type { Metadata } from 'next'
     import type { Result } from '@decocms/blocks'
    @@ -1114,11 +1114,11 @@ 

    6. Render the catch-all route

    return { title: seo.title, description: seo.description } } -async function Section({ result }: { result: Promise<Result<ReactNode>> }) { +async function BlockSlot({ result }: { result: Promise<Result<ReactNode>> }) { const [node, error] = await result if (error) { console.error(error) - return <p role="status">This section is temporarily unavailable.</p> + return <p role="status">This content is temporarily unavailable.</p> } return node } @@ -1127,9 +1127,9 @@

    6. Render the catch-all route

    const page = await openPage(pathnameFor((await params).path)) return ( <main> - {page.sections.map((section) => ( - <Suspense key={section.key} fallback={<p>Loading section…</p>}> - <Section result={section.result} /> + {page.blocks.map((block) => ( + <Suspense key={block.key} fallback={<p>Loading…</p>}> + <BlockSlot result={block.result} /> </Suspense> ))} </main> @@ -1151,11 +1151,11 @@

    7. List another content type

    if (match.entry.__resolveType === 'post') { const post = match.entry as Post const seo: Result<SEO> = [{ title: post.name, description: '' }, null] - return { post, sections: [], seo: Promise.resolve(seo) } + return { post, blocks: [], seo: Promise.resolve(seo) } } const page = match.entry as StoredPage - return { post: null, sections: …, seo: … } // sections and seo exactly as before
    -

    In Page, render the post when page.post is set (for example <article><h1>{page.post.name}</h1>…</article>), and the sections otherwise. generateMetadata keeps working unchanged.

    + return { post: null, blocks: …, seo: … } // blocks and seo exactly as before
    +

    In Page, render the post when page.post is set (for example <article><h1>{page.post.name}</h1>…</article>), and the page's blocks otherwise. generateMetadata keeps working unchanged.

    A blog index needs no page entry. List the post type; each entry carries its own path:

    src/app/blog/page.tsx
    import Link from 'next/link'
     import { client } from '../../client.server'
    @@ -1185,17 +1185,17 @@ 

    How it works

    Framework guides

    TanStack Start with data descriptors

    -

    There are two ways to render with TanStack Start: this guide, which works with any React setup, and the experimental RSC guide; Which mode to use compares them. Here, block functions don't return JSX. They return a descriptor, a plain object such as { component: 'product', props } (see Rendering). Start sends a route's loader data to the browser as serialized data, and JSX can't make that trip, but a descriptor can. (Here "loader" is TanStack Router's route loader, the function that fetches a route's data. It isn't a content loader from Content and loaders.) In the browser, a view registry (const views = { product: Product }) maps each name to a component. Start streams each section's descriptor as it resolves, renders the views on the server, and hydrates them in the browser. It deploys to Cloudflare Workers.

    -

    Editors choose page URLs in Studio, so your app can't know its routes ahead of time. One catch-all route receives every URL, asks matchRoute which page or redirect owns it, and renders that page's sections.

    +

    There are two ways to render with TanStack Start: this guide, which works with any React setup, and the experimental RSC guide; Which mode to use compares them. Here, block functions don't return JSX. They return a descriptor, a plain object such as { component: 'product', props } (see Rendering). Start sends a route's loader data to the browser as serialized data, and JSX can't make that trip, but a descriptor can. (Here "loader" is TanStack Router's route loader, the function that fetches a route's data. It isn't a content loader from Content and loaders.) In the browser, a view registry (const views = { product: Product }) maps each name to a component. Start streams each block's descriptor as it resolves, renders the views on the server, and hydrates them in the browser. It deploys to Cloudflare Workers.

    +

    Editors choose page URLs in Studio, so your app can't know its routes ahead of time. One catch-all route receives every URL, asks matchRoute which page or redirect owns it, and renders that page's blocks.

    Prerequisites: a TanStack Start app (see the Start quick start); npm install @decocms/blocks zod and npm install -D @cloudflare/vite-plugin wrangler, plus the CLI from Quickstart; the example project; and Product and CartButton from step 1 of the Next.js guide. Enable resolveJsonModule in tsconfig.json so you can import the content JSON.

    1. Return descriptors from blocks

    src/blocks.ts
    import type { Blocks, Redirect } from '@decocms/blocks'
    -import type { ProductProps, SectionData, SEO, StoredPage } from './model'
    +import type { ProductDescriptor, ProductProps, SEO, StoredPage } from './model'
     
     export default {
       seo: (input: SEO) => input,
    -  product: (input: ProductProps): SectionData => ({ component: 'product', props: input }),
    +  product: (input: ProductProps): ProductDescriptor => ({ component: 'product', props: input }),
     } satisfies Blocks
     
     export type Content = { page: StoredPage; redirect: Redirect }
    @@ -1235,7 +1235,7 @@

    2. Create the CMS

    }

    3. Match the URL to a page

    -

    This helper is shared by both TanStack guides. T is what your product block returns: SectionData here, ReactNode in the RSC guide, which reuses this file unchanged. The server function hands the incoming request to client(), and draftPointer finds the pointer in the URL or in the cookie from step 6. Each section promise resolves to the value, or to null if the section failed, so no internal error details reach the browser.

    +

    This helper is shared by both TanStack guides. T is what your product block returns: ProductDescriptor here, ReactNode in the RSC guide, which reuses this file unchanged. The server function hands the incoming request to client(), and draftPointer finds the pointer in the URL or in the cookie from step 6. Each block promise resolves to the value, or to null if the block failed, so no internal error details reach the browser.

    src/open-page.server.ts
    import { notFound, redirect } from '@tanstack/react-router'
     import { matchRoute, type Redirect } from '@decocms/blocks'
     import { client } from './cms'
    @@ -1253,18 +1253,18 @@ 

    3. Match the URL to a page

    if (match.kind === 'redirect') throw redirect({ href: match.location, statusCode: match.status }) const page = match.entry - const sections = page.sections.map((section, index) => ({ + const blocks = page.sections.map((block, index) => ({ key: `${href}:${index}`, - value: c.resolve<T>(section).then(([value, sectionError]) => { - if (sectionError) console.error(sectionError) + value: c.resolve<T>(block).then(([value, blockError]) => { + if (blockError) console.error(blockError) return value }), })) - // Every section has started before SEO is awaited. + // Every block has started before SEO is awaited. const [seo, seoError] = await c.resolve<SEO>(page.seo) if (seoError) throw seoError - return { seo, sections } + return { seo, blocks } }

    4. Expose it through a server function

    @@ -1273,11 +1273,11 @@

    4. Expose it through a server function

    import { getRequest } from '@tanstack/react-start/server' import { z } from 'zod' import { openPage } from './open-page.server' -import type { SectionData } from './model' +import type { ProductDescriptor } from './model' export const loadPage = createServerFn({ method: 'GET' }) .inputValidator(z.object({ href: z.string().startsWith('/') })) - .handler(({ data }) => openPage<SectionData>(data.href, getRequest()))
    + .handler(({ data }) => openPage<ProductDescriptor>(data.href, getRequest()))

    5. Render the catch-all route

    src/routes/$.tsx
    import { Suspense } from 'react'
    @@ -1303,11 +1303,11 @@ 

    5. Render the catch-all route

    const page = Route.useLoaderData() return ( <main> - {page.sections.map((section) => ( - <Suspense key={section.key} fallback={<p>Loading section…</p>}> - <Await promise={section.value}> + {page.blocks.map((block) => ( + <Suspense key={block.key} fallback={<p>Loading…</p>}> + <Await promise={block.value}> {(data) => { - if (!data) return <p role="status">This section is temporarily unavailable.</p> + if (!data) return <p role="status">This content is temporarily unavailable.</p> const View = views[data.component] return <View {...data.props} /> }} @@ -1317,7 +1317,7 @@

    5. Render the catch-all route

    </main> ) }
    -

    Keep section 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 section.

    +

    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.

    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.
    • @@ -1381,7 +1381,7 @@

      Deploying checklist

      1. Swap the product block

      Replace src/blocks.ts with the Next.js guide's src/blocks.tsx. Keep src/cms.ts, src/open-page.server.ts and src/start.ts (the preview cookie) from the data guide as they are. Update the schema command's --entry to ./src/blocks.tsx.

      -

      2. Render sections on the server

      +

      2. Render blocks on the server

      Replace src/page.functions.ts with this file. It's .tsx now, because it contains JSX.

      src/page.functions.tsx
      import { Suspense, type ReactNode } from 'react'
       import { createServerFn } from '@tanstack/react-start'
      @@ -1390,8 +1390,8 @@ 

      2. Render sections on the server

      import { z } from 'zod' import { openPage } from './open-page.server' -async function Section({ value }: { value: Promise<ReactNode | null> }) { - return (await value) ?? <p role="status">This section is temporarily unavailable.</p> +async function BlockSlot({ value }: { value: Promise<ReactNode | null> }) { + return (await value) ?? <p role="status">This content is temporarily unavailable.</p> } export const loadPage = createServerFn({ method: 'GET' }) @@ -1400,9 +1400,9 @@

      2. Render sections on the server

      const page = await openPage<ReactNode>(data.href, getRequest()) const content = await renderServerComponent( <main> - {page.sections.map((section) => ( - <Suspense key={section.key} fallback={<p>Loading section…</p>}> - <Section value={section.value} /> + {page.blocks.map((block) => ( + <Suspense key={block.key} fallback={<p>Loading…</p>}> + <BlockSlot value={block.value} /> </Suspense> ))} </main>, @@ -1538,7 +1538,7 @@

      Checklist for migrating existing content

    • Keep existing names. If the content stores file-path type names, register them as they are or alias them.
    • Check inputs. Regenerate the schema and validate saved content. Review new required fields, narrowed unions, and changed defaults.
    • 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 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.
    • -
    • Test the integration. Load real pages against the migrated content: routes resolve, titles and SEO tags are right, sections that load later (if your framework streams them) appear, failing blocks show their fallback, and navigation works.
    • +
    • 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.
    • 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.

      @@ -1672,11 +1672,11 @@

      Contributing

      Under the hood

      Deco Studio compatibility

      What Studio reads

      Studio learns about your site from your site. Your app serves a few HTTP endpoints for it. The main one, /live/_meta, returns the schema the CLI wrote; Studio also calls your app to render previews (/live/previews), to run functions for pickers and the Run button (/deco/invoke), and to encrypt secret fields (/live/invoke). In today's packages, these handlers come from @decocms/blocks-admin; how the new API mounts them is on the Roadmap. Studio also reads your content files in .deco/blocks. Everything Studio knows about the shape of your content comes from the schema, so it's all inferred from your types; the other endpoints run your code, they don't describe it.

      -

      The schema file has two parts. definitions holds one JSON Schema per type: the form Studio shows. manifest lists every key of your block map and your Content type, grouped by kind, so Studio knows which keys are sections, which are rules, and so on. Each manifest entry points at its definition with a JSON Schema $ref. The CLI picks the group from the type:

      +

      The schema file has two parts. definitions holds one JSON Schema per type: the form Studio shows. manifest lists every key of your block map and your Content type, grouped by kind, so Studio knows which keys render UI, which are rules, and so on. The group names are Studio's, inherited from Deco's previous framework. Each manifest entry points at its definition with a JSON Schema $ref. The CLI picks the group from the type:

      - + @@ -1699,7 +1699,7 @@

      What Studio reads

      Three conventions follow from this, and they're the ones the rest of the doc already uses. A key is a block type if it's in the manifest and a saved entry if it isn't, so short names like hero are fine, as long as no saved entry uses the same name (see Blocks). A variant is { "variants": [{ "rule": …, "value": … }, …] }. 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.

      Studio compatibility today

      -

      Deco's previous framework named 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. Today's Studio was built for that framework and still expects those names. It decides whether a key is a block type or a saved entry by checking for a file extension, and it finds pages, the always rule (the default variant), the never rule (how it hides a section) and multivariate (website/flags/multivariate.ts) by their old paths.

      +

      Deco's previous framework named 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. Today's Studio was built for that framework and still expects those names. It decides whether a key is a block type or a saved entry by checking for a file extension, and it finds pages, the always rule (the default variant), the never rule (how it hides a block) and multivariate (website/flags/multivariate.ts) by their old paths.

      Until Studio reads the manifest instead, the CLI bridges the gap: for the types Studio looks up by name, it also writes the old path into the manifest as an alias (a second key for the same type). Content Studio saves under an old name resolves through the same alias mechanism your own renames use. Your code uses page and always; the manifest carries both spellings; nothing in the SDK changes.

      Two gaps remain. Redirects created in Studio are saved in the old nested shape, not the flat one from Routes and redirects:

      // Saved by today's Studio
      @@ -1721,7 +1721,7 @@ 

      Studio compatibility today

      - + diff --git a/site/src/landing.html b/site/src/landing.html index cc221906..4415cfab 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -99,7 +99,7 @@

      Every change is a commit,
      blocks.ts
      export default {
         experiments,  // returns A/B flags
      -  hero,         // renders a page section
      +  hero,         // returns JSX
         seo,          // returns page metadata
       } satisfies Blocks;
      @@ -113,7 +113,7 @@

      Headless for developers

      NameSummer campaign
      Path/summer-sale
      -
      Sectionsheroproduct+ Add
      +
      Blocksheroproduct+ Add
      02 @@ -232,7 +232,7 @@

      Publish in seconds, not on

      Your function stays. Its inputs move into a file.

      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.

      - +
      From db6acf7562be9c0d2a879f78ca019b59aec9e56f Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 17:32:47 -0300 Subject: [PATCH 014/132] docs(site): content option, deco content, built-in page/redirect, polling - createCMS({ blocks, content, token? }): content (a generated snapshot or any Loader) replaces the loader option; the SDK never touches the filesystem and runs in any JavaScript runtime. KV example included. - CLI: deco schema and deco content (writes .deco/blocks.gen.ts, one import per JSON file, hot-reloaded by the framework), both with --watch and --root, run from predev/prebuild. - page and redirect are built-in content types; override them in Content or opt out with never. - remoteLoader checks for a release every minute (interval option, DECO_CONTENT_INTERVAL, 1-minute minimum, +/-10s jitter), at idle moments, inside waitUntil on Workers; instances are shared through globalThis. - A block is either a function (code) or saved content (JSON). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/data/roadmap.json | 34 +-- site/src/app.js | 2 +- site/src/content.html | 459 ++++++++++++++++++++++++----------------- site/src/landing.html | 19 +- 4 files changed, 295 insertions(+), 219 deletions(-) diff --git a/site/data/roadmap.json b/site/data/roadmap.json index 5a9e310b..8cce2109 100644 --- a/site/data/roadmap.json +++ b/site/data/roadmap.json @@ -214,7 +214,7 @@ { "id": "roadmap-blockers--add-a-revision-handle-an-edge-cache-kit-and-an-isr-recipe", "title": "Add a revision handle, an edge cache kit and an ISR recipe", - "today": "Clients expose no revision and there's no forRevision. Degraded pages get cached, and update() isn't passed to waitUntil. Content refreshes on a ~5 min poll with cold isolates serving bundled content, versus ~10 s today.", + "today": "Clients expose no revision and there's no forRevision. Degraded pages get cached. The planned check for a new release runs once a minute, at an idle moment (inside ctx.waitUntil on Workers), so a publish is live everywhere within about a minute; 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 (client.revision(), cms.forRevision(), onUpdate), and markDegraded(). Add an edge cache kit, withEdgeCache() in @decocms/tanstack, 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", @@ -247,7 +247,7 @@ "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 Studio writes Lazy, SeoV2, site/apps/site.ts, redirect, secret and the multi matcher. UNKNOWN_BLOCK now fails the parent block, and list() can't see Content aliases.", - "plan": "Specify single-decode filenames, widen the alias bridge, ship deco check and deco bundle, and have the CLI emit a runtime alias table.", + "plan": "Specify single-decode filenames, widen the alias bridge, ship deco check and deco content, and have the CLI emit a runtime alias table.", "features": [ "block-type-discriminator", "orphan-and-dangling-blocks", @@ -468,7 +468,7 @@ { "id": "roadmap-studio-new--speed-up-publishes-and-make-failures-visible", "title": "Speed up publishes and make failures visible", - "today": "The ~5 min poll replaces ~10 s, with no 'content live' signal. A failed forDraft silently shows published content.", + "today": "The planned once-a-minute check makes a publish live within about a minute, but nothing tells the editor when it is live. A failed forDraft silently shows published content.", "features": [ "fast-deploy-kv", "otel-observability" @@ -683,7 +683,7 @@ "id": "roadmap-api--ship-the-withdeco-and-decoproxy-entry-wrappers", "group": "api", "title": "Ship the withDeco() and decoProxy() entry wrappers", - "plan": "

      withDeco(handler, { cms }) (TanStack) and decoProxy() (Next) own the per-request plumbing. They schedule cms.update() (ctx.waitUntil on Workers, after() on Next). They set the draft cookie as Secure; SameSite=None; Partitioned; HttpOnly and treat __draft=off or an empty value as exit. Drafted responses are private/no-store/noindex, and security headers carry frame-ancestors from an exported STUDIO_FRAME_ANCESTORS, never X-Frame-Options. Studio routes are optional. The CMS keys draft caches on the API ETag, bounded by an LRU.

      ", + "plan": "

      withDeco(handler, { cms }) (TanStack) and decoProxy() (Next) own the per-request plumbing. They set the draft cookie as Secure; SameSite=None; Partitioned; HttpOnly and treat __draft=off or an empty value as exit. Drafted responses are private/no-store/noindex, and security headers carry frame-ancestors from an exported STUDIO_FRAME_ANCESTORS, never X-Frame-Options. Studio routes are optional. The CMS keys draft caches on the API ETag, bounded by an LRU.

      ", "features": [ "worker-server-entry", "csp-security-headers", @@ -740,7 +740,7 @@ "id": "roadmap-api--add-remoteloader-options", "group": "api", "title": "Add remoteLoader options", - "plan": "

      { interval, coldStartTimeoutMs, channel, onUpdate, onError }, local files preferred in dev, and a first-party kvLoader. Scheduling update() is the entry wrappers' job.

      ", + "plan": "

      { interval, coldStartTimeoutMs, channel, onUpdate, onError }, also accepted by createCMS. interval is in milliseconds, defaults to DECO_CONTENT_INTERVAL 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: requestIdleCallback, then scheduler.postTask, then setTimeout; an unref'd timer on Node; and after the response inside ctx.waitUntil on Workers. Instances are process-wide singletons keyed by configuration, with a resetForTests() helper. Also ship a first-party kvLoader(env.CONTENT, { key }) so sites don't hand-write the KV loader the docs show.

      ", "features": [ "fast-deploy-kv", "env-vars-and-secrets", @@ -1146,7 +1146,7 @@ "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": "

      One decodeURIComponent per stem, with a collision report. deco bundle / localLoader.fromGlob() emits { revision, blocks, schemaHash }, and computeRevision is exported.

      ", + "plan": "

      One decodeURIComponent per stem, with a collision report. deco content writes .deco/blocks.gen.ts, one import per JSON file, exporting the snapshot { revision, blocks } with the revision computed the same way the Deco API computes it, and computeRevision is exported.

      ", "features": [ "block-type-discriminator", "content-storage-and-delivery", @@ -1177,7 +1177,7 @@ "id": "roadmap-cli--add-a-local-dev-loop", "group": "cli", "title": "Add a local dev loop", - "plan": "

      deco schema --watch / deco dev: a tunnel, a mounted Studio handler, and a reload broadcast when .deco/blocks changes.

      ", + "plan": "

      deco schema --watch and deco content --watch run next to the framework's own dev server; there is no deco dev wrapper. Editing a JSON file hot-reloads through the framework's HMR, and only adding or removing a file needs deco content to regenerate. Still to build: a tunnel and a mounted Studio handler so Studio can edit a local checkout.

      ", "features": [ "dev-studio-content-loop", "bundler-config", @@ -1191,7 +1191,7 @@ "id": "roadmap-cli--ship-the-deco-command", "group": "cli", "title": "Ship the deco command", - "plan": "

      These docs run npx deco schema, but no package declares a deco bin yet. Ship it from @decocms/blocks-cli, as Quickstart installs it, with schema first and the other deco subcommands on this page added to it.

      ", + "plan": "

      These docs run npx deco schema, but no package declares a deco bin yet. Ship it from @decocms/blocks-cli, as Quickstart installs it, with its two commands, deco schema and deco content (both take --watch and --root), and the other deco subcommands on this page added to it.

      ", "features": [ "codegen-pipeline", "studio-schema-generation", @@ -1327,7 +1327,7 @@ "id": "roadmap-platform--add-a-content-live-signal", "group": "studio", "title": "Add a content-live signal", - "plan": "

      Every response carries the served revision in x-deco-revision, and cms.update() reports each revision it swaps in to the Deco API with the site token. Studio polls the API and shows a publish as pending until a report for its revision arrives, then live.

      ", + "plan": "

      Every response carries the served revision in x-deco-revision, and the CMS reports each revision it swaps in to the Deco API with the site token. Studio polls the API and shows a publish as pending until a report for its revision arrives, then live.

      ", "features": [ "fast-deploy-kv", "edge-html-cache-profiles" @@ -1339,7 +1339,7 @@ "id": "roadmap-platform--update-the-injected-agent-rules", "group": "studio", "title": "Update the injected agent rules", - "plan": "

      Replace the two wrong rules for next-major sites. Content edits go only in .deco/blocks/<encodeURIComponent(key)>.json. There are no blocks.gen.* files; the one generated file is the schema, which deco schema rewrites after any change to a block's types. A new type needs a block-map entry. remoteLoader has no hot reload, and with DECO_SITE_TOKEN set the dev server serves the API release, not local edits.

      ", + "plan": "

      Replace the two wrong rules for next-major sites. Content edits go only in .deco/blocks/<encodeURIComponent(key)>.json. Two files are generated and never edited by hand: .deco/schema.json, which deco schema rewrites after any change to a block's types, and .deco/blocks.gen.ts, which deco content writes and git ignores. A new type needs a block-map entry. remoteLoader picks up releases within about a minute, not on save, and with DECO_SITE_TOKEN set the dev server serves the API release, not local edits.

      ", "features": [ "ai-agent-instructions" ], @@ -1396,7 +1396,7 @@ "id": "roadmap-docs--fix-the-tanstack-guides", "group": "docs", "title": "Fix the TanStack guides", - "plan": "

      Mount withDeco() instead of hand-wiring the draft cookie and cms.update(). Add validateSearch/loaderDeps and staleTime, and state that getRequest() is the RPC request on SPA navigation, so the page URL comes from the request scope.

      ", + "plan": "

      Mount withDeco() instead of hand-wiring the draft cookie. Add validateSearch/loaderDeps and staleTime, and state that getRequest() is the RPC request on SPA navigation, so the page URL comes from the request scope.

      ", "features": [ "worker-server-entry", "env-vars-and-secrets", @@ -1475,7 +1475,7 @@ "id": "roadmap-docs--fix-the-next-guide", "group": "docs", "title": "Fix the Next guide", - "plan": "

      Mount decoProxy() instead of wiring the draft cookie and after(() => cms.update()) by hand. Wrap client() in cache(), map the full PageSeo and degrade SEO errors instead of throwing, make release routes ISR/static, and cover transpilePackages and outputFileTracingIncludes.

      ", + "plan": "

      Mount decoProxy() instead of wiring the draft cookie by hand. Wrap client() in cache(), map the full PageSeo and degrade SEO errors instead of throwing, make release routes ISR/static, and cover transpilePackages and outputFileTracingIncludes.

      ", "features": [ "global-layout-sections", "theming-and-styling", @@ -1494,7 +1494,7 @@ "id": "roadmap-docs--write-an-operations-page", "group": "docs", "title": "Write an operations page", - "plan": "

      Five deliverables: (1) the DECO_SITE_TOKEN lifecycle (issue, rotate, scope) and a one-time warning when createCMS runs in production without a token; (2) a per-host table of how withDeco/decoProxy schedule update() on Workers, Node and Vercel; (3) default bounds for every in-isolate cache and how to change them; (4) the observability chokepoint rule (createInstrumentedFetch + createFetchCache) and the DECO_OTEL_* contract; (5) 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.

      ", + "plan": "

      Five deliverables: (1) the DECO_SITE_TOKEN lifecycle (issue, rotate, scope) and a one-time warning when createCMS runs in production without a token; (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 observability chokepoint rule (createInstrumentedFetch + createFetchCache) and the DECO_OTEL_* contract; (5) 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.

      ", "features": [ "env-vars-and-secrets", "hosting-and-deploy-config", @@ -3437,7 +3437,7 @@ "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, there's no revision to key by, and content refreshes on a ~5 min poll instead of ~10 s.", + "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 makes content consistent within about a minute.", "docs": [ "operations", "releases", @@ -3639,7 +3639,7 @@ "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), bundled content never matches the API hash, and there's no lenient mode or Studio-shaped schema.", + "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: deco content computes it the same way), and there's no lenient mode or Studio-shaped schema.", "docs": [ "quickstart", "manifest", @@ -3895,7 +3895,7 @@ "storefront-tanstack", "blog-tanstack" ], - "summary": "`remoteLoader` polls every ~5 min with no option, cold isolates serve build-time content, and a webhook reaches one isolate. Impact on these sites is low: none serves content from KV today.", + "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 first-party `kvLoader` is still to ship. Impact on these sites is low: none serves content from KV today.", "docs": [ "standalone", "releases", @@ -3960,7 +3960,7 @@ "storefront-tanstack", "blog-tanstack" ], - "summary": "`deco schema` and a hand-written block map replace the registries, but there's no Workers content bundle, no `--check` or `--watch`, no input for the schema gate, and Content aliases can't work at runtime.", + "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 `--check` yet, no input for the schema gate, and Content aliases can't work at runtime.", "docs": [ "schema", "manifest", diff --git a/site/src/app.js b/site/src/app.js index 434172a3..a1b3a57f 100644 --- a/site/src/app.js +++ b/site/src/app.js @@ -228,7 +228,7 @@ const wrap = document.createElement('div'); wrap.className = 'table-wrap'; table.before(wrap); wrap.append(table); } - // Multi-word code (signatures like `localLoader({ revision, blocks })`) may wrap at its + // Multi-word code (signatures like `remoteLoader(content, { token })`) may wrap at its // spaces on phones; single identifiers such as "not-found" stay on one line. $$('td code, th code', table).forEach(c => { if (/\s/.test(c.textContent.trim())) c.classList.add('can-wrap'); }); }); diff --git a/site/src/content.html b/site/src/content.html index 501b1929..9c989223 100644 --- a/site/src/content.html +++ b/site/src/content.html @@ -16,8 +16,8 @@

      Manifest groupWhat lands thereStudio uses it for
      sectionsBlock functions that return JSX or a render descriptorThe section catalog: the blocks that render UI, which an editor can add to a page
      sections (Studio's legacy name)Block functions that return JSX or a render descriptorStudio's "add" catalog: the blocks an editor can add to a page
      matchersBlock functions that return booleanThe rule picker on a variant
      loadersEvery other block function. Studio calls these loaders, after the old framework's name for data functions; they're not the content loaders from Content and loaders.Data fields that can point at a function
      pagesContent types that extend RouteThe page list, "new page", the URL field
      The CMS is 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.
      For content edited in Studio, Git is the source of truth. Publishing is committing; the Deco API serves commits as releases. (Any other store is a loader you write.)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.
      One registry rule. A name is a saved entry or a block type. An entry is merged in and checked again; a function gets its inputs resolved, then runs, and its result is returned as is.Saved entries are just named compositions, 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 entries, and a cycle check stops it.
      The CMS at module scope, a client per request, pinned to one revision.Everything a response renders agrees with itself, sections can be resolved and streamed to the browser one at a time, and the next request sees the next revision. Clients are cheap because every cache lives in the loader.
      The CMS at module scope, a client per request, pinned to one revision.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 every cache lives in the loader.
      Loaders are one interface: load(pointer?), plus an optional update().Production, drafts, and staying current are uses of the same interface, so localLoader and remoteLoader are interchangeable and a custom loader is a few lines.
      A production request never waits for the network.Serve from memory, poll for a hash in the background, fall back to the content the build shipped with. Eventual consistency for visitors, strict consistency for drafts.
      The CMS doesn't route. matchRoute 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.
      - - + +
      PieceWhat it doesWhere it runs
      SDK @decocms/blocksReads your content (from files in your repo, the hosted Deco CMS, or your own store) and calls your functions with it.Your app: any JavaScript server, Node or Cloudflare Workers
      CLI @decocms/blocks-cliProvides the deco command. deco schema generates a JSON Schema from your types.Your machine or CI
      SDK @decocms/blocksReads your content (a module the CLI generates from files in your repo, the hosted Deco CMS, or your own store) and calls your functions with it.Your app, in any JavaScript runtime: Node, Cloudflare Workers, Deno, Bun, React Native or the browser
      CLI @decocms/blocks-cliProvides the deco command. deco schema generates a JSON Schema from your types, and deco content turns your content files into a module your app imports.Your machine or CI
      StudioBuilds a visual editor from that schema and saves edits back to your files.Part of the hosted Deco CMS (optional)
      @@ -29,11 +29,12 @@

      Key terms

      Block function
      One of your functions with a typed first parameter, registered in a block map. It can return anything: data, a boolean, a descriptor or JSX. The framework treats them all the same. See Quickstart.
      Block map
      A plain object of your block functions, such as { experiments }. See Quickstart.
      Block type
      A key in the block map: the name content uses to call that function.
      -
      Block
      A piece of JSON whose __resolveType names a block type (or a saved entry) and whose other keys are inputs. People sometimes call the function a block too. In these docs, the function is a block function, and the JSON that calls it is a block. See Blocks.
      -
      Saved entry
      A block saved under a name: one JSON file in .deco/blocks, whose file name is the entry name. See Content and loaders.
      +
      Block
      Either a function (code) or saved content (JSON), named by __resolveType. A function is a key in your block map and ships with each deploy. Saved content is a saved entry: it's versioned, can have drafts, and publishes without a deploy. The JSON that calls a block, { "__resolveType": …, …inputs }, is called a block too. See Blocks.
      +
      Saved entry
      Saved content: a block saved under a name, one JSON file in .deco/blocks, whose file name is the entry name. See Content and loaders.
      +
      Content module
      .deco/blocks.gen.ts, the file deco content generates from .deco/blocks. It imports each JSON file and exports a snapshot, { revision, blocks }: one fixed copy of all your saved entries. See Content and loaders.
      Content map
      All saved entries, keyed by name.
      -
      Page
      A saved entry with a path, SEO and an ordered list of blocks (its sections field) that editors add, remove and reorder. See Routes and redirects.
      -
      Loader
      The small object that supplies the content map: its load() returns { revision, blocks }. See Content and loaders.
      +
      Page
      A built-in content type: a saved entry with a path, SEO and an ordered list of blocks (its sections field) that editors add, remove and reorder. See Routes and redirects.
      +
      Loader
      Any object whose load() returns { revision, blocks }. Pass one as createCMS's content to read content from a key-value store, a database or your API. See Content and loaders.
      Revision
      The ID of one exact content map, released or not, usually a hash of its contents. Change any entry and you get a new revision.
      Release
      A published content map. With the hosted Deco CMS, each commit to the production branch becomes one. A release never changes, and its revision identifies it. See Releases and deployment.
      Draft
      Content changed in Studio but not yet published, kept on a branch. See Preview.
      @@ -51,19 +52,19 @@

      Authoring: from types to an editor

      Delivery: from files to your app

      -
      ContentFiles in .deco/blocks, or a release (a published version of your content) from the Deco API
      +
      ContentThe content module built from .deco/blocks, or a release (a published version of your content) from the Deco API
      SDKclient.list and client.resolve read and resolve content
      Your appNext.js, TanStack Start, or any server
      -

      At request time, your app asks the SDK for a piece of saved content by name. The SDK reads it through a loader, the one small part that knows where content lives (by default, the files in .deco/blocks; see Content and loaders). If that content refers to other saved entries, the SDK expands them. Then it calls the matching function from your block map with the saved inputs and returns the result. How you render it is up to your framework. When you need more from the block map, such as lazy loading, see Block maps.

      -

      If you use the hosted Deco CMS, you can publish content without redeploying. In production your app reads the current release from the Deco API instead of from disk. A request never waits on that: content stays in memory and refreshes in the background, and the files your build shipped with are always there as the fallback. See Releases and deployment, or how the loader stays current for the details.

      +

      At request time, your app asks the SDK for a piece of saved content by name. The SDK reads it from the content you gave createCMS, the one setting that knows where content lives (usually the content module generated from .deco/blocks; see Content and loaders). If that content refers to other saved entries, the SDK expands them. Then it calls the matching function from your block map with the saved inputs and returns the result. How you render it is up to your framework. When you need more from the block map, such as lazy loading, see Block maps.

      +

      If you use the hosted Deco CMS, you can publish content without redeploying. In production your app reads the current release from the Deco API instead of from the content module. A request never waits on that: content stays in memory and refreshes in the background (each server checks for a new release about once a minute, when it's idle), and the content your build shipped with is always there as the fallback. See Releases and deployment, or how the loader stays current for the details.

      Small on purpose

      Deco Blocks stays out of your way, and every piece is replaceable:

      • Content is plain JSON. Open it in any editor, grep it, diff it, or generate it with a script. There's no database or content API for you to host. The hosted Deco CMS is optional, and Deco runs it.
      • Block functions are plain functions. No framework plugin, no base classes, no magic directories. The block map is a plain object, and you can call any block function directly in a test.
      • -
      • Loaders are one method. Reading from files, a key-value store such as Cloudflare Workers KV, or your own database is one load() method that returns { revision, blocks }: an ID for this version of the content, and the saved entries by name. Swap it and nothing else changes (see Content and loaders).
      • +
      • Loaders are one method. Reading from a key-value store such as Cloudflare Workers KV, or from your own database, is one load() method that returns { revision, blocks }: an ID for this version of the content, and the saved entries by name. Files in your repository need none, because the CLI turns them into a module. Swap the source and nothing else changes (see Content and loaders).
      • The schema is a file. Commit it, inspect it, or generate it another way. Studio builds its forms from that JSON alone.
      • Nothing is hidden. If the SDK doesn't do what you need, read the content yourself: the block format is documented in Blocks, and the files and the content map in Content and loaders.
      @@ -130,8 +131,8 @@

      1. Make the function configurable

      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. Schema generation lists every tag.

      -
      npx deco schema --entry ./blocks.ts --out ./.deco/schema.json
      -

      Commit the file. If the repository is connected to the hosted Deco CMS, Studio now shows each experiment as a number field clamped to 0–100. Without it, the schema still describes the content you or an agent write by hand.

      +
      npx deco schema
      +

      It finds the block map in blocks.ts (or src/blocks.ts; see Schema generation) and writes .deco/schema.json. Commit the file. If the repository is connected to the hosted Deco CMS, Studio now shows each experiment as a number field clamped to 0–100. Without it, the schema still describes the content you or an agent write by hand.

      3. Add content

      There are two ways to create content:

      @@ -139,7 +140,7 @@

      3. Add 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 Studio (hosted Deco CMS). Pick the experiments block type, fill in the form, and save. Studio commits the same file.
    -

    Both routes produce the same file in .deco/blocks, which is where the CMS reads content by default:

    +

    Both routes produce the same file in .deco/blocks, which is where the CLI looks for content (its --root flag points it at another .deco folder):

    .deco/blocks/Experiments.json
    {
       "__resolveType": "experiments",
       "newCheckout": 10,
    @@ -151,13 +152,21 @@ 

    3. Add content

    The file is a saved entry: one named piece of content that code, or other content, can refer to. Its file name without .json is its name (see Content and loaders). __resolveType is a reserved key (the underscores keep it from clashing with your fields) that names the function to call by its block type. The other keys are that function's inputs.

    Two names are in play, on purpose: experiments is the block type and names a function, and Experiments is the entry name and names this one piece of saved content. You could save several entries that call experiments, such as HolidayExperiments. Names are case-sensitive, so they don't clash.

    -

    4. Read the content from your code

    -

    Create the CMS once, at module scope, with your block map. It reads the .deco directory by default, so there's nothing else to configure (to read it from somewhere else, or connect the hosted Deco CMS, see Configuring the loader).

    +

    4. Generate the content module

    +

    The SDK never reads files itself, so it runs the same way in any JavaScript runtime. Instead, the CLI turns your content files into a module your app imports, the content module:

    +
    npx deco content
    +

    It writes .deco/blocks.gen.ts, which imports each JSON file in .deco/blocks separately and exports a snapshot, { revision, blocks }: one fixed copy of all your saved entries, plus its revision, an ID the CLI computes from their contents. The file is generated, so add it to .gitignore, as you would TanStack Router's routeTree.gen.ts. For this guide, running npx deco content once is enough. Rerun it when you add or remove a file in .deco/blocks; editing an existing file needs no rerun. In an app with dev and build scripts, add the two commands to package.json so they run first (see The content module):

    +
    "predev": "deco schema && deco content",
    +"prebuild": "deco schema && deco content"
    + +

    5. Read the content from your code

    +

    Create the CMS once, at module scope, with your block map and the content module (to connect the hosted Deco CMS or another store, see Configuring content).

    cms.ts
    import { createCMS } from "@decocms/blocks";
    +import content from "./.deco/blocks.gen";
     import blocks from "./blocks";
     
    -export const cms = createCMS({ blocks });
    -

    Then ask it for a client, the object you read content with. forRelease() returns a client over the published content, the release; with plain files, the release is simply what's in .deco/blocks. Its sibling forDraft() reads unpublished drafts for previews (see Preview). Make one client per request: a client reads one consistent version of the content for its whole life.

    +export const cms = createCMS({ blocks, content });
    +

    Then ask it for a client, the object you read content with. forRelease() returns a client over the published content, the release; with plain files, the release is simply what's in the content module. Its sibling forDraft() reads unpublished drafts for previews (see Preview). Make one client per request: a client reads one consistent version of the content for its whole life.

    Fetch the entry by name. The client runs experiments on the saved inputs, so you get the same value as calling experiments() yourself. An entry name is just 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:

    checkout.ts
    import { cms } from "./cms";
     import type experiments from "./experiments";
    @@ -169,7 +178,7 @@ 

    4. Read the content from your code

    // 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 entry or a failing function never throws. The error names what went wrong, for example NOT_FOUND. 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.

    +

    Run it with npx tsx checkout.ts. Now open .deco/blocks/Experiments.json, change newCheckout to 100, and run it again (no need to rerun deco content: only adding or removing a file needs that): newCheckout is true every time. You changed what the code does without touching the code.

    That's it. The function didn't change, but its inputs now live in a file that editors change through a form and agents change directly, and every change is a commit. 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 Block maps).

    Next steps

    @@ -233,14 +242,14 @@

    Name a composition, reuse it

    Resolving a page, step by step

    -

    Resolving a block is evaluating its composition: named entries expand, then calls run from the inside out. Here's a page saved as pages-summer. It uses the SummerSEO entry with an override, and one carousel block in its sections list. A page is mostly an ordered list of blocks that editors add, remove and reorder. The block map has two functions. seo returns its inputs; that's still worth registering, because it gives editors a form for SEO and lets them save it as a reusable entry. carousel returns a descriptor, { component, props }: a plain object that says which component to render with which props, for your app to render later. A block function can also return JSX directly; Rendering compares the two.

    -
    // Saved as pages-summer
    +  

    Resolving a block is evaluating its composition: named entries expand, then calls run from the inside out. Here's a page saved as SummerSale. It uses the SummerSEO entry with an override, and one carousel block in its sections list. A page is mostly an ordered list of blocks that editors add, remove and reorder. The block map has two functions. seo returns its inputs; that's still worth registering, because it gives editors a form for SEO and lets them save it as a reusable entry. carousel returns a descriptor, { component, props }: a plain object that says which component to render with which props, for your app to render later. A block function can also return JSX directly; Rendering compares the two.

    +
    // Saved as SummerSale
     {
       "seo": { "__resolveType": "SummerSEO", "title": "Summer sale" },
       "sections": [{ "__resolveType": "carousel", "banners": [] }]
     }

    This page has no __resolveType, so the page object itself isn't a call: resolving it resolves the blocks inside and keeps the rest of the JSON as it is. (Real pages usually carry "__resolveType": "page" so they can be listed by type. That name has no function behind it, so for those you resolve the fields instead; see below.)

    -

    Call client.resolve("pages-summer") and follow the page as it changes. resolve takes either an entry name (a string; a string is always an entry name) or any JSON value, such as one field of a page.

    +

    Call client.resolve("SummerSale") and follow the page as it changes. resolve takes either an entry name (a string; a string is always an entry name) or any JSON value, such as one field of a page.

    Step 1. The reference expands. SummerSEO is a saved entry, so it's data. The saved entry is merged with the reference's own inputs, and the reference's inputs win. The __resolveType from the entry, seo, names a function.

    {
       "seo": { "__resolveType": "seo", "title": "Summer sale", "description": "Light layers for long days." },
    @@ -257,12 +266,12 @@ 

    Resolving a page, step by step

    sections: [{ component: "Carousel", props: { banners: [] } }] }

    To step through a deeper example, see How resolution works.

    -

    Reading without running. Pass { run: false } to read content without running any function. client.resolve("pages-summer", { run: false }) stops after step 1: saved entries like SummerSEO still expand, and blocks that name a function, like seo and carousel, come back as JSON, untouched. Nothing runs. That's useful for previews, editor tooling, and debugging.

    +

    Reading without running. Pass { run: false } to read content without running any function. client.resolve("SummerSale", { run: false }) stops after step 1: saved entries like SummerSEO still expand, and blocks that name a function, like seo and carousel, come back as JSON, untouched. Nothing runs. That's useful for previews, editor tooling, and debugging.

    The rule

    -

    Every __resolveType is a name looked up in one registry, and a name is either data or code: a named composition to expand, or a function to call.

    +

    Every __resolveType names a block, and a block is either a function (code) or saved content (JSON): a function in your block map to call, or a saved entry in your content to expand. Both share one namespace, one registry. They differ in how they change: functions ship with a deploy, while saved content is versioned (each version has a revision), can have drafts, and can be published without a deploy.

    const registry = { ...savedEntries, ...blocks };   // name → saved JSON | function
    -

    Saved entries are the content map your loader returns. Functions come from the block map you give createCMS. The CMS applies one rule to every object with a __resolveType:

    +

    Saved entries are the content map you pass as createCMS's content. Functions come from the block map you give createCMS. The CMS applies one rule to every object with a __resolveType:

    1. Look up the name.
    2. Data: replace the object with { ...entry, ...inputs }, so your inputs override the entry's, and apply the rule to the result again.
    3. @@ -277,88 +286,125 @@

      The rule in detail

    4. Block functions should only read. They run whenever content is resolved: for a visitor, in a Studio preview, for an agent. Put side effects in your own route handlers (see Caching and observability).
    5. 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.
    6. -

      Pages that are only data. Some saved entries describe a shape but have no function behind them, such as a website page saved with "__resolveType": "page". Nothing can be called for them, so resolving the entry fails with UNKNOWN_BLOCK. Read the entry instead, then resolve the blocks inside it, for example client.resolve(page.seo). (pages-summer above had no __resolveType at all, which is why it could be resolved whole.) Schema generation shows how to declare such types, and Routes and redirects how to read them.

      -

      Entry names and block types share one registry. If a name is both, the function wins, so a saved entry called seo and a seo function can't coexist. deco schema fails when an entry in .deco/blocks has the name of a block type, so rename the entry.

      +

      Pages that are only data. Some saved entries describe a shape but have no function behind them, such as a page (page is a built-in content type, saved with "__resolveType": "page"). Nothing can be called for them, so resolving the entry fails with UNKNOWN_BLOCK. Read the entry instead, then resolve the blocks inside it, for example client.resolve(page.seo). (SummerSale above had no __resolveType at all, which is why it could be resolved whole.) Schema generation shows how to declare such types, and Routes and redirects how to read them.

      +

      Entry names and block types share one registry. If a name is both, the function wins, so a saved entry called seo and a seo function can't coexist. deco schema fails when an entry in .deco/blocks has the name of a block type, so rename the entry. By convention, functions are lowercase or kebab-case and saved content is Capitalized.

    Core concepts

    Content and loaders

    -

    Resolving takes two maps. The block map holds your functions, and it ships with your code. The content map holds your saved entries: each name points at a piece of JSON. A loader supplies the content map, and it's the only part of the CMS that knows where content lives. Your functions, the resolver, and your pages never do.

    +

    Resolving takes two maps. The block map holds your functions, and it ships with your code. The content map holds your saved entries: each name points at a piece of JSON. You hand createCMS your content through its content option, the only setting that knows where content lives: either the content module the CLI generates from your files, or a loader, any object with a load() method that returns the content map. Your functions, the resolver, and your pages never do.

    Other meanings of "loader". In these docs, a loader on its own always means a content loader: the object that supplies the content map. Two other things share the name. In Studio, and in sites built on Deco's earlier framework, "loader" also means a block function that fetches data, like catalogProduct in Blocks; those are ordinary functions in your block map. And frameworks such as TanStack Router and Remix call the function that fetches a route's data a route loader. Nothing on this page applies to either.

    -

    A loader returns the whole map at once, with a revision that identifies it:

    +

    Content always arrives as the whole map at once, with a revision that identifies it. This pair, { revision, blocks }, is called a snapshot: one fixed copy of all your saved entries.

    {
       "revision": "b1946ac92492d2347c6235b4d2611184",
       "blocks": {
         "SummerSEO": { "__resolveType": "seo", "title": "Sunny!", "description": "Light layers for long days." },
    -    "pages-summer": {
    +    "SummerSale": {
           "seo": { "__resolveType": "SummerSEO", "title": "Summer sale" },
           "sections": [{ "__resolveType": "carousel", "banners": [] }]
         }
       }
     }

    Each key under blocks is an entry name. It's the name a __resolveType refers to, and the name you pass to client.resolve("SummerSEO"). The field is called blocks for compatibility with Studio's file format, but it holds saved entries: any JSON, never functions. Functions only ever come from the block map you give createCMS.

    -

    The revision names this exact set of entries. The CMS and your app use it like a version number: to tell when content changed, to keep cached data from different versions apart, and to tell one release from the next. The one hard rule: any change to any entry must produce a new revision, and a revision must never be reused for different content. The built-in loaders, the CLI and the Deco API all use the same content hash of the whole map, so two copies of the same content get the same revision wherever they're computed. A loader you write can use any string that follows the rule; Releases and deployment shows what matching the hash buys you.

    -

    To read an entry exactly as stored, with references in place, index the map: (await localLoader("./.deco").load()).blocks["SummerSEO"].

    +

    The revision names this exact set of entries. The CMS and your app use it like a version number: to tell when content changed, to keep cached data from different versions apart, and to tell one release from the next. The one hard rule: any change to any entry must produce a new revision, and a revision must never be reused for different content. deco content, remoteLoader and the Deco API all use the same content hash of the whole map, so two copies of the same content get the same revision wherever they're computed. A loader you write can use any string that follows the rule; Releases and deployment shows what matching the hash buys you.

    +

    To read an entry exactly as stored, with references in place, index the map: content.blocks["SummerSEO"], with content imported from ./.deco/blocks.gen (the content module, below).

    The map itself isn't a call: it's what the names in your content refer to. What each entry means is in Blocks.

    Where content comes from

    -

    When you edit with Studio or an agent, content is authored as files: they write to .deco/blocks in your repository, and every change is a commit. The loader only decides where your app reads that content from at runtime. A release is one published version of the whole content map. A draft is content an editor has changed in Studio but not published yet (see Preview). With the hosted Deco CMS, each commit to the production branch becomes a release that reaches your app without a deploy, and the files the build shipped with are the fallback (see Releases and deployment). If you load from your own store, how content gets there is up to you.

    +

    When you edit with Studio or an agent, content is authored as files: they write to .deco/blocks in your repository, and every change is a commit. The content option only decides where your app reads that content from at runtime. A release is one published version of the whole content map. A draft is content an editor has changed in Studio but not published yet (see Preview). With the hosted Deco CMS, each commit to the production branch becomes a release that reaches your app without a deploy, and the content the build shipped with is the fallback (see Releases and deployment). If you load from your own store, how content gets there is up to you.

    There is no publish step. Content is published by changing it: commit a file in .deco/blocks and push, or save in Studio, which commits for you. With the hosted Deco CMS, that commit becomes the current release without a deploy; without it, the content goes live with your next deploy. See Publishing. (Running deco publish only prints this explanation, so an agent that tries it learns to commit instead.)

    The content map can come from anywhere that can return JSON. These are the usual sources:

    - + - - - - + + +
    Content inLoaderUse it for
    Content increateCMS optionsUse it for
    Files in your repository (the default)localLoader("./.deco"): one JSON file per entry in .deco/blocks; the file name without .json is the entry name (.deco/blocks/SummerSEO.json → "SummerSEO")Development, tests, and the fallback of remoteLoader in production
    JSON bundled into the buildlocalLoader({ revision, blocks })Cloudflare Workers, which have no filesystem
    The hosted Deco CMSremoteLoader(fallback, { token })Production: reads the current release from the Deco API without a deploy, and falls back to the loader you pass (usually your files) when it can't. Also serves drafts for preview
    KV, a database, or your own APIA Loader you writeAnything else
    Files in your repositorycontent: the content module, .deco/blocks.gen.ts, which deco content generates from one JSON file per entry in .deco/blocks; the file name without .json is the entry name (.deco/blocks/SummerSEO.json → "SummerSEO")Development, tests, every runtime, and the fallback under token in production
    The hosted Deco CMSAdd token, which wraps your content as remoteLoader(content, { token })Production: reads the current release from the Deco API without a deploy, and falls back to your content module when it can't. Also serves drafts for preview
    KV, a database, or your own APIcontent: a Loader you writeAnything else
    -

    Configuring the loader

    -

    Most apps never pass a loader: createCMS reads the ./.deco directory by default. Pass an object to keep your content somewhere else, to connect the hosted Deco CMS, or both. These are shortcuts for the loaders in the table above: { root } is localLoader(root), and adding token wraps it as remoteLoader(localLoader(root), { token }).

    -
    cms.ts
    // Default: the files in ./.deco
    -createCMS({ blocks });
    +  

    The content module

    +

    The SDK never touches the filesystem, so the same setup runs on Node, Cloudflare Workers, Deno, Bun, React Native and in browsers. Files reach your app through the content module instead: .deco/blocks.gen.ts, which the CLI command deco content generates from .deco/blocks. It imports each JSON file separately, using JSON import attributes (the with { type: "json" } clause), and its default export is the snapshot { revision, blocks }, with the revision computed by the CLI:

    +
    .deco/blocks.gen.ts (excerpt)
    // Generated by deco content; don't edit.
    +import Experiments from "./blocks/Experiments.json" with { type: "json" };
    +import SummerSEO from "./blocks/SummerSEO.json" with { type: "json" };
    +// … one import per file
    +
    +export default {
    +  revision: "b1946ac92492d2347c6235b4d2611184",
    +  blocks: { Experiments, SummerSEO /* , … */ },
    +};
    +

    The file is generated, so add it to .gitignore, like TanStack Router's routeTree.gen.ts, and run deco content before your dev server and your build, from package.json:

    +
    package.json
    {
    +  "scripts": {
    +    "predev": "deco schema && deco content",
    +    "prebuild": "deco schema && deco content"
    +  }
    +}
    +

    Both commands are described in The two CLI commands. Because each file is an ordinary import, editing one reloads through your framework's own hot module replacement (HMR, which swaps changed modules into the running app without a restart), and the new content is handed to the running CMS. Only adding or removing a file needs deco content again, or deco content --watch left running beside your dev server.

    +

    To keep content in another folder, point the CLI at it and import from there: deco content --root ./content/.deco, then import content from "./content/.deco/blocks.gen".

    + +

    Configuring content

    +

    createCMS takes your content in its content option: usually the content module, or a Loader for your own store. Add token to connect the hosted Deco CMS; it's shorthand for remoteLoader(content, { token, interval }), with your content as the fallback.

    +
    cms.ts
    import content from "./.deco/blocks.gen";
    +
    +// Your files, through the content module
    +createCMS({ blocks, content });
     
    -// Content in another directory
    -createCMS({ loader: { root: "./content/.deco" }, blocks });
    -createCMS({ loader: "./content/.deco", blocks });   // shorthand for { root }
    +// Releases from the hosted Deco CMS, with the content module as the fallback
    +createCMS({ blocks, content, token: process.env.DECO_SITE_TOKEN });
     
    -// Releases from the hosted Deco CMS, with the files in ./.deco as the fallback
    -createCMS({ loader: { token: process.env.DECO_SITE_TOKEN }, blocks });
    +// Check for new releases every 2 minutes instead of every minute
    +createCMS({ blocks, content, token: process.env.DECO_SITE_TOKEN, interval: 120_000 });
     
    -// Both: hosted releases, with ./content/.deco as the fallback
    -createCMS({
    -  loader: { root: "./content/.deco", token: process.env.DECO_SITE_TOKEN },
    -  blocks,
    -});
    -

    token is your site's API key from the hosted Deco CMS, used to call the Deco API. (Issuing tokens for this API isn't available yet; see the Roadmap.) Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads the files only, so the same line works everywhere. For anything else, pass a Loader: localLoader({ revision, blocks }) for content bundled into a Workers build, or your own for any other store.

    +// Content from your own store (see Write your own loader) +createCMS({ blocks, content: kvContent });
    +

    token is your site's API key from the hosted Deco CMS, used to call the Deco API. (Issuing tokens for this API isn't available yet; see the Roadmap.) Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads content only, so the same line works everywhere.

    +

    interval is how often each server checks for a new release, in milliseconds. It defaults to the DECO_CONTENT_INTERVAL environment variable, or 60 000 (one minute) when that's unset. The minimum is 60 000: 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. How and when checks run is in Loader internals.

    Write your own loader

    -

    The contract is one required method, load(pointer?), which returns { revision, blocks }. Here is one backed by Cloudflare Workers KV, Workers' key-value store (KVNamespace comes from @cloudflare/workers-types):

    -
    kv-loader.ts
    import type { Loader } from '@decocms/blocks'
    +  

    The contract is one required method, load(pointer?), which returns { revision, blocks }. Here is one backed by Cloudflare Workers KV, Workers' key-value store, with the revision and the entries stored together under one key:

    +
    cms.ts
    import { createCMS, type Loader, type Snapshot } from "@decocms/blocks";
    +import { env } from "cloudflare:workers";
    +import blocks from "./blocks";
     
    -export function kvLoader(kv: KVNamespace, key = 'release:current'): Loader {
    -  return {
    -    async load() {
    -      const content = await kv.get(key, 'json')
    -      if (!content) throw new Error('No content in KV')
    -      return content   // { revision, blocks }
    -    },
    -  }
    -}
    -

    Pass it as createCMS's loader, or as the fallback of remoteLoader. Store the revision with the content, and change it whenever any entry changes (see the rule above).

    -

    Two optional extras. If your store has drafts, use the pointer argument, the draft pointer that names which draft to read. If content can change while the process runs, add update(), which checks for newer content (see Loader internals). Either way, the client only ever sees { revision, blocks }.

    +const kvContent: Loader = { + async load() { + // { revision, blocks }, stored together under one key + const snapshot = await env.CONTENT.get<Snapshot>("content", "json"); + if (!snapshot) throw new Error("no content in KV"); + return snapshot; + }, +}; + +export const cms = createCMS({ blocks, content: kvContent });
    +

    On Workers, importing env from cloudflare:workers gives you your bindings (here the KV namespace CONTENT) at module scope, so the CMS can still be created once, outside any request. Snapshot is the { revision, blocks } type. If load() throws, the read fails with LOADER_FAILED (see Errors). A first-party kvLoader(env.CONTENT, { key }) helper is planned (see the Roadmap).

    +

    Pass it as content; add token to use it as the fallback under hosted releases. Store the revision with the content, and change it whenever any entry changes (see the rule above).

    +

    Two optional extras. If your store has drafts, use the pointer argument, the draft pointer that names which draft to read. If content can change while the process runs, add update(), which checks for newer content; the CMS calls it on the same idle schedule and interval as remoteLoader (see Loader internals). Either way, the client only ever sees { revision, blocks }.

    Core concepts

    Schema generation

    -

    Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. Point it at the file that exports your block map, the blocks.ts from the quickstart:

    -
    npx deco schema --entry ./blocks.ts --out ./.deco/schema.json
    +

    Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. It reads the file that exports your block map, the blocks.ts from the quickstart, and writes .deco/schema.json. It looks for that file as blocks.ts or blocks.tsx, in the project root or in src/:

    +
    npx deco schema

    For each block type in the map, the CLI reads the type of the function's first parameter and turns it into a form definition. Commit .deco/schema.json with your code, and run the command in CI so the schema never drifts from your types.

    The CLI also checks your content model: it fails if a saved entry in .deco/blocks has the name of a block type, or if an alias (a second name for a block type, used when you rename one; see Renaming and migrating) collides with either.

    +

    The two CLI commands

    +

    The CLI has two commands, and each does one job:

    +
      +
    • deco schema turns the types in your block map into .deco/schema.json. Commit this file.
    • +
    • deco content turns the files in .deco/blocks into the content module, .deco/blocks.gen.ts. It's generated, so gitignore it.
    • +
    +

    Both take --watch, to regenerate as files change, and --root, the .deco folder to use (by default ./.deco). There's no separate dev command: run both before your dev server and your build. Why the content module is gitignored, and what reloads without a rerun, is in The content module.

    +
    package.json
    {
    +  "scripts": {
    +    "predev": "deco schema && deco content",
    +    "prebuild": "deco schema && deco content"
    +  }
    +}
    +

    Annotate fields with JSDoc

    The TypeScript type decides what kind of field each input gets: a string is a text box, a number a number field, a union of string literals a select, an array a list editors can add to and reorder. JSDoc tags on a field refine the form:

    @@ -394,9 +440,8 @@

    Annotate fields with JSDoc

    becomes a form with a Headline text box limited to 60 characters, an optional Body rich-text editor, and an optional Ends on date picker.

    Content that is only data

    -

    A page, a blog post, a redirect: these have a shape but no function. They're never resolved themselves: your app reads them, by type with client.list("post") or by URL with matchRoute (see Routing), and resolves the blocks inside them. They still need a schema so Studio can create them, so declare them for the CLI in a type-level map next to the block map. Page and Post are ordinary types you write; Redirect ships with @decocms/blocks.

    -
    blocks.ts
    import type { Blocks, Redirect } from "@decocms/blocks";
    -import type { Page } from "./page";
    +  

    A page, a blog post, a redirect: these have a shape but no function. They're never resolved themselves: your app reads them, by type with client.list("post") or by URL with matchRoute (see Routing), and resolves the blocks inside them. They still need a schema so Studio can create them, so declare them for the CLI in a type-level map next to the block map. deco schema always includes two built-in content types, page: Page and redirect: Redirect, so you declare only your own, like post: Post. Page, Redirect and Route are exported from @decocms/blocks.

    +
    blocks.ts
    import type { Blocks } from "@decocms/blocks";
     import type { Post } from "./post";
     import seo from "./seo";
     import hero from "./hero";
    @@ -404,11 +449,16 @@ 

    Content that is only data

    export default { seo, hero } satisfies Blocks; // code: a schema per function parameter export type Content = { // data: a schema per type - page: Page; - post: Post; - redirect: Redirect; + post: Post; // page and redirect are built in };

    The CLI reads both exports. Keys of the default export get a schema from each function's parameter type; keys of Content get one from the type itself. The two share one namespace, and a key in both is an error. The CMS never sees Content: list("page") works because the entries exist, and resolving a page means resolving its fields, not the page.

    +

    Override or opt out. Declaring a built-in key in Content replaces it. Extend the exported type to add fields, or to narrow sections, a page's ordered list of blocks:

    +
    import type { Page } from "@decocms/blocks";
    +
    +interface StorePage extends Page { theme: "light" | "dark" }
    +
    +export type Content = { page: StorePage; post: Post };
    +

    An app that isn't a website opts out with page: never or redirect: never. Either way, only the schema changes: the CMS never reads Content.

    Studio learns the shape of your content only from the schema this command writes. What it looks for, which endpoints it calls on your site, and how the CLI bridges today's Studio conventions are covered under the hood.

    @@ -429,7 +479,7 @@

    Content that is only data

    return render(client, request) // your own function: list, matchRoute and resolve as usual }
    -

    forDraft hands the pointer to the loader. remoteLoader fetches that exact version from the Deco API, waits for it, and caches it by version, so the second request for the same draft is a memory read. A draft replaces the whole revision, deletions included, so a page in preview looks exactly as it will after publishing. localLoader ignores the pointer: in development you're already looking at the files. A loader you write honors it if your store has drafts (see Write your own loader). That's why the code above never checks which loader it has.

    +

    forDraft hands the pointer to whatever supplies your content. remoteLoader fetches that exact version from the Deco API, waits for it, and caches it by version, so the second request for the same draft is a memory read. A draft replaces the whole revision, deletions included, so a page in preview looks exactly as it will after publishing. The content module has no drafts, so it ignores the pointer: in development you're already looking at the files. A loader you write honors it if your store has drafts (see Write your own loader). That's why the code above never checks where its content comes from.

    Keep the draft across navigation

    The pointer is only in the URL of the first page Studio opens. draftCookie(request) gives you the Set-Cookie value that response needs, and null on every other request. Append it, and draftPointer reads the cookie from then on, so every link the editor clicks stays in preview:

    @@ -463,12 +513,12 @@

    Who may preview

    The token is the only secret. The site holds no other credential. Invalid or unexpected pointers are ignored and the request falls back to published content, never to an error page.

    Connect Studio to your site

    -

    Studio opens previews on your site's URL, so the site has to be set up in the hosted Deco CMS with your token (creating a site and a token for this API is on the Roadmap). Support for sites on this API, including previews inside Studio's frame, is still being built; see the Roadmap. To try the mechanism locally: with only local files, forDraft returns the release, because localLoader ignores the pointer. Testing a real draft needs a token.

    +

    Studio opens previews on your site's URL, so the site has to be set up in the hosted Deco CMS with your token (creating a site and a token for this API is on the Roadmap). Support for sites on this API, including previews inside Studio's frame, is still being built; see the Roadmap. To try the mechanism locally: with only local files, forDraft returns the release, because the content module has no drafts. Testing a real draft needs a token.

    Advanced

    Block maps

    -

    A block map is the code half of the registry: a plain object from block type to function. The other half is the content map of saved entries, which your loader supplies. The quickstart's blocks.ts is one. This section covers what else can go in it.

    +

    A block map is the code half of the registry: a plain object from block type to function. The other half is the content map of saved entries, which you pass to createCMS as content. The quickstart's blocks.ts is one. This section covers what else can go in it.

    blocks.ts
    import type { Blocks } from "@decocms/blocks";
     import seo from "./seo";
     import productCard from "./product-card";
    @@ -510,7 +560,7 @@ 

    Lazy loading

    Keep import paths literal in both cases. A bundler can split import("./components/Product") into its own chunk; it can't split import(`./components/${name}`).

    Choose type names that last

    -

    A block type's name is what content stores in __resolveType to call it, so pick it like a database column, not like a file path: short (seo, hero) or namespaced when a site has many (commerce/product). Moving a file only means updating an import; the name doesn't change. Renaming a type means migrating every block that uses it, or adding an alias, a second name for the same block type (see Renaming and migrating). Type names share one namespace with saved entry names (see Blocks), and deco schema fails on a clash. A simple convention avoids clashes: lowercase or kebab-case for block types (seo, product-card), capitalized names for entries (SummerSEO).

    +

    A block type's name is what content stores in __resolveType to call it, so pick it like a database column, not like a file path: short (seo, hero) or namespaced when a site has many (commerce/product). Moving a file only means updating an import; the name doesn't change. Renaming a type means migrating every block that uses it, or adding an alias, a second name for the same block type (see Renaming and migrating). Type names share one namespace with saved entry names (see Blocks), and deco schema fails on a clash (at runtime, the function wins). A simple convention avoids clashes: lowercase or kebab-case for block types (seo, product-card), capitalized names for entries (SummerSEO).

    @@ -603,7 +653,7 @@

    Every variant resolves

    Websites

    Routes, redirects, and content types

    -

    A content type is a kind of saved entry that holds data and runs no function, such as a page, a blog post or a redirect. Its name is the value of __resolveType, and you declare it in Content (see Content that is only data). The framework ships two building blocks for anything with a URL: the Route interface, which any content type can extend to get a URL, and a ready-made redirect content type.

    +

    A content type is a kind of saved entry that holds data and runs no function, such as a page, a blog post or a redirect. Its name is the value of __resolveType. You declare your own in Content; page and redirect are built in, and deco schema always includes them (see Content that is only data). The framework also ships the Route interface, which any content type can extend to get a URL.

    /** An entry that lives at a URL. Extend it to make a type routable. */
     interface Route {
       /** @title Name */
    @@ -612,18 +662,19 @@ 

    Every variant resolves

    path: string; // "/summer", or a template like "/:slug/p" } -/** A saved entry of the built-in `redirect` type. */ +/** A saved entry of the built-in `redirect` content type. */ interface Redirect { from: string; // literal or template to: string; permanent: boolean; // 301 or 302 }
    -

    A page is a route with SEO and a list of blocks (its sections field). It's not special to the router; it's just the first type that extends Route. It's shaped like the pages-summer entry in Blocks, with three additions: "__resolveType": "page", so the CMS can list it by type, and a name and path, so the router can find it. Like every data-only type, it's declared in Content, not in the block map:

    -
    interface Page extends Route {
    -  seo: PageSeo;                                     // an SEO block, or a reference to a saved one
    +  

    A page is a route with SEO and a list of blocks (its sections field). It's not special to the router; it's just the first type that extends Route. It's shaped like the SummerSale entry in Blocks, with three additions: "__resolveType": "page", so the CMS can list it by type, and a name and path, so the router can find it. It's built in, so you don't declare it. To add fields, override it in Content (interface StorePage extends Page { … }; see Schema generation). It's exported from @decocms/blocks:

    +
    /** The built-in `page` content type. */
    +interface Page extends Route {
    +  seo: Block;                                       // an SEO block, or a reference to a saved one
       sections: VariantOf<Array<VariantOf<Block>>>;   // a list of blocks; the whole list, or any one block, can be a variant
     }
    -

    PageSeo is your SEO input type. Block is a block as stored: JSON whose __resolveType names a function or a saved entry. VariantOf<T> accepts a T or a multivariate block that picks one.

    +

    Block is a block as stored: JSON whose __resolveType names a function or a saved entry. VariantOf<T> accepts a T or a multivariate block that picks one. Both fields are deliberately loose, because the package can't know your block types. To narrow them, override page: for example interface StorePage extends Page { seo: MySeoBlock; sections: Block[] }, then export type Content = { page: StorePage }.

    Saved as SummerPage
    {
       "__resolveType": "page",
       "name": "Summer campaign",
    @@ -666,7 +717,7 @@ 

    Your own routable types

    Pages are data; resolve their fields

    A page runs no function, so you never resolve the page itself. Read it, then resolve the blocks inside it: client.resolve(page.seo), and one client.resolve(block) per block in page.sections. Separate calls let each block render as soon as its own data is ready (see Rendering). A redirect is never resolved; matchRoute only reads it.

    client.resolve("SummerPage") and client.list("page", { run: true }) fail with UNKNOWN_BLOCK, because page names no function, and the CMS never sees Content (see the rule). To read a page, list it, or pass { run: false }.

    -

    Running code for a whole page. If you want code to run for a whole page, such as validation or a layout step, add a page function to the block map and move Page out of Content. Then client.resolve(page) runs it after every block in the page has resolved.

    +

    Running code for a whole page. If you want code to run for a whole page, such as validation or a layout step, add a page function to the block map and opt the built-in type out with page: never in Content, since a key can't be both. Then client.resolve(page) runs it after every block in the page has resolved.

    Content types without a URL

    Navigation, email templates, and campaign settings work the same way, minus Route: declare the type in Content if it's data, or register a function if something should run, and save entries of that type.

    @@ -710,21 +761,20 @@

    Errors, streaming, and cancellation

    Websites

    Routing

    The CMS doesn't route. It lists and resolves. Routing is three steps in your request handler: list the types you want at URLs, hand them to matchRoute, resolve the winner. Do all three with one client, so they see the same content even if editors publish mid-request (see One revision per response).

    The example used here and in the guides

    -

    This page and the framework guides share these files: the model in src/model.ts, the post type in src/post.ts (from Your own routable types), and two content entries, stored as files in .deco/blocks, where the default loader reads them (see Content and loaders). Each guide's block map (src/blocks.tsx or src/blocks.ts) declares seo, its own product, and Content, the map of data-only types from Schema generation.

    +

    This page and the framework guides share these files: the model in src/model.ts, the post type in src/post.ts (from Your own routable types), and two content entries, stored as files in .deco/blocks, which deco content turns into the content module (see Content and loaders). Each guide's block map (src/blocks.tsx or src/blocks.ts) declares seo and its own product. Pages and redirects are built-in content types (see Content that is only data); the guides override page with StoredPage, below, to narrow its sections field, so Studio's form matches what the guide code reads.

    In src/model.ts, ProductDescriptor is the descriptor a product block returns in data mode (see Rendering), and StoredPage is a page exactly as saved, before any of its blocks run.

    -
    src/model.ts
    import type { Block, Route } from '@decocms/blocks'
    +
    src/model.ts
    import type { Block, Page } from '@decocms/blocks'
     
     export interface SEO { title: string; description: string }
     export interface ProductProps { id: string; name: string; price: number; currency: string }
     export interface ProductDescriptor { component: 'product'; props: ProductProps }
     
     // A saved page, as client.list returns it by default (nothing run).
    -// This example keeps the sections list a plain array: no variant around the whole list.
    -export interface StoredPage extends Route {
    -  seo: SEO | Block
    +// Overrides the built-in Page to keep sections a plain array: no variant around the whole list.
    +export interface StoredPage extends Page {
       sections: Block[]
     }
    -

    StoredPage is simpler than the Page type in Routes and redirects: it doesn't allow a variant around the whole sections list. If yours does, resolve the list first, const [list] = await client.resolve(page.sections), which picks the variant and also runs every block in it, or store the variant's values as entry names as in Every variant resolves.

    +

    StoredPage narrows the built-in Page type from Routes and redirects: it doesn't allow a variant around the whole sections list. Each guide's block map exports export type Content = { page: StoredPage }, so deco schema gives Studio the narrower form. If you keep the built-in Page, which does allow it, resolve the list first, const [list] = await client.resolve(page.sections), which picks the variant and also runs every block in it, or store the variant's values as entry names as in Every variant resolves.

    .deco/blocks/ProductPage.json
    {
       "__resolveType": "page",
       "name": "Product page",
    @@ -821,43 +871,52 @@ 

    Linking and listing

    Everything on this page is exported from @decocms/blocks. The built-in matchers (always, never, date) are in @decocms/blocks/matchers.

    createCMS(config)

    -

    createCMS returns a CMS: your block map plus a loader. Create it once, at module scope. For each request, ask it for a client: forRelease() for visitors, forDraft(pointer) for a preview. A client loads one revision on first use and reads only that revision afterwards, so everything in one response agrees. Every later call, list or resolve, reuses that content, so a page's blocks can stream as separate calls; the next request's client sees the next revision, so don't share a client across requests. A client also remembers what it has already resolved: an entry referenced three times runs its function once per client. That memory is tied to the client and to the block map object (results are kept per function of the map you passed), so a map rebuilt inside your handler starts with an empty cache; build the map once at module scope. Clients are cheap to create, because the loader caches the content.

    +

    createCMS returns a CMS: your block map plus your content. Create it once, at module scope; it returns the same instance for the same configuration (see One instance per process). For each request, ask it for a client: forRelease() for visitors, forDraft(pointer) for a preview. A client loads one revision on first use and reads only that revision afterwards, so everything in one response agrees. Every later call, list or resolve, reuses that content, so a page's blocks can stream as separate calls; the next request's client sees the next revision, so don't share a client across requests. A client also remembers what it has already resolved: an entry referenced three times runs its function once per client. That memory is tied to the client and to the block map object (results are kept per function of the map you passed), so a map rebuilt inside your handler starts with an empty cache; build the map once at module scope. Clients are cheap to create, because the CMS caches the content.

    function createCMS(config: {
    -  loader?:                               // optional: defaults to "./.deco"
    -    | string                             // a content directory: localLoader(path)
    -    | { root?: string; token?: string }  // files in root (default "./.deco"); add a token to read hosted releases, with those files as the fallback
    -    | Loader;                            // anything else: bundled content, KV, your own
       blocks: Blocks;
    +  content: Snapshot | Loader;   // the content module (.deco/blocks.gen.ts), or any Loader
    +  token?: string;               // hosted releases over content: remoteLoader(content, { token, interval })
    +  interval?: number;            // ms between release checks; default DECO_CONTENT_INTERVAL or 60_000; minimum 60_000
     }): CMS;
     
     interface CMS {
    -  forRelease(): Client;                         // a client reading loader.load()
    -  forDraft(pointer: string): Client;            // a client reading loader.load(pointer); an invalid pointer behaves like the release
    -  update(): Promise<{ updated: boolean }>;      // loader.update(), or a no-op
    +  forRelease(): Client;                         // a client reading the current release
    +  forDraft(pointer: string): Client;            // a client reading the draft a pointer names; an invalid pointer behaves like the release
    +  update(): Promise<{ updated: boolean }>;      // check for a new release now (manual refresh); checks also run on their own
     }
     
     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[]>>;
     }
    -

    The loader option takes a directory path, { root?, token? }, or a Loader; leave it out to read ./.deco. What each shape does is in Configuring the loader. The rule from Blocks is how a client resolves.

    +

    content takes the content module or a Loader; token and interval add hosted releases. See Configuring content. The rule from Blocks is how a client resolves.

    Loaders

    One interface. load() with no argument is production; load(pointer) is a draft. update() checks for newer content. Which one to use, and how to write your own, is in Content and loaders.

    interface Loader {
    -  load(pointer?: string | null): Promise<{ revision: string; blocks: Record<string, unknown> }>;   // the content map: entry name → saved JSON
    +  load(pointer?: string | null): Promise<Snapshot>;   // Snapshot = { revision, blocks }; see Types
       update?(): Promise<{ updated: boolean }>;
     }
    - + - - + + +
    LoaderLoadsUse it for
    ContentLoadsUse it for
    localLoader(dir)
    localLoader({ revision, blocks })
    The content that ships with the app: a .deco directory on Node, or JSON imported at build time on Workers. Ignores the pointer: there are no drafts in local content. No update().Development, tests, and the fallback in production
    remoteLoader(fallback, { token })The current release from the Deco API over a fallback loader, or the draft a pointer names. The token identifies the site; everything else comes from the Deco API.Production
    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().Development, tests, and the fallback in production
    remoteLoader(fallback: Snapshot | Loader, { token, interval? })The current release from the Deco API over the fallback, or the draft a pointer names. The token identifies the site; everything else comes from the Deco API.Production
    A Loader you writeWhatever your store returns (see Write your own loader).KV, a database, or your own API
    -

    remoteLoader(localLoader("./.deco"), { token }): load() never waits on the network. It returns what's in memory, the newest release fetched or else the fallback loader's content, and update() checks for newer content in the background. See how it stays current. load(pointer) fetches the draft a pointer names and caches it by version. If the draft can't be fetched, the client serves what a normal request would get, never a mix of draft and published entries. See Preview.

    -

    update() is rate-limited to the polling interval, every five minutes, never throws, and resolves at once if a check ran recently. On Node, remoteLoader calls it on its own timer. Cloudflare Workers have no timers between requests, so call it yourself, off the request path, with ctx.waitUntil(cms.update()) (ctx is the Worker's execution context; waitUntil lets work finish after the response is sent) or from a Cron Trigger (a Worker run on a schedule). A webhook or an admin "refresh now" button calls the same method. A loader without update() is always current by construction, like the filesystem.

    -

    Caching, ETags, and fallbacks live inside the loader, which may be shared by any number of clients. The client only ever sees { revision, blocks }.

    +

    remoteLoader(content, { token }): load() never waits on the network. It returns what's in memory, the newest release fetched or else the fallback's content, and update() checks for newer content in the background. See how it stays current. load(pointer) fetches the draft a pointer names and caches it by version. If the draft can't be fetched, the client serves what a normal request would get, never a mix of draft and published entries. See Preview.

    +

    remoteLoader checks for a new release on its own:

    +
      +
    • every interval, which defaults to DECO_CONTENT_INTERVAL or 60 000 ms and is never below 60 000;
    • +
    • each check one interval, plus or minus up to 10 s, after the previous one (60 s ± 10 s by default), fetching only the revision hash;
    • +
    • when a check is due, at the next idle moment (see Loader internals). On Cloudflare Workers, which run no timers between requests, it runs after the response inside ctx.waitUntil (ctx is the Worker's execution context; waitUntil lets work finish after the response is sent), so you never call update() yourself.
    • +
    +

    cms.update() stays for a manual refresh, such as a webhook or an admin "refresh now" button; it refreshes only the server that runs it. It never throws. A loader you write that has update() gets the same idle schedule and interval. A snapshot, like the content module, never changes, and a loader without update() is always current by construction.

    +

    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 and remoteLoader instances are process-wide singletons. Each is stored on globalThis under a Symbol.for("decocms.blocks…") key derived from its configuration (the site 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 a package loaded twice, by two bundles or by a dev reload (hot module replacement, HMR), still shares one cache and one poller. 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. Tests can start clean:

    +
    function resetForTests(): void;   // clears every stored instance

    Draft pointers

    A pointer is a string, <host[:port]><path[?query]>@<version>, that names where a draft lives and which version it is. Studio makes them; forDraft reads them. Two helpers cover everything in between:

    interface DraftPointer {
    @@ -915,7 +974,7 @@ 

    client.list(type, options?)

    sort: (a, b) => b.date.localeCompare(a.date), limit: 20, });
    -

    Filters run in memory over the loaded content map, the cost of a loader returning the whole map at once (see Content and loaders). Sites with tens of thousands of entries of one type should keep an index elsewhere.

    +

    Filters run in memory over the loaded content map, the cost of content arriving as the whole map at once (see Content and loaders). 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 Routing.

    @@ -938,14 +997,20 @@

    Types

    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 + interface Loader { - load(pointer?: string | null): Promise<{ revision: string; blocks: Record<string, unknown> }>; // the content map: entry name → saved JSON + load(pointer?: string | null): Promise<Snapshot>; update?(): Promise<{ updated: boolean }>; } interface DraftPointer { host: string; path: string; version: string } interface Route { name: string; path: string } // extend it to give a type a URL + +// The built-in content types; override them in Content with an interface that extends them. +interface Page extends Route { seo: Block; sections: VariantOf<Array<VariantOf<Block>>> } interface Redirect { from: string; to: string; permanent: boolean } type Match<T> = @@ -981,7 +1046,7 @@

    Errors

    Framework guides

    Next.js App Router

    In this guide, you'll render CMS pages with Server Components, stream each of a page's blocks under its own Suspense boundary, and generate metadata from the page's SEO block.

    Each of a page's blocks resolves on its own, so a slow block never holds up the rest. Editors also choose page URLs in Studio, so your app can't know its routes ahead of time. Instead, one catch-all route receives every URL, asks matchRoute which page or redirect owns it, and renders that page's blocks.

    -

    Prerequisites: an App Router app on the Node runtime (the default loader reads .deco from disk, which the Edge runtime can't), with a root layout; npm install @decocms/blocks server-only and the CLI from Quickstart; and the example project: src/model.ts plus the entries in .deco/blocks at the project root.

    +

    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 and the CLI from Quickstart; and the example project: src/model.ts plus the entries in .deco/blocks at the project root.

    1. Add the components

    Product is the component the product block renders. CartButton is a Client Component with state, there to show that interactive components keep working inside blocks the CMS renders on the server. Neither knows about the CMS.

    @@ -1014,39 +1079,48 @@

    1. Add the components

    2. Create the CMS

    The product block returns JSX. Next renders it as a Server Component, and CartButton stays a Client Component.

    -
    src/blocks.tsx
    import type { Blocks, Redirect } from '@decocms/blocks'
    +
    src/blocks.tsx
    import type { Blocks } from '@decocms/blocks'
     import Product from './Product'
     import type { ProductProps, SEO, StoredPage } from './model'
     
    +export type Content = { page: StoredPage }
    +
     export default {
       seo: (input: SEO) => input,
       product: (input: ProductProps) => <Product {...input} />,
    -} satisfies Blocks
    -
    -export type Content = { page: StoredPage; redirect: Redirect }
    -

    seo just returns its input. Registering it lets editors save SEO as a reusable entry with its own form. Content declares the data-only types (pages and redirects) for the schema CLI.

    +} satisfies Blocks
    +

    seo just returns its input. Registering it lets editors save SEO as a reusable entry with its own form. page and redirect are built-in content types (see Content that is only data). The Content export overrides page with the narrower StoredPage from the example, so Studio only saves pages this guide's code can read.

    +

    cms.ts imports the content module, .deco/blocks.gen.ts: the file the CLI command deco content generates from your JSON files, exporting a snapshot { revision, blocks } (one fixed copy of all your saved entries). Step 3 sets up that command; until it has run once, TypeScript can't find the import.

    src/cms.ts
    import { createCMS } from '@decocms/blocks'
    +import content from '../.deco/blocks.gen'
     import blocks from './blocks'
     
    -// Development (no token) reads the files in .deco. Production reads releases from the Deco API
    -// and falls back to those same files.
    -export const cms = createCMS({ loader: { token: process.env.DECO_SITE_TOKEN }, blocks })
    -

    The loader shapes and their fallbacks are in Configuring the loader.

    +// Development (no token) reads the content module. Production reads releases from the Deco API +// and falls back to it. +export const cms = createCMS({ blocks, content, token: process.env.DECO_SITE_TOKEN })
    +

    The content options and their fallbacks are in Configuring content.

    src/client.server.ts
    import 'server-only'
     import { cookies } from 'next/headers'
     import { DRAFT_COOKIE } from '@decocms/blocks'
     import { cms } from './cms'
     
     // The client for this request: a draft when the cookie holds a valid pointer, production otherwise.
    -// In development (no token), drafts and releases read the same files.
    +// In development (no token), drafts and releases read the same content.
     export async function client() {
       const pointer = (await cookies()).get(DRAFT_COOKIE)?.value
       return pointer ? cms.forDraft(pointer) : cms.forRelease()
     }

    import 'server-only' makes the build fail if a Client Component imports this file, which keeps the token and the CMS out of the browser.

    -

    3. Generate the schema

    -

    Run npx deco schema --entry ./src/blocks.tsx --out ./.deco/schema.json so Studio has a form for each block type (see Schema generation).

    +

    3. Generate the schema and content

    +

    Two CLI commands generate the files the app and Studio need (see The two CLI commands): deco schema writes .deco/schema.json, a form for each block type in Studio, from the block map it finds in src/blocks.tsx, and deco content writes the content module cms.ts imports. Run both before the dev server and the build:

    +
    package.json
    {
    +  "scripts": {
    +    "predev": "deco schema && deco content",
    +    "prebuild": "deco schema && deco content"
    +  }
    +}
    +

    Commit .deco/schema.json and gitignore .deco/blocks.gen.ts. What reloads on its own is in The content module; while you work, you can leave deco content --watch running beside next dev.

    4. Keep preview working across pages

    Studio opens your site with the draft pointer in the URL. proxy.ts (called middleware.ts before Next.js 16) runs before every request and turns that pointer into a cookie, so the editor stays in preview while clicking around. client() reads the cookie.

    @@ -1062,7 +1136,7 @@

    4. Keep preview working across pages

    }

    5. Match the URL to a page

    -

    openPage lists pages and redirects, matches the pathname, and starts resolving every block without waiting for any of them. Listing is cheap: the loader holds the content in memory. Block keys include the pathname, so React mounts fresh Suspense boundaries when you navigate.

    +

    openPage lists pages and redirects, matches the pathname, and starts resolving every block without waiting for any of them. Listing is cheap: the CMS holds the content in memory. Block keys include the pathname, so React mounts fresh Suspense boundaries when you navigate.

    generateMetadata and the page component both need the page. Wrap the lookup in React's cache so they share one client, one match, and the same block promises.

    src/open-page.server.ts
    import 'server-only'
     import { cache, type ReactNode } from 'react'
    @@ -1137,7 +1211,7 @@ 

    6. Render the catch-all route

    }

    7. List another content type

    -

    Add src/post.ts from Your own routable types, add post: Post to Content in src/blocks.tsx, and save an entry such as .deco/blocks/HelloWorld.json. 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, since a post is data with nothing to resolve:

    +

    Add src/post.ts from Your own routable types, change the Content export in src/blocks.tsx to { page: StoredPage; post: Post }, and save an entry such as .deco/blocks/HelloWorld.json (a new file, so run deco 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, since a post is data with nothing to resolve:

    src/open-page.server.ts (changes)
    import type { Result } from '@decocms/blocks'
     import type { Post } from './post'
     
    @@ -1187,52 +1261,43 @@ 

    How it works

    Framework guides

    TanStack Start with data descriptors

    There are two ways to render with TanStack Start: this guide, which works with any React setup, and the experimental RSC guide; Which mode to use compares them. Here, block functions don't return JSX. They return a descriptor, a plain object such as { component: 'product', props } (see Rendering). Start sends a route's loader data to the browser as serialized data, and JSX can't make that trip, but a descriptor can. (Here "loader" is TanStack Router's route loader, the function that fetches a route's data. It isn't a content loader from Content and loaders.) In the browser, a view registry (const views = { product: Product }) maps each name to a component. Start streams each block's descriptor as it resolves, renders the views on the server, and hydrates them in the browser. It deploys to Cloudflare Workers.

    Editors choose page URLs in Studio, so your app can't know its routes ahead of time. One catch-all route receives every URL, asks matchRoute which page or redirect owns it, and renders that page's blocks.

    -

    Prerequisites: a TanStack Start app (see the Start quick start); npm install @decocms/blocks zod and npm install -D @cloudflare/vite-plugin wrangler, plus the CLI from Quickstart; the example project; and Product and CartButton from step 1 of the Next.js guide. Enable resolveJsonModule in tsconfig.json so you can import the content JSON.

    +

    Prerequisites: a TanStack Start app (see the Start quick start); npm install @decocms/blocks zod and npm install -D @cloudflare/vite-plugin wrangler, plus the CLI from Quickstart; the example project; and Product and CartButton from step 1 of the Next.js guide. Enable resolveJsonModule in tsconfig.json so TypeScript accepts the JSON imports in .deco/blocks.gen.ts, the content module.

    1. Return descriptors from blocks

    -
    src/blocks.ts
    import type { Blocks, Redirect } from '@decocms/blocks'
    +
    src/blocks.ts
    import type { Blocks } from '@decocms/blocks'
     import type { ProductDescriptor, ProductProps, SEO, StoredPage } from './model'
     
    +export type Content = { page: StoredPage }
    +
     export default {
       seo: (input: SEO) => input,
       product: (input: ProductProps): ProductDescriptor => ({ component: 'product', props: input }),
    -} satisfies Blocks
    -
    -export type Content = { page: StoredPage; redirect: Redirect }
    -

    Generate the schema from the map file: npx deco schema --entry ./src/blocks.ts --out ./.deco/schema.json.

    +} satisfies Blocks
    +

    As in the Next.js guide, Content overrides the built-in page with the narrower StoredPage. Generate the schema from the map file (deco schema finds src/blocks.ts), and the content module (.deco/blocks.gen.ts, which cms.ts imports) from .deco/blocks, before the dev server and the build (see The two CLI commands):

    +
    package.json
    {
    +  "scripts": {
    +    "predev": "deco schema && deco content",
    +    "prebuild": "deco schema && deco content"
    +  }
    +}
    +

    Commit .deco/schema.json and gitignore .deco/blocks.gen.ts (see The content module).

    2. Create the CMS

    DECO_SITE_TOKEN is your site's API key for the Deco API. On Workers, environment variables come from env in cloudflare:workers; locally, wrangler dev reads them from a .dev.vars file you keep out of Git. In production, set it with npx wrangler secret put DECO_SITE_TOKEN.

    -
    src/cms.ts
    import { createCMS, localLoader, remoteLoader, draftPointer } from '@decocms/blocks'
    -import { env, waitUntil } from 'cloudflare:workers'
    +
    src/cms.ts
    import { createCMS, draftPointer } from '@decocms/blocks'
    +import { env } from 'cloudflare:workers'
    +import content from '../.deco/blocks.gen'
     import blocks from './blocks'
     
    -// The content this build shipped with, keyed by entry name (the file name without .json).
    -const files = import.meta.glob<{ default: unknown }>('../.deco/blocks/*.json', { eager: true })
    -const entries = Object.fromEntries(
    -  Object.entries(files).map(([file, mod]) => [file.slice(file.lastIndexOf('/') + 1, -'.json'.length), mod.default]),
    -)
    -const local = localLoader({ revision: import.meta.env.VITE_CONTENT_REVISION, blocks: entries })
    -
    -// Production reads releases from the Deco API and falls back to the bundled content.
    -export const cms = createCMS({ loader: remoteLoader(local, { token: env.DECO_SITE_TOKEN }), blocks })
    +// Production reads releases from the Deco API and falls back to the content module.
    +export const cms = createCMS({ blocks, content, token: env.DECO_SITE_TOKEN })
     
     // The client for this request: the draft it points at, or the current release.
     export const client = (request: Request) => {
    -  waitUntil(cms.update())   // Check for a newer release (at most every five minutes; never blocks).
       const pointer = draftPointer(request)
       return pointer ? cms.forDraft(pointer) : cms.forRelease()
     }
    -

    Workers have no filesystem, so the content is bundled into the build (see Content and loaders). Workers also stop running timers once a response is sent, so instead of polling on a timer, each request asks cms.update() to check; it returns at once unless five minutes have passed, and waitUntil lets the check finish after the response.

    -

    VITE_CONTENT_REVISION is the bundled content's revision. Any string that changes whenever the JSON does is correct (see the revision rule). Define it in vite.config.ts with define (step 7 does), for example from a hash of .deco/blocks. This hash isn't the one the Deco API computes, so remoteLoader can't recognize the bundled content as the current release: each new Worker instance downloads the current release once instead of skipping it (see Loader internals). That costs one fetch per instance, not correctness.

    -
    content-revision.ts
    import { createHash } from 'node:crypto'
    -import { readdirSync, readFileSync } from 'node:fs'
    -
    -export function contentRevision(dir = '.deco/blocks') {
    -  const hash = createHash('sha256')
    -  for (const file of readdirSync(dir).sort()) hash.update(file).update(readFileSync(`${dir}/${file}`))
    -  return hash.digest('hex').slice(0, 16)
    -}
    +

    The content module is ordinary imports, so Vite bundles it into the Worker like any other module; nothing here is specific to Workers. Its revision is the same content hash the Deco API computes, so a new Worker instance whose build already holds the current release skips downloading it (see Loader internals). Workers run no timers between requests, so when a release check is due, the SDK runs it 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 cms.update().

    3. Match the URL to a page

    This helper is shared by both TanStack guides. T is what your product block returns: ProductDescriptor here, ReactNode in the RSC guide, which reuses this file unchanged. The server function hands the incoming request to client(), and draftPointer finds the pointer in the URL or in the cookie from step 6. Each block promise resolves to the value, or to null if the block failed, so no internal error details reach the browser.

    @@ -1345,11 +1410,9 @@

    7. Configure Cloudflare Workers

    import { cloudflare } from '@cloudflare/vite-plugin' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import react from '@vitejs/plugin-react' -import { contentRevision } from './content-revision' export default defineConfig({ plugins: [cloudflare({ viteEnvironment: { name: 'ssr' } }), tanstackStart(), react()], - define: { 'import.meta.env.VITE_CONTENT_REVISION': JSON.stringify(contentRevision()) }, })
    wrangler.jsonc
    {
       "$schema": "./node_modules/wrangler/config-schema.json",
    @@ -1362,11 +1425,10 @@ 

    7. Configure Cloudflare Workers

    Deploying checklist

      -
    • Bundle the content with localLoader({ revision, blocks }).
    • -
    • Wrap it with remoteLoader so releases arrive without a deploy.
    • +
    • Run deco schema && deco content before each build (the prebuild script).
    • +
    • Pass token to createCMS so releases arrive without a deploy.
    • Set the token with npx wrangler secret put DECO_SITE_TOKEN.
    • -
    • Call waitUntil(cms.update()) on each request, or from a Cron Trigger (a Worker run on a schedule).
    • -
    • Expect each new Worker instance (Cloudflare calls it an isolate) to serve the build's content until its first check.
    • +
    • Expect each new Worker instance (Cloudflare calls it an isolate) to serve the build's content module until its first check. Checks run after responses, about once a minute.
    • Keep both compatibility flags.
    • The Next.js guide targets Node, not Workers.
    @@ -1379,7 +1441,7 @@

    Deploying checklist

    This guide reuses the data guide's content, CMS and openPage, but the product block returns JSX instead of a descriptor. The server renders it as React Server Components and streams the result in React's wire format (Flight). Because the browser receives rendered output, it needs no view registry, and view code used only on the server never ships.

    1. Swap the product block

    -

    Replace src/blocks.ts with the Next.js guide's src/blocks.tsx. Keep src/cms.ts, src/open-page.server.ts and src/start.ts (the preview cookie) from the data guide as they are. Update the schema command's --entry to ./src/blocks.tsx.

    +

    Replace src/blocks.ts with the Next.js guide's src/blocks.tsx. Keep src/cms.ts, src/open-page.server.ts and src/start.ts (the preview cookie) from the data guide as they are. deco schema finds src/blocks.tsx on its own, 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.

    @@ -1428,13 +1490,12 @@

    3. Render the route

    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.

    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 and content-revision.ts from the data guide.

    +

    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 data guide.

    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'
    -import { contentRevision } from './content-revision'
     
     export default defineConfig({
       plugins: [
    @@ -1443,7 +1504,6 @@ 

    4. Configure Cloudflare Workers

    rsc(), react(), ], - define: { 'import.meta.env.VITE_CONTENT_REVISION': JSON.stringify(contentRevision()) }, environments: { rsc: { build: { outDir: 'dist/server/rsc' } }, }, @@ -1453,25 +1513,25 @@

    4. Configure Cloudflare Workers

    Production

    Releases and deployment

    -

    Your code ships when you deploy. Your content can ship the same way, or on its own. This page covers how a content change reaches visitors, how each response stays consistent, and what happens when the network fails. It rests on one idea from Content and loaders: a revision identifies one exact content map. localLoader, remoteLoader, the CLI and the Deco API all compute it as the same hash of the whole map, so two copies of the content are the same exactly when their revisions match. If your fallback's revision uses that hash too, remoteLoader can recognize it and skip a download. A loader you write only has to change its revision whenever content changes (see Write your own loader).

    +

    Your code ships when you deploy. Your content can ship the same way, or on its own. This page covers how a content change reaches visitors, how each response stays consistent, and what happens when the network fails. It rests on one idea from Content and loaders: a revision identifies one exact content map. deco content, remoteLoader and the Deco API all compute it as the same hash of the whole map, so two copies of the content are the same exactly when their revisions match, and remoteLoader recognizes the build's content module as the current release and skips a download. A loader you write only has to change its revision whenever content changes (see Write your own loader).

    - - - + + +
    Content fromHow a content change reaches visitors
    localLoaderThe content ships with the app: a directory on Node, or JSON bundled into the build on Workers. On its own, changing content means redeploying (or, on Node, editing the files). As the fallback of remoteLoader, it's what a server serves until it has fetched its first release.
    remoteLoader(fallback, …)The Deco API serves each commit of the production branch as a release that never changes. The loader keeps the current one in memory, with the content the build shipped with as the fallback. A content change reaches visitors without a deploy.
    A Loader you writeWhatever your store does. Content reaches visitors when the loader returns a new revision; if it can go stale, implement update() (see Write your own loader).
    The content moduleThe content ships with the app: .deco/blocks.gen.ts, which deco content generates from your files, is bundled like any other import. On its own, changing content means a deploy (in development, editing a file hot-reloads). With a token, it's the fallback: what a server serves until it has fetched its first release.
    content plus token (remoteLoader)The Deco API serves each commit of the production branch as a release that never changes. The loader keeps the current one in memory, with the content the build shipped with as the fallback. A content change reaches visitors without a deploy.
    A Loader you writeWhatever your store does. Content reaches visitors when the loader returns a new revision; if it can go stale, implement update(), which the CMS calls once a minute at an idle moment (see Write your own loader).

    A revision never changes once it exists: change any entry and you get a new revision. That makes a revision a safe cache key everywhere.

    Publishing

    -

    With the hosted Deco CMS, publishing is committing. A developer or an agent edits the entry files in your content directory (.deco/blocks by default) and pushes; an editor saves in Studio, which commits to the same repository. (If you load from your own store, see Content and loaders.) Either way, the production branch (the branch the hosted Deco CMS publishes from, usually main) moves.

    -

    Publishing takes seconds: the Deco API serves that commit as the current release as soon as it lands. Each server picks it up on its next check, every five minutes by default, or at once if you call cms.update() when a publish happens, for example from a webhook (see Loaders). Requests never wait on this: load() always returns from memory, and the check runs in the background. Step by step: under the hood.

    +

    With the hosted Deco CMS, publishing is committing. A developer or an agent edits the entry files in your content directory (.deco/blocks, or the folder you give the CLI's --root) and pushes; an editor saves in Studio, which commits to the same repository. (If you load from your own store, see Content and loaders.) Either way, the production branch (the branch the hosted Deco CMS publishes from, usually main) moves.

    +

    Publishing takes seconds: the Deco API serves that commit as the current release as soon as it lands. Each server checks for a new release about once a minute, at an idle moment, so a publish is live everywhere within about a minute. Calling cms.update(), for example from a webhook, 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 (see Loaders). Requests never wait on this: load() always returns from memory, and the check runs in the background. Step by step: under the hood.

    Because content and code share one history, a deploy and a content change are the same kind of event: a commit. Rolling content back is reverting one.

    Content can't get ahead of code. remoteLoader tells the Deco API which schema your build shipped with (a hash of the committed .deco/schema.json), and the API serves only releases whose content fits that schema. That's why the schema must be regenerated and committed with every code change; run deco schema in CI. If a commit adds a block type the deployed build doesn't have, visitors see that commit only after you deploy the code that defines the type; until then, the loader keeps serving the previous release. (Sending the schema hash is part of the Deco API release service, still on the Roadmap.)

    Fallback

    -

    Until a server has fetched its first release (a new server, or one that has never reached the Deco API), remoteLoader serves its fallback loader's content. If the Deco API becomes unreachable later, the server keeps serving the newest release it already has. With the default, localLoader, that's the content the build shipped with, the content this deploy was tested against, so it's always there and always compatible with the code. If you pass a different fallback (see Write your own loader), keeping those guarantees is up to you. remoteLoader always switches between whole revisions, never mixing entries from two of them, because a mix can break references or bring back deleted entries. The loader reports the revision it actually served.

    +

    Until a server has fetched its first release (a new server, or one that has never reached the Deco API), remoteLoader serves its fallback's content. If the Deco API becomes unreachable later, the server keeps serving the newest release it already has. With the content module, that's the content the build shipped with, the content this deploy was tested against, so it's always there and always compatible with the code. If your content is a loader you wrote (see Write your own loader), keeping those guarantees is up to you. remoteLoader always switches between whole revisions, never mixing entries from two of them, because a mix can break references or bring back deleted entries. The loader reports the revision it actually served.

    One revision per response

    Because new content can arrive in the background at any moment, two reads a few milliseconds apart could see different revisions; a header from one and a footer from the other make an inconsistent page. A client 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.

    @@ -1479,7 +1539,7 @@

    One revision per response

    A revision only selects content; it isn't a secret and unlocks nothing. Unpublished drafts are protected by the signed token in the draft pointer (see Preview).

    Deploying on Workers

    -

    Cloudflare Workers have no filesystem and no timers between requests, so the content is bundled into the build and each request asks for an update with waitUntil(cms.update()). The deploying checklist in the TanStack Start guide lists every step.

    +

    Workers need nothing special. The content module is bundled like any other import, and because Workers run no timers between requests, the SDK runs a due release 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 cms.update(). The deploying checklist in the TanStack Start guide lists every step.

    @@ -1488,7 +1548,7 @@

    Deploying on Workers

    - + @@ -1508,9 +1568,8 @@

    Tracing

    This page covers renaming safely, and bringing in content from a site built on Deco's earlier framework, where type names were file paths such as site/sections/Product.tsx. Moving source files is free; only names matter.

    Rename a type with an alias

    -

    An alias is another key pointing at the same thing: the same function in the block map, or the same type in Content. Content that stores the old name keeps working, and routing is unaffected because matchRoute looks at each entry's path field (see Routes and redirects), not its type name. Here, Page is your page type and Redirect ships with @decocms/blocks:

    -
    blocks.ts
    import type { Blocks, Redirect } from "@decocms/blocks";
    -import type { Page } from "./page";
    +  

    An alias is another key pointing at the same thing: the same function in the block map, or the same type in Content. Content that stores the old name keeps working, and routing is unaffected because matchRoute looks at each entry's path field (see Routes and redirects), not its type name. Here, Page is the built-in page type from @decocms/blocks; you declare only the legacy name, since page itself is built in:

    +
    blocks.ts
    import type { Blocks, Page } from "@decocms/blocks";
     import productCard from "./product-card";
     
     export default {
    @@ -1519,9 +1578,7 @@ 

    Rename a type with an alias

    } satisfies Blocks; export type Content = { - page: Page; - "website/pages/Page.tsx": Page; // same type, legacy name; entries still route - redirect: Redirect; + "website/pages/Page.tsx": Page; // the built-in page type, legacy name; entries still route };

    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:

    type CardInput = Parameters<typeof productCard>[0];
    @@ -1546,12 +1603,12 @@ 

    Checklist for migrating existing content

    Production

    Troubleshooting

    -

    Most problems come down to three facts: which revision this server is serving, which entry was asked for, and which block type it names. Until the client exposes its revision (see the Roadmap), build the loader yourself, pass it to createCMS, and log (await loader.load()).revision; that's the revision a new client would read right now. To see an entry with references expanded but no function run, call client.resolve(target, { run: false }). To see it exactly as stored, open .deco/blocks/<name>.json.

    +

    Most problems come down to three facts: which revision this server is serving, which entry was asked for, and which block type it names. Until the client exposes its revision (see the Roadmap), build the loader yourself (const loader = remoteLoader(content, { token })), pass it as createCMS's content, and log (await loader.load()).revision; that's the revision a new client would read right now. To see an entry with references expanded but no function run, call client.resolve(target, { run: false }). To see it exactly as stored, open .deco/blocks/<name>.json.

    LayerLifetimeYour responsibility
    Content mapCached in the loader: releases by revision, indefinitely; drafts by version. See Content and loaders and how remoteLoader stays current.Nothing, unless you write a loader: then key by revision.
    Content mapCached in the CMS: releases by revision, indefinitely; drafts by version. One cache per process, even if the package is loaded twice (see One instance per process). See Content and loaders and how remoteLoader stays current.Nothing, unless you write a loader: then key by revision.
    Resolved blocksFor one client. A client runs each block once and reuses the result for the rest of the request.Make a client per request and resolve everything the response renders through it.
    Dynamically imported modulesBlock functions you load lazily with import() (see Block maps) 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.
    Cross-request resultsAnything 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.
    - + @@ -1560,7 +1617,10 @@

    Checklist for migrating existing content

    - + + + +
    SymptomWhat to check
    UNKNOWN_BLOCKCompare __resolveType with your block map's keys, including case. Add an alias for renamed types. If the function does a dynamic import(), confirm the imported module has a default export.
    NOT_FOUNDA string target is always an entry name, never a block type. Confirm the entry exists in the served revision, and that the loader reads the content you expect (see Configuring the loader). To run a type directly, pass an inline block: client.resolve({ __resolveType: "seo", … }).
    NOT_FOUNDA string target is always an entry name, never a block type. Confirm the entry exists in the served revision, that createCMS's content is the content you expect, and that deco content ran after the file was added (see Configuring content). To run a type directly, pass an inline block: client.resolve({ __resolveType: "seo", … }).
    CYCLEFollow the references in error.path and remove the one that leads back to an entry already being expanded.
    My saved entry is ignoredA block type probably has the same name, and the function wins. Run deco schema, which reports the collision, and rename the entry.
    Blocks come back as JSON instead of runningYou called client.list without { run: true } (it returns saved entries by default), or you passed { run: false } to resolve. To run listed entries, pass run: true, or resolve the fields you need with client.resolve.
    Serialization error when sending a block's result to the browserJSON can't carry functions or React elements. Return plain data (descriptors, see Rendering), or use an integration that renders on the server, like TanStack RSC or Next.js.
    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.
    Content changes mid-responseUse one client for the whole response; a client never changes revision. If you wrote the loader, check that it never returns different content under an existing revision.
    Stale or fallback contentCompare the revision your server serves (see above) with the revision of the latest release. A server that hasn't fetched a release yet reports its fallback's revision, which matches only if the fallback uses the same content hash. If they differ: the newest release may be held back because its schema doesn't match this build (deploy the matching code), or, on Workers, update() may never run (call waitUntil(cms.update())). See Releases and deployment and how remoteLoader stays current.
    Stale or fallback contentCompare the revision your server serves (see above) 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 newest release may be held back because its schema doesn't match this build (deploy the matching code), or the server hasn't checked yet: allow about a minute after publishing. See Releases and deployment and how remoteLoader stays current.
    A new content file isn't found in developmentRun deco content, or keep deco content --watch running beside your dev server. Only adding or removing a file needs it; edits to an existing file hot-reload.
    Warning about an instance with different optionscreateCMS (or remoteLoader) ran twice with the same token and content but different options, such as another interval. The first instance is kept. Create the CMS once, at module scope (see One instance per process).
    Warning that interval was raisedThe minimum is 60 000 ms (one minute); lower values, from interval or DECO_CONTENT_INTERVAL, are raised to it.
    @@ -1585,14 +1645,14 @@

    Contributing

    │ Depends on no other @decocms package. ├── blocks-admin/ @decocms/blocks-admin the HTTP endpoints Studio calls on your site. │ depends on: @decocms/blocks -├── blocks-cli/ @decocms/blocks-cli deco schema, plus scripts that move sites off -│ the previous framework. +├── blocks-cli/ @decocms/blocks-cli deco schema and deco content, plus scripts that +│ move sites off the previous framework. │ depends on: @decocms/blocks ├── tanstack/ @decocms/tanstack the adapter for TanStack Start on Cloudflare Workers. │ depends on: @decocms/blocks, @decocms/blocks-admin, @decocms/blocks-cli └── nextjs/ @decocms/nextjs the adapter for the Next.js App Router. depends on: @decocms/blocks, @decocms/blocks-admin
    -

    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. blocks never imports from the others, and the two adapters never import from each other; if a feature needs both sides, split it into two functions rather than import in reverse. Apps following these docs need only @decocms/blocks at runtime, plus @decocms/blocks-cli as a dev dependency. The two adapters serve sites on Studio's current protocol; apps written against the API in these docs don't need them.

    +

    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 and remoteLoader keep their instances on globalThis, so a second copy reuses the first (see One instance per process). blocks never imports from the others, and the two adapters never import from each other; if a feature needs both sides, split it into two functions rather than import in reverse. Apps following these docs need only @decocms/blocks at runtime, plus @decocms/blocks-cli as a dev dependency. The two adapters serve sites on Studio's current protocol; apps written against the API in these docs don't need them.

    bun install
     bun run check     # typecheck + lint
     bun run test      # vitest, per package
    @@ -1623,22 +1683,32 @@

    Contributing

    load() from memoryNewest fetched release, or the fallback loader's content
    Content resolvesSame revision for the whole request
    -

    Background path · on first use, then every ~5 minutes

    +

    Background path · on first use, then about once a minute, when idle

    Ask for the hashA few bytes from the Deco API
    CompareSame as memory: done. Same as the fallback: use it, no download.
    Fetch, verify, swapWhole revision at once. Any error: keep what's there.
      -
    1. Serve from memory. load() returns the newest release this process has fetched, or, if it hasn't fetched one yet, the fallback loader's content. A freshly started server (a cold start) serves the content the build shipped with on its very first request.
    2. -
    3. Ask for the hash. In the background, on first use and then every five minutes, update() fetches the current revision hash from the API. That's a few bytes. Each wait gets a random extra delay of up to 30 seconds, so servers that started together don't poll together. On Node, remoteLoader runs this on its own timer. Cloudflare Workers can't run timers between requests, so there you call cms.update() yourself, for example with waitUntil(cms.update()); calls more frequent than the interval return at once.
    4. -
    5. Compare. If the hash equals what's in memory, there's nothing to do. If it equals the fallback loader'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, as long as the fallback's revision is the same content hash the API uses; a fallback with its own revision scheme just gets downloaded once.
    6. +
    7. Serve from memory. load() returns the newest release this process has fetched, or, if it hasn't fetched one yet, the fallback's content. A freshly started server (a cold start) serves the content the build shipped with on its very first request.
    8. +
    9. Ask for the hash. In the background, on first use and then every interval, update() fetches only the current revision hash from the 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 never competes with rendering, and a request never waits. There's no push signal: servers ask, the API never calls them.
    10. +
    11. Compare. If the hash equals what's in memory, there's nothing to do. 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's revision is the CLI's copy of the same hash. A loader you write with its own revision scheme is downloaded once.
    12. Fetch and swap. Otherwise it fetches that revision, checks that the content hashes to it, and swaps it in whole. The API answers only with releases whose content fits the schema the build shipped with (see Publishing), so content never gets ahead of code. Requests in flight keep the revision they started with.
    13. Fail quietly. Any network or validation error 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, within five minutes by default or at once when you call cms.update(), and each server sends one tiny request per interval, however much traffic it serves. (This is called eventual consistency.)

    +

    So different servers can briefly serve different releases, by design. A published change reaches each server on its next check, within about a minute (or at once 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 are the opposite: a preview must show exactly the version Studio asked for, so load(pointer) fetches that version and waits for it, cached by version afterwards. If the draft can't be fetched, the client serves what a normal request would get: the newest release in memory, or the build's content if none has been fetched yet. It never mixes draft and published entries.

    -

    To configure remoteLoader, see Configuring the loader; for what a release is, see Releases and deployment.

    +

    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 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. 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 configure remoteLoader, see Configuring content; for what a release is, see Releases and deployment.

    @@ -1720,16 +1790,19 @@

    Studio compatibility today

    The CMS is 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. For content edited in Studio, Git is the source of truth. Publishing is committing; the Deco API serves commits as releases. (Any other store is a loader you write.)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. - One registry rule. A name is a saved entry or a block type. An entry is merged in and checked again; a function gets its inputs resolved, then runs, and its result is returned as is.Saved entries are just named compositions, 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 entries, and a cycle check stops it. + A block is either a function (code) or saved content (JSON). One namespace: an entry is merged in and checked again; a function gets its inputs resolved, then runs, and its result is returned as is.Saved entries are just named compositions, 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 entries, and a cycle check stops it. The CMS at module scope, a client per request, pinned to one revision.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 every cache lives in the loader. - Loaders are one interface: load(pointer?), plus an optional update().Production, drafts, and staying current are uses of the same interface, so localLoader and remoteLoader are interchangeable and a custom loader is a few lines. - A production request never waits for the network.Serve from memory, poll for a hash in the background, fall back to the content the build shipped with. Eventual consistency for visitors, strict consistency for drafts. + content is a snapshot or one Loader interface: load(pointer?), plus an optional update().Production, drafts, and staying current are uses of the same interface, so the content module, remoteLoader and a custom loader can replace each other in the content option and a custom loader is a few lines. + A production request never waits for the network.Serve from memory, check for a hash about once a minute, at idle moments, fall back to the content the build shipped with. Eventual consistency for visitors, strict consistency for drafts. The CMS doesn't route. matchRoute 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. - Data-only types live in the Content type, not the block map, and the framework ships no ready-made page or post types.A block map entry means "run this". Pages, posts, and redirects have nothing to run; they need a schema, which is the CLI's job. Nothing ships that a site couldn't write in one line. Redirect is the one shared type, because matchRoute reads it. + Data-only types live in Content; page and redirect are built in, and you can override them or opt out.A block map entry means "run this". Pages, posts, and redirects have nothing to run; they need a schema, which is the CLI's job. Almost every website needs pages and redirects, and Studio's page list and redirects screen look for them. Overriding with extends Page keeps them yours, and an app that isn't a website opts out with never. Posts and the rest stay your own types. Lazy loading belongs to your web framework, not the CMS.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. Three matchers ship; the rest is app code.always, never, and date depend on nothing but the clock. A device or cookie matcher is an opinion about the request, and the app owns those. Short type names; aliases for the old file-path names.A type name is a column name, not a file path. Content saved under the previous framework's names, like website/pages/Page.tsx, keeps working through aliases, while new code uses names that survive a refactor. - 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. + 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. + Two CLI commands, no dev wrapper.deco schema and deco content each do one job, run from predev and prebuild, and take --watch. + Poll a hash once a minute, at idle moments; no push.A few bytes per server per minute, no infrastructure to reach every server, and never in front of rendering.

    diff --git a/site/src/landing.html b/site/src/landing.html index 4415cfab..e3ac382d 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -54,7 +54,7 @@

    Headless for developers.
    @@ -255,7 +255,7 @@

    Your function stays. Its i

    {{icon:check}}The key experiments is now a block type content can refer to.

    -
    npx deco schema --entry ./blocks.ts --out ./.deco/schema.json
    +
    npx deco schema && npx deco content
    {{symbol}}Experimentsexperiments
    New checkout flow100–100
    @@ -266,14 +266,17 @@

    Your function stays. Its i

    import { createCMS } from "@decocms/blocks";
    +import content from "./.deco/blocks.gen";
     import blocks from "./blocks";
     
    -// Production (with a token) reads the hosted Deco CMS;
    -// development reads the files in .deco.
    +// .deco/blocks.gen is generated by `deco content`.
    +// With a token, production reads the hosted Deco CMS;
    +// without one, it reads that generated module.
     export const cms = createCMS({
    -  // your site's API key for the hosted Deco CMS
    -  loader: { token: process.env.DECO_SITE_TOKEN },
       blocks,
    +  content,
    +  // your site's API key for the hosted Deco CMS
    +  token: process.env.DECO_SITE_TOKEN,
     });

    {{icon:check}}With the hosted Deco CMS (the token above), a commit is live everywhere in seconds, no deploy. Without it, content ships with each deploy.

    @@ -294,7 +297,7 @@

    Small on purpose. Nothing
    Content
    Your Git repository
    Yours
    Code
    Your Git repository
    Yours
    -
    Hosting
    Any JavaScript server you choose, Node or Cloudflare Workers
    Yours
    +
    Hosting
    Any JavaScript runtime you choose: Node, Cloudflare Workers, Deno or Bun
    Yours
    Visual editor & publishing without a redeploy
    Hosted Deco CMS
    Optional

    @@ -303,7 +306,7 @@

    Small on purpose. Nothing How it works as a whole{{icon:arrow-right}} From ab87a6664f35a85965c2c44b47372f94164b4684 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 18:18:20 -0300 Subject: [PATCH 015/132] docs(site): schema compatibility in CI, deco schema --entry, Workers memory - deco schema finds blocks.ts(x) in the root or src/; --entry overrides it. Flags listed once: deco schema [--entry] [--root] [--watch] [--check], deco content [--root] [--watch]. - Compatibility is a CI check, not a runtime rule: the claim that the Deco API holds back releases whose schema doesn't match is gone. "Keep the schema compatible" defines breaking vs non-breaking changes, explains backward vs forward compatibility (deploy code first), and ships a GitHub Actions workflow running deco schema --check (planned) against the base branch's schema. - Workers guide: the bundled snapshot stays in memory as the fallback; large sites can pass a KV Loader as content instead, with the KV key seeded before the new Worker serves traffic. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/__pycache__/gen_roadmap.cpython-314.pyc | Bin 0 -> 80799 bytes site/data/roadmap.json | 64 ++++++++++- site/src/app.js | 4 +- site/src/content.html | 109 ++++++++++++++++--- site/src/landing.html | 2 +- 5 files changed, 156 insertions(+), 23 deletions(-) create mode 100644 site/__pycache__/gen_roadmap.cpython-314.pyc diff --git a/site/__pycache__/gen_roadmap.cpython-314.pyc b/site/__pycache__/gen_roadmap.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a1d3477cc29d9f86a409b53dd603732d1442e0ec GIT binary patch literal 80799 zcmc${4OAT0ohRDW-84<}{S6Wl1xS1ekN{a92#^qCWl|?jYym*j4Y-v#Y^pU{|Bh$gT;#1a>v~OzfKIOT<-w(0m}t zmsG49GhvNk|qFg%rUoqzXx!jlL`)O-L6qgiIkz$QE*hTp>@$-<;sf-l7w> zO>({*p+G1U)(C5bB4M4dUMLoLp+qPZ$~K#Pxxxmtb>mr1C_k&;Z1ClswfORLbtO8X zB3~y|4i;=RD0MCrs`7QdH9|GcYlTfX7YQ{uuM=uztggRA@xHvcV0))*UBxIvb!m;0iNci51{u;`w^H%^C( zG8}PFxlo92-0I_P4zG1gM4K);eB&(B5w~Z$3c&1g%W!eaWhtQM(L9^mW#^~uz8c=^ z=IsPQluKY1S#NszDHp4|-B!m_mkhfvQQJg2ZxgMk((p9j-qT~D#?nKV+s8Wq0ns|* zb6l`D@m8LF&N^+v4nR6wJQXS0tw0vQ5zV>i1=?J6PdROXihBZGhpdnv0g%9f1{|C6 zd8T}noj8RIfKhHwtrPWi^37g5%0kOVoK~;5r4+bTt2`=g<%ea2^EjuxtX%J;)#*ef z06sfDWu4nT89&DR54rqm>OAC#}7|A zoVGg8H1C-7xJBRw!=p*JZOUn9jT%7}yb6FTfoIets}oQmRFdH(qZ-2rD&Hdlr~o_f zotm7qiqlON3y;GCba%TN*+|+vW_Jy#V+e(xsjYQS`S^VYk8}=Nx~jNPVmqOVT?`p1 zTYrk0md0Di;1x$6Co7L?tsY0@&xq6UI75Boaaw&eJ|3%|164-6Ap^b$8IUq$^7^K1 z4mS$H^T%f35(7tw8+)9DKcfzp!#f`0JdcwIkhLRjn?1x?DY*+bBaf5DFa&C?7p>DF z4%GtM`xEy$E&;JFh72Cj?$6k3M+NNsq+?99`Y_}|iL8&&utY{(sD&5Z;)Fk`hX@Sa zh7w2IG%S4t1o_4=>bMj#VqBoqBO&f+i0c_q=^b5_2}SoX4}Y%e26aA8&|%uz>@x_Q z&nW22bXA5>QU^fUDT;3Krmm|hA!HsJa#<(sLqj3U(2(5YxK9}xIzMG~DsR${@~%!^xQb7dPNbcG`>^ZvsyLM)z~0S=^*PA5rE6{MYG>_!;nf*nrbjU7xN3S31Vh zX8HIDlxogtImJ7V?NF+zOe zO)kWFy;KLK5eB>zci=z$y!kj?)qR?r{_?Ib?|RicSO2vO^S#T-)snG#b?u3JLhX$Y z8<5{s-RIR_;0BENf*su^^)7M+pP&~E17`I}H{;IKfAX~i{i?QH_sgDcSAu~2-FcT)Vrn|T>E&^UfI`AEd=qt1T znH~%^5Kp42pp706SO=Pzol|3u(P_-J9*@(3dBo@DLAEgO_`odGVbJe8-0?WE`OMkY z!A-3pT@+3cj0qhd4&zjcvaafSb^9|BP^>$Q zS4q0{7)52eXABPRCf9`#l04;d(3mE=97+PqG7Ku|8oQ|v>Fc(L#PZ4`hnGiAQZt5{ z{gOv)4dqi~>zXmgS)Jb2y6}tL^i45N|A+=i=`F9<&syI)ys}~E-3>dJN>(;BU*}%o zzFz;xOZf7o^_#i3Et@&N<;+=Yt$$1H&cRI~t~QjY%%W9EBIOrKauH+XbolKdy+{LM zYFH##h0If+N^FoMINcYC{+@F?T%iPv7>9=_00BP4`J!53q^gVE_`VrG?<7uFb-y%c zub9`}HLsf+eQWrg(Ql3}oLAN+d4Hh1KUjV$Xg+ zlfdv-6!5s6UaiMTCNwQyZuv&mn|XhjH*ZX0AxIS8AGju1oDg9>MlztzkEf3enbth?amhaDo$=N`7 zD(O}QdrfjoD)GM$vj{5c59<#%$>h(Y$CQ%Lrx#4+$Y~%vwWt%1#~_$9bo)9{J7b?A z@?O)*3{4f3TcBrRWYG3S3G$x@K@~P?U2BNyDu>5#7`;wpyT7aveBYG57(Wl^&{ZY* z7Y{-#Bo(GFo%xo46?We51;8vaCBR33M`Z8f|TS%Bn23LCdOomEXV zIxK=BMKeK!#40xvM2NE{xeH0Wc3CE*3XrU+2#NgU6pAHTY{cz!I&2Y%aI)@kioy%m z5qtAEVahjJy911k8B(j8>fo;oOH>Y9ODV=uDMn5y6Vhv4)(eqG{KrtiP!Qs<;2v}7KVi8IVag!mF+$SLes_r)?T}Jlo*?Z|_D`|Csw7R84DXlJ;w&&{pU*;EHzx>K& zsjN%t?qAM7`Ov_no#sBtKRH|Sz@SUdczNu_v03X&=f1Jw&DuY#UEsc(cr$U4`;H~J zrtxlCBhn#NLh=J0ef7YkTf6D%zL{MCWB#X>)R#71ZU1@3@fqWNbMjZbuQpt7dZkHP zzxfAcOUC!ASL*i%>i0{>PfGRsgY~C^*{6f)XYQHLeAxgtIaA<1OU|6xB^mP`{W_hp zW-WccwLQmZ-II;EHX`vtuxDa(WfgxJ(&PoJD64B)NINqHNyArnehT`49=j6)wp*;M zs3X8_#VGveApwPP-M46zNo^5+seH681?iG(T>mZ%ozFVfMcjBvOAB9d zps}Kgr~JpCkURCMTnnfjC>>?urPJoKYTs6>aJvHC!WF0%uBf_)D?|XRHf*kgob0Rg z))6x8MjlPPYs%@A^QM*oo!V4^y0U_y?Px_+oflF8n#1E(fof$8VzS!F^&`Akg>|(R z{3f6mplij;@Mr<1SbcT{j+GTw0>4*$N(!&X>Qcd}j2@Jy9XtdkYaOvG$flx9h0Ul^ zgI#zDnit#33l%kl(D49pp`wZnRm#{a)-jbC>o{cZ6>Hrt=XAv`dP4+P(UqWR$}N@{ z0NXBj07PxdO?*WYUmwPb-cHPWKCcNDv3O}s?-`s z;T7TYIL49MaRg--QD|#a?*c~@4I58ixU??`6@3mLNJ0zmi+=GOZ+C*yS?e4wmt8#2 zd-MoP6aJ@`>QT|fT7&%6ipu@Q$5MWE4Aw#EtIiq>S)vffqv?7!5n(cFAy$nM9Q#N- zP>wR)u3S8u(T1&}c1%(}7!O1rL72Tx$B11f%g8o5zc5uEy~MX-YEZ@=Wk9Op-z^8t z$HP9?>TglYIHg0G=ySW231I>=0s{!ufEf7 zp{PilX#-S~rJ*mHZT3sYMyUcQnclGStI*T6^~AVf7715^R8i9kqoquIFi+8F z-XdfF=YkOS4GevyEvPFxfO@3vLKT~U)fe9I3$F~;afi(YIlF}~al3Xg4$X~`O~x_T zrcEla7gg%88f1g1%|{B z!ffJVcy<>LFQ%v$G;^$blxDY*DQ7HxI30{|osrux2-;P{6Ca6`PvTmL>#z|-V1S9s z*r>z?d4xfO3do=VBVmj4IEKez*dp^d(TQ1`RC)z|kvKM3gJ=ZG!%ep8Fkr&aBU`+v zD{A;L7|Uea94vLPEXhPjh9eF4IKyi=8j8d&4#z{@(FuYZA?TA50KIE?H(qN;LWQ@2 z#uZ^2q0ew^=R`9v!!dk_Rdhf|fb{FN+mJ&KG=Z+MN>&MsHHNxpCI%zCh%)0uO(`f< z9u|xsYnU{duqeY=9TV#KCU#UA4Q^$SoN*7*fUK%>+FfJ5aTx{FeqwY^OKDIn8y;-L zvkJ(cfjU(5ptEQ^fD32CUQS2@KP-v>X!27{a-tBCq)-RY18vr^IaGAH)g0wPh=Df@ z%uw*ct8f-!))SUr4~XOHJz3CeMmMS0esan~fzqf`JRY zGUPlk+anX`wJ`arghVHgSfb{`fRG&=zX(bRzK4kiG~{BRRaUH;ePqzfv#NGZ1v5(2 z?93Apn6Y_ru^Y$>-b1FZl)c=q7=5KZWC_P$vNH*-O9iNxjI2|TX{bz>t+WZ&RVY$q zIgaUQWH2cjN~iLMBs<7y7aU_mc2N+M!hUDq5sMcKsQAFM%Ck1OHmZ~&V#{KJm8LO; z{v*E%V%gbjKy#=y9+;F|w6J0`R2W)qAlGOhL}V{SX4zYS1Y;Q%7*a(0M5sc$yVg%$l-%cbLpJJ>qJqD#_FKIyFC7u%xN%}dMtGsx`CU?729c7gj z>Rbp$?kOlh2%!!Mxtmc;m{|Nz#aWV7&qB8zqZ$h1tpdDwrRMbJ@-BI6h-y6PyYrUZpj5D7#d%S_TJ(9BMKqC(H|TopVt zEX|u0z1aw~g5krU+&#>I9^MLVVc1LH&wPeFog3y7xLCng0Oth3fU}8AroKcW0cW#d z!Z}Gu#MvU4aZVP>1q*Y|N)alAWOkP-q~J}OkcxAQ=esJC=ZX z-PJq~15Dyqxr6((H!cPnYdGVG|mXEA}1pLm%8o2t)edMN*@2M1(CyKc%lD zTMW{kFNNJl^R`QW80LAhJiy+d@6iBaK*c>>kB)4pu}8$&VIb@*paN+J42^>RX(>{4 zf+2psaQ!f?~&(y#N z`N2-3z{d#q7*#m@i}Fr*YTjYQ`xoVHdTQR`KKd8so%qze$*j$ee^K7%r{*1YHvwKJ zX;aF0{Y5ZA!R)XFGJ@Ih^Za6CWgu+7W7uwq!r7Rb?z6+rsMnOM?6JMT$D&gjCo%qeeDHSXzT2QY-XL9M@%hm9B+qynNt_rBEEWmyF4y0g%hk zeut3}cJo0=nh~Tn68)8+p+hI4Ya5|M9+M{lxo5`Yj!`Wic$cMp_dC+Juq7C^B4!LsxDDb)?j3njFAHy)!a~{sI)3hMp6LQN*A+K8>E}G;!ft)R%@BrGl?hJV~Nzi zFS$bbxLU=T;=bhQc570{)J4HEUfYYE++lD&RMG(YN=mOO z+=hI_?_0nqCuLr}J0hr|OHEf1lm_XiN_DNnyoY&% zji@kJJU||c?1-P63Xg$3N-`aTN%a@~4irE$@1ixEw5`Ga8twln!ocv{h5InANA32i zHHci5wpv(QEkdnOx4NzPvLo_^dKdHM=6GMCc3ZTyLwztd z8=nMA;Gg`=@ zR!7vnidko^($J>%k19j-qYCwn$%TFepRVuC3Pq zdAG8IVbG4gj8z!46VIZ)iN&C(+?%wyt6-|u@*kiljC+sCRoKO7LA)9?YiIIUnDwRi zWi*o9O#WjrM{H{dzK7qG@#OHw@|96ba9i>bfj)AKLl2|+z@hX(Cg@|D(4uWQdIlO2IdL^|lpR(nVD z%o6>jnxY0=wR#Bgpi$H(L!(^_jl+MZ^!d-_E8`P77@v@XHpRhx^ll}3F|99$@dBON zHmP+{-tCL`Zb}a<@1!w^>pnIXG46yL9Yb`v~kA%!*8YFvXNbj)uGu3~xkNB{i_vg@>T(*GNEn-bBoDtwS9!gMj=I~gx zh0Jswq9vL}AKX6SQEkT>Bs^D~LcRV5^-j*0EPc$DM^;i=l%=fEg^M*?0Zav9b*J@0 zDA7J`r!~NSH80+u$GhxyB*bfGm{U@O5}1Qo$fEeEU?~W^!R3X!UaU5Pl@t$DClEkR!3_1qdba*8qse36`DOOM~a*p?9?msd-Q=W*-T*VcwO z-k&LB4Delq8xjQz@Tc0Q;1^B9gAi8qBei%1C#-5_Yji^vmfB0HLkYbHdyjO6lGzg8 zA$e4$$$!-GSY-?u$ z=6dHV-WXi)EsQR3I3ji-%@?a~-ewgDIuVE*LVa4(*ZE^y(84GpNqrNWKq5 zFL|$Z+RImf5Ug560K-0|B4>7TW#Fk>XubUqe11Zy$~}m)b!7T+mO8BE$F)e}PwrvK z*%E}uxiJak??M(@q%cIC=M{&jV(bC?gxAtNw)}?oCsA_tmR5Y)JzhUs+7I$)_k@fS zFx+FE4{~DT(7X>~q)Dd0S372$RPN+_ob zrP%CFILskP#L)1xUj;17Tl)r-pF&&^k+rfo0IZh&6b%e`KRgpwB_VNnIMDn0<_@dShC!qF>BDUg&C}N);3NIaFWIuQHu)y_asA4j$5sk{3s1eJV216{Z zjkmNdvUsnDi%SQQ53Npp`nN=hwbV>7e1R{F@k{)?FSYXp%1vlU02ECBNCJgF@$zND z#wLH}bI-A*GOR0Dn3Bhq=J0aP$NJLN$HwN(Y&qu^e8{j@{I|Hnn%iM|z{n4&F2clz zc$%vC#0N{PF*!zv5t6P!eY`X#h!|z$&RVaJT^pOTe(jt>Sn}!?tCre+vj2nqx7&V5 zRAK*VY0H`AjDh=wW%F(C?7y*pq3v6T=9?G9#SQOQ->P0Z{*(R>`fnfq;puw~2c-Ig zcXFisE-9mnQ4D|Kz~uqCNW`v5K z*2qF?7b$AduHWkQsmC}he9OtZzU`H5(wc2chG6z?DP^}z#}a&Q8=`X| z_jh)6U|C^jdngg7p@SVgAp^V^#TjabvAwOgCzPdG2#oEnE$9ZWa>TE+jgWUWTx*}#tA(KjtRX8H_&#pGi2^L*xNZo1)Jam zI|W8Hi6!jl>}h8h8Zz`A>kb*k1irdON#ANp=_3@)7hy#={5EdQt23lO)>oCwuqnjZ7=DHHfH8N0g(E;b6|0Yy zONJ0e#TMd5L)=7&yA(1ETk%g6ze@1ohD9<%!P|x=0uJ$Ehyk?`{Jx2k7gDZnw48k7 zVh+X@h)@9OLMd9L{Skfj9q^q%($A_lEgbsO+F-$so3-=D=eE9?KbL>AHc+qwZaJx+ znp0J&d%u1ukX9jWYW`90Uzh&y$}mA7gGd3#V`^8GDa-%tHsYB0C;R_a3iT-F=8a~IxNcQ>#6R%#%(6)7zDpV|5T z;P(cDdF{6b7de)P<=&c2ck?&h8VuyMvrINOe)qX=KNrktx%u2e$(;U;gt^f-Qt#$& zy!l)prv)jJ?l*3KzvX)^!MwIxEelqbiTRDqck`=mwFL6oD38x7s}`#NG&PvL?PltH z{jBY^{d31(Idn5Mki8AB%>c|iH(&k7sfep`BXzERHs|GwGZ$~9VqxnKDgtRcrCsi2 zv*&(h)@;-Db+hYc+K6!~xxVq*#<_&A)vRPSN?DEb_3u1$QprUnS_WiXWJDxp}kB*N(9^ZK!gpD&p+ULTts zyWtPyHn5j@Yp$QWc5WrFJdjtul2`fSzL|Db@!3PK)y{7ZWK}PWFM1d4(yp#M=cVIk z{{Fc@*N{~6>~e}#PIln6O>bodvdZQ!EgBZ4q^+H|t#`~no%nD_+IV6)r7x1GAdpqH zaB^{E@r2aWD{YFr+)pfNyOh=bR`&ey`HOF#{#Nl1+Lw$!v3y|p{;@zw`|V3och8+E zsn;giN2M{>J-b`7d8E$s%PHc|b9c>hY%+PxHfNi4uHYZltT`uERS=S^=o z?&en9S`)}=Mm|aRckEoM_zO$0p#1~OV*T9KH}dC8<`2GIbGNYW152QQ1{aZpuh?d{ zUT?Y9vXWg9$gT)xR|V6m@76g2X^#78p5GX_B$4~9rFH4zUz`pW9{Aw&5;xcJM#X&n z{NTN^#=C18KR6vIJbSjrSrFo@9jP=nKvQx^tqhn!c9~{@vFt*5-JwfeVnqLN+|g(Gj}Dk zY&o+m{IYuC=*KBd^m4;z`2}-Duf2eLQYed}SBsRvJqy%*D~a$LEneF2<3t2U+0!i@ z7o?uk_l}?#A(q*lGV9{0AeYfb_MZuhHH;d+#ZhqzT&7wdK(XFKW#U=9%lDUZPXScq( zlPJ{o>uYA$%(UGvDV^W`_8LfB2VXom%djYrvi{Sx8{~1gnECPA#%qbQ-0ap*u#mrF zu4L}u8#N2b%Nbi~$l2z$z2yYv~7JQ5Q-o3Ik71-Qy$0GIgOD6~J^$beFS?TNx4-hAW zYt#Qmr_0={zh70ou=U;i#gfHdsj_L-cs=P_5;F6>Q60?K_%K;lw3Vo0(Ssaaejyr| z{Atd*Ioq4#ua7TqALmrR*fG;KbDl`y^DDXKQf|3ID=<8FzP|ICYr(vjxw!Sc{KcfD z;yaliB}hk~mCCKl8N>H8a;_&|OJ2z+4`h_jpI;~qX4I}^Yz<^=T|EAA#`cE^y1WXY zM(!p+KP&Hg@wMXltt(~E1j?RS$$Uo2d_k(YI?bnBsxt zlfq(5pcQXhf`yyto0kr;k|;GBQlAv82Mx=6V>(z+h4-T`9i;c`KhxlALE&7*o0d7t ztnriLjq|pJZErh+#f`JZ*}ku(JT&ME%jkeB19Qkm~QXA2=8|4d` z3r+8?3zlxbTe$thHGzU7z?jtgHMI-p-aWcfvpZ0;d%31<;V%shsOmk#3M#8D%v z9yxu?3nyY>l!Jv8>v}m^9jH}EM)K6u#;n#WaxH=1c3)WiSfRYAh9UwYwXu(5_VVz1 zILk93+iz~(yqg74Vlk;m0b`%^_z+-*t>-3Ph)=Xqs8R?+SZ6k3_m`e&ZXLW*Ub(xZ za(9z;(sRY>yE5jxLXq>XlnvN6Rqd+smt!#x%YgJ$2wFKY<>BSUjj%PicsXnnMG8!a z$g)hH6OW?e(9dJ7(a1Og$B;}b9;0UoY&loR2xW{<)gsCfTx1C@TNM{HipfnR+LG{( zfKBO{FJJibh1rSuhWYcs^r{&nNeWhq)bf&JuKq>WN?NIuRyv=IX>s7B*Nkwh9w+6EgETkWX-x@+B2`0%^qLa@ZIX0)q#?&FQ1<||9aVsK}vguI%(u( z_l$e4?A5XPjq~|Z<-t2;cXFlTV;`HlAN|^(OW)?TAftbs)n+n&!-P}RiW3?rY(T$) z8(M@BS#!cn%}hfVH;y5kZQ>1jO(#OaJ>LlTZ_@q69DRnfVYi1yJ-QGsh>FqE5Y+)( zl&+6Dw-LXG@PvS_FC2rg7zSOgM;{Me`l6f;qHP;W+`$sLZ0dT7JBaRl0oixmCigSMJAEDkQNT??NTvUWC2M=Z&bv!a)Zq8krXb>He8T9n9W&t7nm$?YMsE+M&7g zuN|GQT*=vVH)qrLdmgc|`+F7D9CTNDD)?PGy^oVR+!=1=(9=(FV(Cd&Agc!c0*^vz z(e>Fzg*W5HKa-gtdznfPX73E9?4055gS)K#c5N`7DGO%Xu6JJR1S5F-YlnjwJhTSs zJ0$atM|abAF!X)9sLg1M8pTvahCoDpNv(U9VU^-TQ|y|wRQ{30RdmXKns~+4rpDapQ7)7arGO@ z^$oO%8iO`5qQSb8ic?#lzi>XJMmnpzaK44=PR_5Wdu9&w0cWnAna_Oftdz51zFkVM zl+2Z@t4yPhRe2lxP>xZ{^xOC!dqe}B>9}bcG>C9y(}{mXr#Epzy%o(dtM`Azqo~zl z30S4-nh1p<&;IVA+GC`$+OM5!n`@jKp3iw_%?${zZ?BtAU+_ukTP5>W)`YGRE!D`x zS7<}Z!~dIo{zZTuO^A$@kP!_eFklQlt0p0nAJA9&xHFFcAtt|(_-}f>5N6mXvjoQ zWcrV^DRwZ~YoLxlh+rv-7XUK`!Sv7UV2WD-91IW#8%%*5W`VCPZP-eyk8SSKsycBR zE%fI%Q@{c>Mz}bM$kQNWFc>6aZ71He%Dhi%RO~x{iZ<+>c!@GvTdHOPE4~_NoTv5G z_7U-4Q;O_TghEI71Li>CwA+TKTT1;!wTu}mZM8Ie)C`msmA3j*nm2n|o5ve4*&?l3 zP0P%%epB=2@rG85Kbe-H$`EPAaC#4Av8a_*iEL;g{2g?$#$mT@nnH2IBZ4(iQQq2z zz36`;9Ff_XTnIwxD(O(NZYLD}Cf-2Fnv(JIffoRj4>yCP6}NIG@qUh2S%{(e^RykTx?{>Trq7mqJay?65a>lV|c-KSU1O-chp z(y;yG0lVa#l1^WE0Cyc(G(3!n5&sV9kKw`{)0f{bubl5)C?Xx(QswRX+xpC;Q=8ofK+rI*jTp zcG(5Q=(y-=*+)y1>--tb!zvm9_bzyVHFAW;3oQ(jLmab(4I{KEuwh&jzYBpT4m2Y)cUl=+^0vHwRsx3hlO zzjQEAx=-pkA(iw=eWVguIXMzIIU?EY_fC#V{bSPDgp}@-%+A#`+=$#+*AWe&t`qbS zBUc@fwY~s8s2J#l(VB#W`?L}Ku~&hcpRn6KGL-;3>$D^GJfc&{_I26>fW>!U+%0Wf zz(NziQwLi&FqasLETHTXATRyqvB3hF-eD) zhWnU#jzS7X@PXylMfdDaPZj zPpBZ5x$F94ALZU|Q?1BkpZdF8g|FtzIPw3X;+pA{g6|aOE0iGGJ#H`S-cf~tWWE}# z%uwd4YV=Z>G#>vMk@*L#Qs&0_(RU_pOe|CB!TMvt@?+A*Zp1&8(oafehcxEEd3C=l zRN4ISF0!)ftop0uXN&vT1A>*_s;-AWsMERi5xTsKJ8nFrv+;x{wBi<`n_hOV(Bj0t z6kTNN0+17|?@QKE*KkL3Sr_S#W|C)W{IAvdK*dUB<=TFou7?D;!(jnV9~R)?p;={! zLMp;ECcvG=O|w2Mz?I5K1;?$}X5)YKd=V|e?ZkTp(Kf6SRPUMA8?JlwXh|KYg(OZY*d@iaAL?mTK=poys)UdRDLAj0`q6YlYT0G z559N=Ibdz^n@xY%w4C1mdef}+yV>8#zQ?K)tm*r1_II7jXtCLvi(2hcM|;EMtwBN=oJ{d{MM| zP<=$zp*@0V<0mx$HLDAS69Su(9R*_$V^?Ww$gris_83Y>#&`2F<85vpZ)DsrlfWLE z*msOEW~smeH@1p|k8mtBAYMzz!fHK4L9Asz0|QzWtrQF;!d1mRG~O6ta+L>zgkGeH zy{h{si*CnGDFf~~u##?Hs0^evE)CBZ?q?RxmCfZse9N9snD1LSw9veGMN03G%pEd; zLk{6Xnb=Yuk~_?kc^3)+(}yKfstQ;oH$-98BU&b-3C&7QN4?~8!*Axh;dH}SQpd4y zO%y{6v5_bwlEzP$F*9|=HM?9zldnn(>C03Og* zNenaCz602FNF0UMSKhsf5QcK*t)mp{2_I14KZ+COJn<882)K8%Y}CYCMGV5p^dgCgKn1 zKTSz+YqAcD{~dP<^P}kT+UZ@1IvLsQha#?rCTr3 zLofIhq;%^q$nP%D!!hn6T`uY`(Pdiy99=HypQp?7`hy1KnR>|Z^K~VUyku_rQd{d@ z9rri^@N~Ay92*BK2S$x{7Z8&Sp@`;F{%?uTXQZ*&=}>74aG$b*+(a{y z<|(lal!Y>(VO%*KaY&FJ0AM6&2X4Y7C<6wBOqG>T_{|jJ2bE!)6-Ts?=Rv*9?jZw3 zVAGDOQgu}MXa+=7+tqsnohQ7>!PiV%U%KI zM3dmtnb;!?^&aau#zaQN4iwUlH;RqOER;Y589OWxPor3ZHdM>CQl?^T5Es@rDK(=& zU)~l}A>2J;Ie_mGL1!6)Hy zQ54>S6?2Y-^%JF!6M)ylt<=-QtvnMSiJ~*55on$6rXpN?f_uhpX@!d@VKz+9>e=W@VvS-SE9o=|Bh6fbbj*iUplX}@9sMi$}2 zh&yzenQ?|+5DTe}F~=9hJu-DN8uLxuXmGFNiz+l?DA9U`nc)hp#!MI_iTRB=`nbsI zbYP{vAwj`PXq*{VLT$&e(kSi*v}B_TsVus{G5iEq4dN;K6NY$Byo#egS+5d1g!PYu zZ%sBIqL~Y@#kt zNQKRtH8cqOI>pTkTRLk}B{EUi97Q>ay8VB`-e|o^M0W&Ys74{GBwbE7{>L8CxE;NY zKL&fD{bAT^U`BNDDzZ`?Btphx-JM+`@F^amh82BoxZ4lWx~VwmdYQ0+YAkDBa?jrK zE|x6Wesb=Eb9XX-JbCB9z19=Urv?M9gUflf@S=n3GYq7Y^9NS)YVYRNe&4I0<3?cO zyQMh!(~hAywg9S$2ay+fWImOO6C?IVNG(c_OQIehRUen)G1;InFZ6(*63WS@dc`tr z!-J3k3uP7MjxymVh1(cWEQTNXsg0xs2F8Dymb*-i4CbB>tVqoD0!Gy7?yR$DLhCpKK?(byY9XT>- z^O!tFPXc%iLgtu*d7Cg^qtCROqI(iy@rd&k(;CD}Zzh*649kRwdsw9Ycy1a14=*1B zL>$-=;b&Cs15zfkV0h*}Gwvxk2vnxAM^w%hq#^u?Iik`eH>7JU#DETrZz)U{mf8>{ zJHr;N$)^m{h4z2!TjscA@FaM!D8DNGu^u~MYx|K}%t%NtZZs3%x(M@yHtN7iX}?m+ z*RoO#24&F0n$M;zP8sxiWik_tX$;}ScxLE2>Erqda+KKJIYHM9hk4;tGez>#j2ZH^5~BvzKPow1oh z%xNEmtr2QiD0kt}V1vv_x)wzd>S=|EE4H;ZdFi946`E6K)`L?3<9d%!<^PsWBzCeb zL>c2N(g48DuEd;#EKElz>rxpxH!^J^|+_1Y3KhmJ`wmGDb`NgVNxT^sG%9vLoFnS8=~$(>s@MTwZL%`WC5t z2d3?veZ*k(Lkk3EuwQQ{W{@s;II4FL3)ZhcNB{ct6Lgt?-seGrKJ%jfpG>-)EmCgP z!iI%B`1xdS{cWNlb06*PcaS^I(Vye+TQDRE^r)TdWRK#$e*_=)v>i;A`Gb`9^`>_c zaf%{yG_|qm>@;rvM~W5Lms8Is6~(l^n$mYsh!;0_eh-SP% zo%Kkkox9~^tJ6h<-8~A$3JbgbQX&&feHrB^p%r=MA)QK$4TcO;aJGu%ElffP6MPp@ zviJi!?IRqfxmjISD-t0Kg$-%4@Hpg&L*-wjPhmM!r4O!P%O@pub8Qa{y0tAo5^i%p zKC!g_cEfGk-%fluAqhP{bxEDTS1h6!TN!Z$MqHBHb8qClG%QNubN33K|9MHBtTg<+ zHd9*?=daPIzezpI0<%O@FxlnA?gd4paVQ?Z37i}aees7l$x^X0j?HRYB1Sp&$-kwh zkfxF3l#HzFiPsW;qeFXWRImHyI_!X1u;rnS%iJ=P@L6Wdl6|TFcBhnnNHQN^=EYB8ha2IZ0jV}A-=LP8g(wBgTykA5z-7|?#DQo8SpGBb~q3X2D-Qi z8T+kb!fQZOdwdaB-c^|?sD#Y^1Ch-%tdY0_P)=fJ3erxJ35C&i+3^EjDzZV2%>!2X z6)l>ri)W&=!@i;MHaQOv$?~%wcmB2HVzSDxiG{bNt(AV08iJuJJejaL1^E6m%H~5l zJ;q7(@ctz|_>4|83yAk|QuKOZvTsJ-&@aLhG8{BteezX8muY+hY)MYLUUjW%IeG2< z{I%DouT9TqzxMo#2WHx4OYi4CvpBqz^WOO4=_T=YDIC5w-CiekIiz#0m2jj>}&Cak}IPbWRoYGOI(Fwa8tY>_b{^%OG9DL#YR8#^r zMZ_Cb-mqc7fP;?m*aWYb!Nq^7dq~V5__U_v3=Se|O-~L+bh8Yj}p+*XzG3SX~p?{S~%t z?B|47I3we-D;Io7 z8&qAP*P$dl7$V}KxbY}dJ4FkwP)Vjw}i zL64`Fn0YRlJxz&un#!K8a=LENHzuH7AntG_kJPJ&)hk1;myoFq0i!Nr5wg0W`iMRH z@|a&$wqj1nh<#7-V%5G-``cpzOtV*~6mkY~)u%`ihXkuRs9MD*)QJc$_P${To;ZEC zx<;U6;R8yUxGkl9bWkgr7GsXp`FY5+6u0CPNy=KVHRypPAs^`qVzR^EzNq~w(DMZi zv?8oX#u1MNUlzvg4U4cw*v08oI0o(v77u+LIoMx2&sYSLgArnqUc zG-)4WhKi%_k@RXaM!Y3RBRziY~4%jonptacHj!U`-FGeaKQ(Aw86i__7*z zaFoN{Cfs4M2<~cdhg~{wSBpFBx`4Yn+@-U-&A3ZpcU!u+C_E&cTFjyLI7?rTFQGU^ zZVk9gWp|A+d4NTVJ)-j1iZ3(Sm(SoXi`{L*T{gShjyt)89k|P3&vtf&x2f{v0yed= z`C~3<>dRHBS=hrg35w2U1R`?zM0Y4nb{@y<55~x9i{kdeIzW;HG~C`6SuzI)x4OjD zQZ*N!(~B0hFbE<>y#{5nQjB^q6~BRkWTE6u+>veMujt8N(kYwRkkx!&Sd>;HNX6m# z-lR_ne`KC7yC-m5xYIU!4lAtI-B`D9{$5GF#5XLKF802E>ei{H^Y0BTcHQ1^FK2)7 zxS%LcckJT)2YbPyX+)?fU)2ELNGTsiU; zE?B~#tXl}PzTT?UnxhmH#-x!y$Bo7@8ff|XnxobD7vCKM@=JJuJFwIkv5g>ooCzX2 z-^dWhf*sX!PEeUK#R8Cs?D4;1wF1flgM~Z#^g1GOYv!#V! zidq*AHeGPxW{M|T&m!wuPix{I0I$WRi=Dp+9;6>hkr~%*srxPc>m|Kt2OQ||upK)N;6j_d;a%Z~UZb!oXj{wq^I&Ui zixziC2Ny-kh+AT)l7{wZNEs;GlLS{7b-tK-kq8_?FXo6w1ew4g<>#UdcFNOtlTtM1 zSe2g<>EdvKvFWhC9#KaeieL>fnyC@6NR?TgO)`0FIp6&OVapsfwBIDlbZAq0_yWap`~$nGRB1BLO)8l3?V8fPu3b%7%~e{Oh=Q1c?M zJzC@cw|I?7crWEe$`^Tom#jL7(2nm==byoA#m_dc_&?>YV_l!V8^LmNetdDsaa+7o za%cbFSKlc@z@24}SMp7vH!i{(3SBgXJ_R8-FZZd3>!6S^@RI(ZiU()OL) zV*|Mym{!y+u<;-(%N5Uw6pjRXmcBZt_M3R^VpTxMG-lEmuwLk9TlWN<9;j~ zRtty%FOeAr-hEo|LiKPyGg_sha`B40Vzy3qR&`G;RDi8m#{uF6WY)zzR8@F?iYM_g z?tOwcNtZIbx8KN|Ygrs#;enb_XhUUqAoK`EQjx62F2(@!{QpFT>~z6+$e$ zu@d28*kK9i9ELVfQ`RWUsD0CbSom%iLU7u#Y!!aDjQpT_v`29N~H3Z zl?|_z$zpx;BWQ2 z_)c2;D=$O=vn}s4PpfBWPk96jb z08JY2IzH^j7B{nKX{bi^sKA;42x}M6qZ*Hj8fqJW$ClD5yF4`UJ<9eDN9y<<+G#-v zHq918VRbdF9ml2&Cb_GuSAc%2dt{1&Cjqw{-daju+IoUTQ=$hf1GbXQ(j3(X*t&rN zLlA=ynf_OarK%LE;wkY1h_R*MDlG(5kSbP8t&w5KY>)p#5ET`DLTw|V=1W_ZFzena zj|Z@nYfGB|u)y^+W+-kvHrn9rHV1a6fX#)iBy-s>`D!Pv=kOwIBxO~H85&{j(e-SL z48GF86Uc)`SD`zbNerw=XqA0rf@lV-r~Q(BWQx9#HwD4M!N0(K#;m}f z12uJombNOyAX;~U2yx7QrGkg`4;#shTma#i#}h1Ns5Ssk1eHv2UsYwKbQ)weD z7*UiQ3RGxCA-Z_PF1i406^5cb8srX_NvG9jLmW8lqvEUM)k?D91o&uLMsw}Jl^0B+ zvqYE^Wqgqdnun2lf*ERpRyk=O6)ZnQQmjQ(lpHoUnfOp7k0^G)UDD0?+&-&Q?tg_0 z%f>_KkXXGZ-3Umd*=qn#p|c5ZP(TzYG9AcGrTK)iN7<9|6E6YU>Xe59+g<@VP_S+q zEQESqiXcJx)X{zkY<&|b4Z{PNINhV8{IuOCuNB5>Cv*md;^WB^R{;rcf!S zEFx?n-!{c3f(7TGssIECvT;G?X=E6%k%sS}CUf)tKcTGbV{TfwS|?A#VKhWN0255N zUhH?|#V6J=tHUL4MWo=DoF%XkqY}lb_T4bVSPSIu$=DmtLGDNlHWejDML?bak7v{n zTEY$D#vqR~Q5Rqf+@ff}$vZHiY*}B2_*iI)?DF@Et8b`v!gPcswQ`lhSzu}AB!c7D z@rdxEssme<7E%pmv;ybkn3|-Kqp*YFdQz}NhIzyu$|d>XJ_srb(9p1)8m?vhmW)oZ zU~x}5ZG`(QXK^wz*1{@6hEYl|6uE)`HL#OY+uLrYxuv8R(?`I7@Fwo6sS;w%Z6gM+%*drn}YqsQCwZu0s9si(HTEF+>l=fdG z(pw7hUV=!PhWVXRRxN@bA9}BLu~^#EBW>s-sFW@cQXj zPNRYNgH^MKxe{77TQXxr_E?#NNTFC*n^#QfPQG#y>GFz^EWLQffCSr`-aq}l)4|L= zw@xGQVLQB0EU#PU>)%M7ePP}f%-VG8^o&W$+(Vs0p^r`T-Ua=9axkxE;o`l#ZA*E# z^*>&751Robf0~^)>%0EKwHJcf6@OB;U|Xr%6R6u0tZuuP-8N(XG%GidyAx_S#|?)p zD=(Mt29wL^Y@@yrW0XI*u!J`U(>^5dMO94 zvnN-2&jflAa@l^bcU0;@m?m!^%ZKzQ^!@so#QV8}(%Ipav-ZGQyEN*#clNwA2yetI zQucE*W<>lav{^}67f4wbOesdBFsdJ9`C#t$VAl4TM6?F`B-|*$1`5IKJ*YqZhun#M z41%mZ8QB36-mu9?Fk|;j!awP=GrUO;b#$KXoZbF_-N9jZE!vb+}ij$mE4~p4`>x~!S=PUpDO*s_j z(N53L62|oFpCx5p4+8R_Vgl6p4=UKl<+@>gS3>yDG5D__L03Y5Jf=&LKhq{ZCAeg|9C>!xG@TeO!k_p;A`(m|?lFg- z?J;|je#2^v!CR@z$f3bMcW~YpOs|p5HIIJX&grt+IJW-&Z_@d$B4d=YhKyCvFqt3D z;w*bu!)6PL8+%0T+1Q%S0L>(6jMx#!p^*6pA)h$*hlpkWi4KK^qq0MxG3-$Ih4NGN zqaJ;o4$*@X)%wM+M`Yz@hOU#_2lbo0^hrpF=NZM^8ci%RS4^nLNPE=s`x4?UEE3G> zdsWS7gNhp77)b~d8SC?8VV)PsHEI)ZMcB#|q)gQ+>=79%4bReJo@Mlz8ufM9YcsL} zTM5CDe6(7~1U$&L8gt-jJWr>D=-sx}HmFzFkFtRc2$I>I8IvLYB2Oq$V-GydjeQ0~ zT7r^?{5+~`z!YP1cJ!z$asyvG0UU84U_t1$#L+tBi3*DlcNL>`R7--D0V5y&E zmW`}8RLnD~4qI!U!P>FM$XZ=H_8)Qo1pi?1T~zHpe)4W*O#fck9;n~D&^EW>&Fa^y z=a0WxJ69X5->Z21t}o%l*O6Z+Ei8D$Y~!>03s!l_+hm2MCUzbLv=QH;+R)C{;@h}; zoYJB0JqQ8*adt#LhBa;yA#4=3_g=mC>K*_0KYssGxI8KTpkTMStW>m=V&N~7(>f&BPYb6ny7!St`naU45Ywg|-aQwe1P?>TZ=Mk1 za>n<Xt?ax%hDfTFXZlkQFi}p{nPdDHND#;9BUMsx@L}b ziwAnd;uFG-lfuaXA=@!rHWv{kc>}$A;qeRb6IgYlN^;o(=b`)F%$>4}E6b->3N`h! zHE_2BBK){m15FHA|Ma=r`hkLtf1+q2OW0gJ z&A)f%-7`YN(VtzI?iYa8xUN-b@Ao)Q3un&Ho_W}P=3$U?gOJps#!kh0g0<8su7XIt zXy&XXbia%(pq`Iu1!UCFFY%d)8nOYn2i~_qQ4DGKLPqV-VFt&uzyo(_G730gi{ocx zfZu*Xctr;escBXXI0(YpUqFvloos&m?0dY_L>8ka^j1SKZybz%F*5okApJu6h2C<2 z%h#x>4`qcyNnxN7c%V#v6pRvE(eQv4@0hfnl0h@_2xfTAff9mtr8Nwzs+V^2y~(Cm zaz&ho^`L}&n%wXOB7YM+Gpkon+5dx%jHp$!lyAbLIg=-O_p0?fDID=>0jtIe@RKn_D+K9WP)%5 zeo98Q(rine!3Xvw)%w>+=S)L@x%N|&ri9zefe8_{kNeH8J(C5W=#_m=+4ZVxRd5z0 zIiA}0O0Jl+V=gK6dgZms@l_KA6AiCyg}_?K-igVe7QmG_(M}KZWi2K{=zY;?su>>R zW)C&F4>jQqHKuU(9AciweDP0G)1d|FK2A2A@N5rPMNM9Y-79`|!&Vl`A2}x}#)@7o z6=DCi{cuN>`0a~g(uRKm7TG2g9R&}K0HX;N0RuzctCfxwjt!1=3TyUG@$Z~@>&&$C z#}}sh3Cb<)D7-^?PQo;DP&_##oWN9mLFm3HM!~(oL-+3*DIX`%@_$+qTbizWE3qUR zCojYq9%!g>*^D-c83qf5`rc{_^D+o8TTS7tj$nCiWu%3aKX(ZTM+o5vY17^4MHjnL-C z-Ywh~=3PT_sU45}AXw@l?KUm*27;Y(5(WdiX6=4Jo1!_OwZX&6m?2a*H*g5-C%q3fc8SoQ`NOq#6vt9qsWW}Ql=Eh?s@<{QeRj$yD*nx6n!cGLU zQsCj^L?BMqdLy%mNehex-s4*9D%ICgQ!&|UDkdk#Z+l9VW+DFX?M2|fJq=W`s$*kT zR%nBYl^1VO*`9K;;-;245tmevl>I67SiXW*VtS)LB!Cz9Mb<9}LZ*QRr)T_65DDs}uhR896q#dQp=nR4 zkzwUc+k5SOt?l+=AKA9BLF6d^gW4kpX)kHUPu&{2IW%$j*26D8JZ2Lz8>ec29y?t- z?fONN2&}*IV?tRY?lOZ@=(W-}@aqB8cUcdx;lit8hrW8s5^Q&}u$AN4(g%@DFxYe2&_fO29N1n1z(6I=7obgAZGC(y2qJDfFN!Cp=cOJ2r>L1jR zm_(j&73pK0;Rm3&%NUW2wMgnqWG>UglZuYgie3@@1AZmisrdDK@62}sPgFBsw#=Br z-N3`&?!bRPEHxiMAS%52ow|lzopx~2o_F}JdKV2Lt}%c zCTR{_X1!52#!0TGCr*9$z-0E!n$jtkdrj^2v}e+ugU2{fSDLS;6>>Mj3OQIT$$ghe z?%AHq6^0(F7($Uoxy$1HK+64vb~wc7^4jV6n0&6t3Z`5mtBfq%0#j+&JQJ+F758I4 z9d%fKc%?!}DIfk{Kv{3GGWtyGl=hZQ>v$I`2DR$V$bqL<^FOC{T(AnUCf4DVDr>oo z=iCElg%c+y6UmkMR4eAy`uCdOZ5A4vW)3uq6)i&faX=%@Z0ZmP&dSWwb%h*b4!ELf zpr}RdDf9gNI-d~*VU6)UT&Ox<6?HylmV^6&2SJ3c3G>}mFGBuH5;X`K&==Kv^@VvX zu;B3Gn^nu5IKZHW@oK@!lFTib4>$rf`)j&`t?XjW97@fMs>(e%^Nf4&G$4D%!zPMe z-F$oVWC;*{_Pnxls%WOLTv%T*9YdH4@0J6=mi!zaI{J%)LSx%ZRl88x0bk%VStrE7 z(+a>(TuB1Qj6eAA;X6w#WyYY`m0E%ctH*)4tbG784`3`XS(7M39vFBaDILMEB)~Qe z=%disO_OazWGyeBI;xM=@mKWD?^D2d92WlPs3r7a*h(TP*N_T|q^v`9Kp;PwT7`d~ zf&lIaV~o(^&Dhg9(Gp{X-C*1iVvLZ&jI`7Na}2MPSSG|MVZ;XCPH~`T!w}Twwf&tu zX|_uYY;9Yw1UDazNYH@uI~a3i-YC!aKDaXZ#_y1e^Fw@8(yB2hjE4MK|8NL2T`UQn zggs+LFKqwTb}?bkua-B$WxS=C8`ghJ>I_)i#UN6S0$q+bd=ZYJ`HCsm;@;-r6x|rhG!1xsRfD4(mROAZwG%Cr^wt3TZsh zX$AhQ5PNP;ecK_N8WJ3*h1fH55pmDPjvo3(;%J1hvP%GTa#x?Qq<=0V<|*gsh9_Mk zz2LiNR1;=LJi<^zbl4{mnsD>)BMs3}5Q>|j(E>iZWeHq;$Hb28e>!it6ctTK^w4xH zF0%q>ZFt|EIJCi&~{`NT3%|p(Ke+OGD`(Z+3@aPM=hC)j)Bj&x|f%J zkX1ZcJDXMfc<)r}bi#C~P}?e$warAgjcDiM^Tu1p>n9R~*e#P~KdgSOdb;Sn9q;ZC zjx>suP2%?Enb>A9%j1{B#r)}S9ueYJ39;F@5fweM~w=<^GXI31TTakMG@U_DpHi*T{GvbOf@He;O90!;yi;Mf{i#hq? zO6Vj(3Sbu+TY=mw>;p1s-)zsIy9XFX=foah(`hb(|Nc3h800mbaI;*?IA|BaI=iRQ zEImLmBCEBZW@us(K3S=OGEn=7O;|1|;dHv2JH_U1p$Se4xDy0nm-E7z8)wFy&s`Yn zpV&kG@wTBSNy<*6rDLw|w@qfgd}5+;YKc%-E=E-driz7dT(p8?88O}aTZC*=fg6E` z|AOTp4*(b^fk!~B5X2&~Jm3h3rSq|N2zAM(f>2QwMzE_mC=EkR3x1hDjV|8J}5Z+~ynl(Sq@9w~}urU#)$j_Bp*|^EN9~6tXK`$O&JP7B) z3+WKd5>9$W9VI1Y8hQyUYpGqUN!Ht_E*Je(g!1246*gm#nwRl^%~xhb&>Fp@DrxA?^am z*JtnNBfMXx1VnT57K)^2O~PNL>u5zsggg$@aLI|04E?7l|6fwTX!04l`UwS0cm79o z^%ez;P8aCPN0YnhC!@(dbj8SanhkQoCBpn8y2}W2BD(p%KrnCUXKFP37Aod%={5~; zp3&zV?m3A!L#~G9L!!DRZbSH**t8RW17bT;$;@t{F|743$)#z!$tJ=k;4u~p2 z<8=|K7lMf8+TR;>G0?j(8Z8ytVf|-unxxFJL*pf$<@ujMl)LUX%kv*E8u=nw?xbBz z+9i|?38&8h6LaSD!^r=%q}WlSl~Frn6&R+{Q3=!zNMS1==f$ZEyJ;jS1!@OQ^^$9= zQtr5RE;(&1ZEWwibH-$)74~MISXd?0)eDD?&(zsJ3DHC+{gZ}^ zZsSl^Cc1@EeAGHfZbQviA}&V=39H9<-`aO`-$dzms>X{azDyf zx`dcjW2c4a{P(gZ$|jdgnx^#b^?L=|;hE?o!+HYoBxT)F0Q)Svv21*U2w6E1yOFIa}1@G{4tgbAs?aDh$i@w7zo$4K-LEhnq z4(NS{Qt$A9G7-l=f^pOM$7xtUfxwjo?04oIa7Q|(oWZX2;sb%EMuh*)?EYOR10yR4 z=(I>mMpXkmcoUL~NU~?_@7k6M3%8!m9$0merJ~AW3o8d!H)&rEHZJU-_Y&VUm6oUW zRQTDV_uj;3aFu+~dnHClDi+!x=2ZY^l5&et7DdTkhvoej)BqieP zJ{`<>(9d`Z#=t&9cV8lU7gmJNQ4?@fdvW6LP;d}|#N3k+2dcs%EMMMggEi(~Yu-C`!rrGf21jdWIu{3*>X!p>j zvqVy$qZ4jcXyw`Curzxdq1|Z-^yu(xm*ghzm^6FD9H$tO$F(eXAu@e7Ji`;7!LrAs z0f3=K_aW?zn}wFanWiD}&}pIm401UuIf0LxGd~*wJW+ zJ9T(dz%dYIC3n?G8F<*DARfnLOCn;V#fJ$z7K)A4`$G7R4Ot{-0<+Y~`3Bhue@VVN z_zIzQnH?xugmUdWt0okKU)&)3U{ze>dOh;9`0`P%6rnKo!{i4y)TTqIQqTC(QiQKJ zLS>(F4^jfAEb=k<%mgADq&C5X&`OE?_Qc8FJ+aB^2cBv(_?s4xdWXCNf(X}U#9lJh zZ!Z}dREa;D^$0V4IcwA=dGUZcguxoE-lICgpac_cgG$R@=&U-7!E0snTLK)|Z3pdz z)K_Adnu5t1tA5MEUgC(T4-du*fZ_@O7_IS*OO@{#xT{v@iMhGq zui!g*{~ddx!oNVy%aLD_IzQZ7fqa4}A|Ph6I;QW@Yt_H`f57`mT_E7FuCo>d9S2?oS?hWN`?MDN z+6g%YnKhJB*~|gFn<4@?wQg8%Sio7qI?;o5B!$)tiOaIlbtZ6!*a<%|b<7l80qBAw zs0;??{&sS{!7V-_gD!eGtw~(;1GQMBa1&7DCD(i0e_3 zvoRje6fRWAi*=HC*q`61d|G*yivvgR3!PIodcT8zK4>W|Fc*WYehcCOLT|x6ofm)c zkkl`%UU?my06loaxfV2CCty6#b|C8%2uHpFBl{0VNmN$+hiK*VC@t!Bg*kz`^30(P z5d>L&_>1$|3hKJdS7-r4pyO;1e%3}J)&2w-C@6b%+BLt4PaolcT+hAGexv;}pzSql z1@o3S(kE-*%$!URcC~uiV0+s&)7CAt_9&2gl0V9eGmeLZ0V3meDPWBiM5FD8SOH`i zK{w*pJ~A!UKq0Y-5nIiGV@to!cIjWWlq7T4O7sX_e9%6N9lFvaU)=Bj5@K!I`NcgJ z`T4r5A2*UlBkQUE6BjC3@(Jpxi_4PL^8@;wO2G+|;~=z~e{WO9JL|EFNSXuQpf;m? z0Qg7M8+`q`_mA$^>gIJN2Tlo6zgBi!67T81qGR(U(yWG0kGIgKlJ3Bd=;|E`rYML- zgdkc3{|&ddP+xrwk(9n#t@R>8Ms1y3`NQ1Ta;IwF%$rOR$~!$>Ct&q5dvefylDX|T zc}DCyD|DU{&YefsTSWF%LH+Tfv=g`>DUnx#c*fcoXqVhB*(JAAKkm?WQo06h7wt;w zwB2;ms_mgukM7a5ZF<5+L39UL`B2_$dM5{iJL%;A99@ z5+=wRKvs47`v7CgLR|ycFf*)PnXAlPP!L1+oD8{>nq37DD_{`i$k-^@AIdORR8i)k zSyqg}3z_~H-It32R3Q4!}D&cj`H<;!JeHZJXr5jlsppgM&FX+mXJPC8bu9 zR!b;A?3N(Q{*}34216deQ)6zX)ZW-S8Dfy~da(9)q&uLmM)B*Qrop}+^}txA%`p=y z@QnOJG#W_}#cxui2`m=>_h{H|*It6A{~`)8epXffI$i1s%x9ziK>7G-h^q5>pwxce zlnSBAq-c#@LT&gC1kfd=8HRs@u0xnb%)G(YVlAnzWZV#GYErO{{~CjF-s>i2v9(s# zE!{`c*B<2i&zCe~d&i4zZN0g5!v5VIlS^k-@19v%`u5@J_V-S_djh&RzkpNG_L=IA znLVA*rI_vJ-Q7^Z7@X+_pY_5__eFQcLyQB#lc}SmmGd=Z?DwVqU+RVN?@_rHQ3#`{ zyo%alJ+%chn@wqTAGU+3JC-|MJ6<}@e?McQ{nh^4{Zq9w8~2Ip%Y}l9>A0EHYVolB zJ}*$-v^cwB2>ZGR6sQd7lh>K&3X_+8tolqL&_zxmyNin1*M-*IT3q`{PN3vQk( zl93z5vVTPW5?&yg-(I9w0UR46LmFW~5R#F@BoUv#;VtqbG;Khsve*Rg1!s)`V2R2! zoR`PeMzZpffDu)*2qtZ0#Q%b-%LE!Ef#JVOrCAb48X+!ZimqgZG-CLTK1C{UOFPdZ zFQ!IC!&gFEkM_2kyZ}jp9cKJX^rA~g)AJB1;$nKn!UFHSJV6cS#Pb5>@?8pkNI?|^ z3@?c-9=h_-)h!A(QxHn`nCUPX%<=VfuZDut6fnV$MdCvKmTnMF zab9=2b+8|bSO}TOXFdeA=Rc(9cPMD5fEhXy*PZngp6WcW>p~qE%n{G@-ibvNppdmo zLOGg`^c&0b@j=PyLh8i72?4nd^{k9F%`ws|bu-5J}i6^#~*q&=|`m+xD) zLmUKfn##+SglVLSW<+t7gn&~8A;_mRBnZJA!5Y#NYI>px#&q}rNS`Y08bbbA!;pWX z8FWW%k(D)g!n8W%j&7K2@S_) z8;-jhj*E46!P*M1kZoKk^t3`E5t;~T`@rP`j~<*e6FDWEa6r@N6j#a3RUVwq!gVbo z-znWYHTaI}E!U5C3cKrsx=uKujO^w9;J9M>hq!5pWOWiRxvRW>`Tt2hx zq2Y2*#KUvY0y92cHN0moHh#G5!-T|&K72p~ZU-zq#$rC}C4MYE!{2zq34f^wijrZ=U-` zv5=T6LomSo3!Dn*ESQOxJP1GmjT=cQS5N+i7#r`_R|-o6K)P@9Td~9Jw?Bc!{apy z$!k;Yq>y;5;@fM)l(j-K@OaST@f&{!b+2vJ(}UBUfKhU9IU;oR{2upDav$}%=;A#0 zPaz?e3*4th&C=yixr>Y;6!p27H80gqWPaz^_<_loH%h1UukWASj@OiPS$DE>#xtJl z9y>aI_6_G$;p=Co3*NZ+T8Ln+7dAl2bI8h{sL{eWzxQof3`$CrY9HQI-pe-8qvi9F#0aquKs6v`Tyb-acUR?6`jqI&Te} z(}Jnu*~qGGhPRWJ9pFrFZ#5wFTW*Q3fvLcBuN$)hnWclDgXt&mD3WjBDPjj6s;BzE zXTiz7w7XO9!zco&@31tI~OS@xH9BUd(C)w}F=(rAmbCT^1;!684 zY13r)Spp#ohWXk?Sbzk93KY%m9^fRaHe)JiX&_UQ9JR+mc%5E^QSSzX^j`)C-~|z; zI{Z^qw;#xQJZyspx$RJ&gF(+KaHjTNLVtw&L_17mq&L(4kf3{0CFow#)`8sxjB46@ zVK1|XK%5`6qLkH_R!@NkmM~GJ_uvhZ2@|t7V&*}N6!DuhXg5`e{~%U^|5gwivd&+G znc}m9g9Q-)Sc0$xNsB=RYZF;?GjXhBxcy51<^IvyS#yeDPWeuvgjMhdZCIE@H|iVv zQ&(?yX~VX=baaW{`2s4(U!j2J4L4&5BM)mSF^jquQP-d1=J?ZCubtc;ibf}jwwFaC-z4$CAB4_G z(v76?4l%6=`wjYxJ!B#RNLpTy!8sJ|tesQ4=hewCT+C+1%?@q&L`&g-H&W8GuDdI}_o#sfBO$E1rb|UBmNUYft zYnU&+svh*k0uEZ0uFTS5jj-md`(?U6!KnlD-Kv zvLQe7eHmM*@5@57JQQD2W#4^vGjYRk~FC00s3y7s>PyIxkfcYM?yLnlt?_eVf4#6O?fUm zM@?6L`_94Kar#2bLQTExse3UCCreC~V<*Dzi#8cCnerePXGNXxyq~Z1abkT>|~cRB5e)uFDcfJo}c} zXjLyU5J7PD$mPxxREf6!_XwNzMA%dewU$ zd~_8q%*~M$d@O$vh2mJD>MKdsBDE;A(YMNPfc@$UUwf;|uljZ!@;6GdZd@dvO%Ie$ zi8r6ki{!KAf$~9rV|^ycx^7zo<_v z{LZQzOIVJW#R89osaOejt4ipd@#~}NQ+*@HN1dte`Bo}r0@K)*D{WGrP@lBO{0gmJ znqM&z$^uIW%^%#7=TFc|Z2p7|FMWw2)%_O3r%Mm-{Tl;Y|YQz2>~qoA!$D5aP#`kUVE35 z$MhHkzLc2tvG~77;>zvZgRod^eweR-CqYW(sQVJ;i?Z#t`sqQC7KAG-|NOc&Me8nR z@7(G#<;Xv_R{uSfuo|p45GWenj$odv$kEP+I3RMZ#!tzN_L9Vc)Ae`Q`S~P8SSm5^ zeB4EP%43~p09*eInQ3hN)vZr#9qk)$n@KL1sGLdOITiOd|IXlBgJNvev~?!7Mu@0k ztf@a3RyiFVZL6TFB10&VLpwM|JUa*vYTEPXUD}HmT`S<-a0rZF2ROapd7nQI&TU&q z_dqY0oOrx^dv^D+^@T-EIgPEynv0uqi?i{?a<;E+EGTvz-=5t*ctKJrywIu=!=HAf zUkHRPA@j-$0k%jNyqY&1=Q>)UI+UK{_YzkOQ%CH#Lo*@!SaW()Zcci&E3~n-%kFUQ zY;1MKtishQsvnsLLEmX>Dt`=3?27U_g@dvKyu7*A-~=To^6)g!+e_F|%;}k=76qdu z2@Q(z{1F@d6Od43&qS&c?A5Dt(p_QlU1S)XCvC&Ky++tr#!p#58YJMH?yt?GjB|N_ zqCb1_%0NodAg#oL#FleT_JezeLvPAN{^(gW$uYq`Q*w}1|0JF+Uy5fwZgpUP+73J} zd~Ck>GIgqS#Fc!v*1FRQM@xQCJIPH|zLWn}KDlpwt@d%dm{usGoWD z;)#;=)*IyKzs|!i|25Qcfsr{MhN3=7#l?Il6*pmZU{UYZq64_UFq`&m~$NUA@W1_$64nEFyUH0ekP&;cDgSA;UUsH?g*l6sA7ZwHHD z36kDV^8|OvZM0-GEjf?xE`(Dl7*RW2$xL=a!&JfqvO@@imm-Aswbbl+E{AEBG`2QC zJ_9dgLqtT`Q&-79^YioJj%-ybjMe#UYLAF^AY}FQGBGweU_e7#C?m;Ke{$=u$0R|HiH zUxx4M7<9U#eKwg=b)gjRuMio~3R3gSZQzRTfJ6^qbd)7?edx*vG;@?E(qkogN1Kk> z^IT1y zGBCZwz471+_HWr=w%q5RL`L&^X5bmT$a~dArfK$H?fIJj^9LyO4ZlKXTW^<)pAxp! z%xhaydC~H6iR`dATxBTw?_Tt$AE4;p@+*4&(bv)@YK4-+vpbKtcOJQQ>cvy9 z%Pz0y^~^~3-@VXZe}Fw z(Ae)&V^k=P;$zL*n{orEXh{pW7?ap`3>{jHNRVHGrGVuZp;cL`#{8#YR@nbiB@V`M2e6eSRPQ_zJn-q7uTe^g! z$DmGCFx5O$T0gtMoR2)q#S(P`$Rq zwTw~R?Hw=`0Ksi(kI8~)2WT`cCmfj% z@;pWQFZAer3QkCmU=iocNtZNENyT8^c)G{g)7sm?8>pmu3Lc~QZ4~@1CE{AgjLk5!M{M3WPg6bn0n+?B)}Zm{0;4b;LdoJ{+G!S(2jaydDF;Ao| zte^y3H~(wOE2P&sIM3Eyd{(yZ^2wC+d8!9(&?P9s<#dm2%+hhMGfTldS>(KnZQS_( zMejHJ@7wy3lQY#Qfe*&SUCnwTOITJgv3|n&cKSOj-&*O3*$01$fXBG_^^4D1uiKun zjqRTZnP?DK>=2`hhjqW#aiO)`J(DJ}L3_DkxbzN?16!~5JkbL`ob?k$lbbz-r7x|& zRrF%fv_)J}>4~X??&tJr;ZV1`2d-vLi9Ni~?Gz3Seh@MAX$a-=G0+U#M>8%D%$f@X zbHUik@wOXlg+i;xRQECbZ6-dEC?sv2gdd(=(6%+1pGv=)`vjc+Xp)(qnNsau(mjmb zNA@_O`?M_1^Q|wO_|^%bVDEH@C#y<~IpEH!8ncX)jix=h1()ua12d)r!-ij_t@6aw z-B@xhZq$M@Z+UUcMC02VrY%3-{B!Gj_3zftuC`t+7%8~1WZ3AAsRQcb$g0Oq3M_3r2$lwsy$J)=9vH;k_mmaX@g3hzWO0kUG@lV`_PzL0k#Zzf@_ z7`+Y{G*V1q`MzoW)EQyfL652C4n7_lLRhh3!YRgV!$(6qc0HTmHm8p5A3rnRBcyNk zn6?0J5$KWewGzUh-t!r7_&IQ608{Qv%tjy= z*3a%Z=-zQq+)^_WQ={krK^$lY5VOFEZ+D0-TJu$j>*4*GRH;lEBvr%E4 zP0XU7!m&0_I}j)P#P)unZ9o_xXhT`I^E?N)mINBR-4kRk^sdPg3bwbM+{dYa{ zq#DI^!S@0^hw61mdk1b|U0-(ziJK;4#i$*_I=mgVS@~$svxTFB*In0K-`MFk=gPHm zP85CT929kE!uabi%XR>tWA?2^j~4+In&6Au-w}eTVju4Ue7# z6wkKX+hz;P01)pf-0N9UZrpw!56x9V4b*Yl z(Y``;r^nRg{lueft)s%a<3ekPC$rO&aKW9~Ioc*T zoU@K|ZpXO~prw7X_ZiOZIDd`9qu~g5!Ug&^y|d;D!CZ0O`3rR=bTr}V zwa?eNiC7o2tzxFt9b<)>v^UB}h_YfPX4OlDZla3C{G;OPqwbiapX+^b0LT)%OvwmT zsF=N9%-rve**^`#qZZ-taiPlonK2}^jr+``S)F$)_eGjQH!?N$L)ybswbe%`Ty~VY zC9ZF*gf4pRsAaU|nKjKm--Ih(5yjI^> zJ)2(SPA~GLZxk(?MDwOM3f;xsOnUAg zf%mv+4)@*r9OAB1z`b4K$NMiRuyUMR11CBdQIWfd$(zusU z=9t-0H~k!>;020ug#wbcNZO}NLzbBxeMV9Kg#sdk_*_Jpx3sh{wnlqT+hAKqZ*NNr znE8>an=LI)MApa@R5xWy0a2>Xy9oaI5zQsdU9DEX^ln%mr!VF1Hizi+CEB|YT74~d zH%zPFa@WM^x7{^x`Wy;2d}h$=PjH`^wfa8pu1Tja=k7*uxI+&QlQB+I!KHm4=`HuP zdi@^mUWmpJ^9j3zEr%iIQ>{UtC*Q=?C($ALGHz_>GYy^ZhH?6R+Pem=zEpeHq}2~% zbQ=L~dN)R=uf%Kg`joro5WNGJ8}xa19bA}xzxHmTUjHz6H#$qdpSx@2V)WJA-P{Ph z>N^h@Ri%ltMhDXhgcdSqwJrC}1l(84*gE3^Jm`2|{ZO*?z7i={ zO1;n)OZ?(06;~qStcsi97k8s7l63|E}WrecaYrmYVD84 zJxUD3-ltsq&bV9d`=wj~x!XIF_GSenzT?UR)X@N(5W(__t*VOH(Yxf5 zRE?oP16-p6#FnnRqt`JX4u3`i{hd8sLwv`)k?&w4eLOXE@caPp^ewM00~{P7ZWI+VoijPxW^;DBb9PQ~Q-xyAJ|Vk&nwv@6 zFV-KIWqPagImVpiBajheNJ1){?FCkGg|2~2bf~>&Aa$Nw?TUnz9bmSZ1)VdMelpua z$@VRH%l7-XEa+=B``zaO4nj^Z(T61R5gZ^%>HKm#Lk(tu_!Diq0eMJ_;$PxQY2=S^ zJ+I~aWMuPLya>Q@i&$wdHDFHps_e1o}nDKZ({9SZShQik2-0BK- z0H>826PI2g5aaSHa%TnsR0cIX-!(*} zYz>jG{JgQHg(Lwjd@7>OM*te91#wx7`7oA*emm!lFqgCs4)XIM&U26=I0xG?dU>MG zBr!U=!vb~eUFNb1KcW+PYysicp^I>efw~(IByxzg1{OoIB^a6YqHK6^Z zgdM^5qVJ;7RgC-}&`-8MeVwk@{`4)nVk~{Oi=4tw2uHMb$Gnb;FYQ%*JoJw!`c8Ta z@yMC$E{PScAU)`4Q1J&5<7NCiNjKv@RFgt2v!SW)ho-*NK%fqj8^yfcQw1}5dpx0g zFYTVw86G+KCkIFN3F)<$4tjLfx$wv8i7P03?Df+^W!T6ZTj`l&eLSU+2Kz4}`9#J;{Q z9$kH@bj}$5$k|8EUIK?P>Pp1dBSz}49)A3=XiAk z@pGP3yHI~fu(fr^Z}o}0>xB{k0@yqaM}=cev&T-lkDU}7dIejb(C6};C&%`?w7Uu4U>AUVKb214 z)W{fH=izMzMwM0CrE^P{UpHMdc~Z+K`5&JB!C5i2TwGc%BvecE^X6hORYm?B_ zDRgxUJ&u{KQ$nYNIgXtOmeEoR(t(==V$gJ{qei9vOT&SG_nx38qhcAzuWwQZu|Xv0BG39-JefrV4kDj%}c3Diuh@A ziJ>apJa1}gp)L!4L&T@q49~}3oE{fCLO9i<6hnjHqXZ}zV`k^N5Lru)zXi`2hnjg9Fl?x-~ zqH)EnG1G0#6pgE9jrne4{v{oT?QrMUE{;4jW6Z=aL)h@4uk~IE0VgN?A?}h9OjvX5 z$bqp9<9U-yh0;SpLzB?bCq(pL3jN3sJ(4kUeyntS`9#~~#;L;TaCkl!^hYjf|IV0q zN%s+$#pgydg|xznBU7Q%8-==N;dqY#kh{|aZt;+WLSovZ#cLy?Ml2(x;LMheS%mP6 zOQw$^qb`MgX5hHckudarE)={VZY`KY+-fkKxDxKJnd6G+vIdvoT*4>GHZCmklHqe+ z>ojss?o$mrebyVLPublishing without a deploy relies on editors only using block types and fields the deployed code has. Studio must build its forms from the schema the deployed site serves at /live/_meta, never from the schema file committed in the repository, which changes as soon as a code change merges and before it's deployed. Until then, an editor can add a block type the live code lacks, and it fails with UNKNOWN_BLOCK. Update Schema generation once it ships.

    ", + "features": [ + "admin-protocol-endpoints", + "content-schema-drift", + "studio-schema-generation" + ], + "docs": [ + "schema", + "studio" + ], + "pinned": false + }, { "id": "roadmap-api--add-a-section-renderer-and-a-deferral-api", "group": "api", @@ -740,7 +756,7 @@ "id": "roadmap-api--add-remoteloader-options", "group": "api", "title": "Add remoteLoader options", - "plan": "

    { interval, coldStartTimeoutMs, channel, onUpdate, onError }, also accepted by createCMS. interval is in milliseconds, defaults to DECO_CONTENT_INTERVAL 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: requestIdleCallback, then scheduler.postTask, then setTimeout; an unref'd timer on Node; and after the response inside ctx.waitUntil on Workers. Instances are process-wide singletons keyed by configuration, with a resetForTests() helper. Also ship a first-party kvLoader(env.CONTENT, { key }) so sites don't hand-write the KV loader the docs show.

    ", + "plan": "

    { interval, coldStartTimeoutMs, channel, onUpdate, onError }, also accepted by createCMS. interval is in milliseconds, defaults to DECO_CONTENT_INTERVAL 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: requestIdleCallback, then scheduler.postTask, then setTimeout; an unref'd timer on Node; and after the response inside ctx.waitUntil on Workers. Instances are process-wide singletons keyed by configuration, with a resetForTests() helper.

    ", "features": [ "fast-deploy-kv", "env-vars-and-secrets", @@ -753,6 +769,24 @@ "docs": [], "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": "

    With content plus token, 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 Loader as content for large sites, so no copy is bundled and the fallback is read from KV only when needed. Ship a first-party kvLoader(env.CONTENT, { key }), and a documented 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.

    ", + "features": [ + "fast-deploy-kv", + "hosting-and-deploy-config", + "in-isolate-caches", + "content-storage-and-delivery" + ], + "docs": [ + "tanstack-data", + "content", + "releases" + ], + "pinned": false + }, { "id": "roadmap-api--add-session-region-delivery-promise-and-prices-to-apps-vtex", "group": "api", @@ -1038,7 +1072,7 @@ "id": "roadmap-cli--add-validation-commands-to-the-cli", "group": "cli", "title": "Add validation commands to the CLI", - "plan": "

    deco schema --check, plus deco check/validate reporting UNKNOWN_BLOCK, NOT_FOUND, orphans, schema violations and route collisions, with an optional resolve-every-page smoke test.

    ", + "plan": "

    deco check/validate reporting UNKNOWN_BLOCK, NOT_FOUND, orphans, schema violations and route collisions, with an optional resolve-every-page smoke test.

    ", "features": [ "orphan-and-dangling-blocks", "content-schema-drift", @@ -1054,6 +1088,24 @@ "docs": [], "pinned": false }, + { + "id": "roadmap-cli--add-deco-schema-check-a-compatibility-check", + "group": "cli", + "title": "Add deco schema --check, a compatibility check", + "plan": "

    deco schema --check <base> generates the schema, compares it with a base .deco/schema.json (in CI, the one on the target branch), lists every breaking change and exits non-zero if there is one. It writes nothing. Breaking: removing a block type or Content type (any removal, since it compares schemas only), removing or renaming a field, adding a required field or making an optional one required, and narrowing a field's type (a string becoming a union of allowed values, a union losing a member, a tighter limit: a lower @maximum/@maxLength or a higher @minimum/@minLength). Not breaking: adding a type, adding an optional field, widening a type. An alias keeps a renamed type's old name in the schema, so a block type renamed with an alias passes; Content types wait for the runtime alias table. The docs ship a GitHub Actions example. Compatibility is checked here, before merge; the Deco API and remoteLoader don't compare schemas at runtime.

    ", + "features": [ + "content-schema-drift", + "ci-and-release-workflows", + "codegen-pipeline", + "dev-tooling-and-repo-hygiene", + "content-storage-and-delivery" + ], + "docs": [ + "schema", + "releases" + ], + "pinned": false + }, { "id": "roadmap-cli--publish-the-tag-and-widget-vocabulary", "group": "cli", @@ -1191,7 +1243,7 @@ "id": "roadmap-cli--ship-the-deco-command", "group": "cli", "title": "Ship the deco command", - "plan": "

    These docs run npx deco schema, but no package declares a deco bin yet. Ship it from @decocms/blocks-cli, as Quickstart installs it, with its two commands, deco schema and deco content (both take --watch and --root), and the other deco subcommands on this page added to it.

    ", + "plan": "

    These docs run npx deco schema, but no package declares a deco bin yet. Ship it from @decocms/blocks-cli, as Quickstart installs it, with its two commands, deco schema [--entry <path>] [--root <dir>] [--watch] [--check <base>] and deco content [--root <dir>] [--watch]. By default deco schema looks for the block map at blocks.ts or blocks.tsx in the project root or in src/; --entry overrides it. Add the other deco subcommands on this page to it.

    ", "features": [ "codegen-pipeline", "studio-schema-generation", @@ -1244,7 +1296,7 @@ "id": "roadmap-platform--build-the-deco-api-release-service", "group": "studio", "title": "Build the Deco API release service", - "plan": "

    A release endpoint and site tokens (with a DECO_SITE_TOKEN lifecycle), a schema-hash compatibility input, rejection of ambiguous-route releases, and per-environment channels.

    ", + "plan": "

    A release endpoint and site tokens (with a DECO_SITE_TOKEN lifecycle), rejection of ambiguous-route releases, and per-environment channels. The service serves every commit as it lands and doesn't compare schemas; compatibility is checked in CI by deco schema --check.

    ", "features": [ "content-storage-and-delivery", "cms-page-routing", @@ -3871,7 +3923,7 @@ "blog-tanstack", "faststore" ], - "summary": "The file model matches, but filename encoding, bundling, the release service and skew protection are unspecified. Nothing gives the loader the app's schema hash, so 'skip a release whose schema doesn't match' can't work as documented.", + "summary": "The file model matches, but filename encoding, bundling, the release service and skew protection are unspecified. Compatibility between content and the deployed code is meant to be checked in CI by `deco schema --check`, which isn't built yet.", "docs": [ "quickstart", "model", @@ -3960,7 +4012,7 @@ "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 `--check` yet, no input for the schema gate, and Content aliases can't work at runtime.", + "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 `--check` compatibility check yet, and Content aliases can't work at runtime.", "docs": [ "schema", "manifest", diff --git a/site/src/app.js b/site/src/app.js index a1b3a57f..d5fdb214 100644 --- a/site/src/app.js +++ b/site/src/app.js @@ -118,8 +118,8 @@ } /* ------------------------------------------------------------ Code blocks */ - const languageLabels = { typescript: 'TypeScript', tsx: 'TSX', json: 'JSON', jsonc: 'JSON', bash: 'Shell', text: 'Text' }; - const extensionLanguages = { ts: 'typescript', mts: 'typescript', tsx: 'tsx', jsx: 'tsx', js: 'typescript', json: 'json', jsonc: 'jsonc', sh: 'bash' }; + const languageLabels = { typescript: 'TypeScript', tsx: 'TSX', json: 'JSON', jsonc: 'JSON', bash: 'Shell', yaml: 'YAML', text: 'Text' }; + const extensionLanguages = { ts: 'typescript', mts: 'typescript', tsx: 'tsx', jsx: 'tsx', js: 'typescript', json: 'json', jsonc: 'jsonc', sh: 'bash', yml: 'yaml', yaml: 'yaml' }; // JSX in a TypeScript block: a closing tag, a self-closing capitalized tag, or a fragment. const looksLikeJsx = text => /<\/[A-Za-z][\w.]*>|<[A-Z][\w.]*(\s[^<>]*)?\/>|<>|<\/>/.test(text); function fileNameFor(pre) { diff --git a/site/src/content.html b/site/src/content.html index 9c989223..456edfd3 100644 --- a/site/src/content.html +++ b/site/src/content.html @@ -17,7 +17,7 @@ PieceWhat it doesWhere it runs SDK @decocms/blocksReads your content (a module the CLI generates from files in your repo, the hosted Deco CMS, or your own store) and calls your functions with it.Your app, in any JavaScript runtime: Node, Cloudflare Workers, Deno, Bun, React Native or the browser - CLI @decocms/blocks-cliProvides the deco command. deco schema generates a JSON Schema from your types, and deco content turns your content files into a module your app imports.Your machine or CI + CLI @decocms/blocks-cliProvides the deco command. deco schema generates a JSON Schema from your types (and, in CI, checks a change against the previous one), and deco content turns your content files into a module your app imports.Your machine or CI StudioBuilds a visual editor from that schema and saves edits back to your files.Part of the hosted Deco CMS (optional) @@ -132,7 +132,7 @@

    1. Make the function configurable

    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. Schema generation lists every tag.

    npx deco schema
    -

    It finds the block map in blocks.ts (or src/blocks.ts; see Schema generation) and writes .deco/schema.json. Commit the file. If the repository is connected to the hosted Deco CMS, Studio now shows each experiment as a number field clamped to 0–100. Without it, the schema still describes the content you or an agent write by hand.

    +

    It finds the block map in blocks.ts or blocks.tsx, in the project root or in src/ (--entry points it at any other file; see Schema generation), and writes .deco/schema.json. Commit the file. If the repository is connected to the hosted Deco CMS, then once that code is deployed, Studio shows each experiment as a number field clamped to 0–100. Without it, the schema still describes the content you or an agent write by hand.

    3. Add content

    There are two ways to create content:

    @@ -361,6 +361,7 @@

    Configuring content

    createCMS({ blocks, content: kvContent });

    token is your site's API key from the hosted Deco CMS, used to call the Deco API. (Issuing tokens for this API isn't available yet; see the Roadmap.) Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads content only, so the same line works everywhere.

    interval is how often each server checks for a new release, in milliseconds. It defaults to the DECO_CONTENT_INTERVAL environment variable, or 60 000 (one minute) when that's unset. The minimum is 60 000: 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. How and when checks run is in Loader internals.

    +

    The content module you pass with token stays in memory as the fallback, alongside the live release. On Cloudflare Workers with a large content set, see “Large content on Workers” under Create the CMS.

    Write your own loader

    The contract is one required method, load(pointer?), which returns { revision, blocks }. Here is one backed by Cloudflare Workers KV, Workers' key-value store, with the revision and the entries stored together under one key:

    @@ -378,7 +379,7 @@

    Write your own loader

    }; export const cms = createCMS({ blocks, content: kvContent });
    -

    On Workers, importing env from cloudflare:workers gives you your bindings (here the KV namespace CONTENT) at module scope, so the CMS can still be created once, outside any request. Snapshot is the { revision, blocks } type. If load() throws, the read fails with LOADER_FAILED (see Errors). A first-party kvLoader(env.CONTENT, { key }) helper is planned (see the Roadmap).

    +

    On Workers, importing env from cloudflare:workers gives you your bindings (here the KV namespace CONTENT) at module scope, so the CMS can still be created once, outside any request. Snapshot is the { revision, blocks } type. If load() throws, the read fails with LOADER_FAILED (see Errors). A first-party kvLoader(env.CONTENT, { key }) helper is planned (see the Roadmap).

    Pass it as content; add token to use it as the fallback under hosted releases. Store the revision with the content, and change it whenever any entry changes (see the rule above).

    Two optional extras. If your store has drafts, use the pointer argument, the draft pointer that names which draft to read. If content can change while the process runs, add update(), which checks for newer content; the CMS calls it on the same idle schedule and interval as remoteLoader (see Loader internals). Either way, the client only ever sees { revision, blocks }.

    @@ -386,9 +387,9 @@

    Write your own loader

    Core concepts

    Schema generation

    -

    Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. It reads the file that exports your block map, the blocks.ts from the quickstart, and writes .deco/schema.json. It looks for that file as blocks.ts or blocks.tsx, in the project root or in src/:

    +

    Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. It reads the file that exports your block map, the blocks.ts from the quickstart, and writes .deco/schema.json. It looks for that file as blocks.ts or blocks.tsx, in the project root or in src/. If your block map lives somewhere else, pass its path with --entry, for example deco schema --entry app/cms/blocks.ts:

    npx deco schema
    -

    For each block type in the map, the CLI reads the type of the function's first parameter and turns it into a form definition. Commit .deco/schema.json with your code, and run the command in CI so the schema never drifts from your types.

    +

    For each block type in the map, the CLI reads the type of the function's first parameter and turns it into a form definition. Commit .deco/schema.json with your code, and run the command in CI so the schema never drifts from your types (see Keep the schema compatible).

    The CLI also checks your content model: it fails if a saved entry in .deco/blocks has the name of a block type, or if an alias (a second name for a block type, used when you rename one; see Renaming and migrating) collides with either.

    The two CLI commands

    @@ -397,7 +398,15 @@

    The two CLI commands

  • deco schema turns the types in your block map into .deco/schema.json. Commit this file.
  • deco content turns the files in .deco/blocks into the content module, .deco/blocks.gen.ts. It's generated, so gitignore it.
  • -

    Both take --watch, to regenerate as files change, and --root, the .deco folder to use (by default ./.deco). There's no separate dev command: run both before your dev server and your build. Why the content module is gitignored, and what reloads without a rerun, is in The content module.

    +
    deco schema [--entry <path>] [--root <dir>] [--watch] [--check <base>]
    +deco content [--root <dir>] [--watch]
    +
      +
    • --entry <path> (deco schema only): the file that exports your block map. By default the CLI looks for blocks.ts or blocks.tsx in the project root or in src/.
    • +
    • --root <dir>: the .deco folder to use (by default ./.deco).
    • +
    • --watch: regenerate as files change.
    • +
    • --check <base> (deco schema only): compare the schema your code generates now with an earlier one, and fail on breaking changes. It writes nothing, and it isn't built yet (see the Roadmap). See Keep the schema compatible.
    • +
    +

    There's no separate dev command: run both before your dev server and your build. Why the content module is gitignored, and what reloads without a rerun, is in The content module.

    package.json
    {
       "scripts": {
         "predev": "deco schema && deco content",
    @@ -405,6 +414,68 @@ 

    The two CLI commands

    } }
    +

    Keep the schema compatible

    +

    With the hosted Deco CMS, content goes live without a deploy, so the code that's already running renders it. That's the point of publishing without a deploy. Nothing at runtime checks that the two fit: the Deco API serves each commit as it lands, and remoteLoader doesn't compare schemas. They're kept compatible before anything merges, in two directions. (deco schema --check, which the first one relies on, is planned; see the Roadmap.)

    +
      +
    • Backward compatible: new code still renders all existing content. A code change must not break content that's already saved. deco schema --check <base> enforces this. It generates the schema from your code and compares it with <base>, the path to an earlier .deco/schema.json, such as the one on main. It lists every breaking change and exits with a non-zero code if it finds one. It writes nothing.
    • +
    • Forward: already-deployed code renders new content. This holds as long as content only uses block types and fields that the deployed schema has. Studio is meant to build its forms from the schema the deployed site serves (see What Studio reads), so editors can't add a block type before the code that defines it is deployed. Today Studio can also read the schema file committed in the repository, which changes as soon as a code change merges; using only the deployed schema is on the Roadmap. Deploy the code first, then publish content that uses it. An entry written by hand or by an agent that uses a type the deployed code lacks fails that block with UNKNOWN_BLOCK (see Errors) until the code ships. Rolling code back is the exception: content that already uses the newer types or fields fails on the older code until you redeploy or revert that content, so check what's published before you roll back.
    • +
    +

    What --check treats as breaking:

    +
    + + + + + + + + + + +
    ChangeBreaking?
    Removing a block type (a function in the block map) or a Content typeYes. --check compares schemas only, so any removal counts, whether or not saved content uses the type. Remove a type in a later change, once no content uses it. To rename a block type, keep the old name as an alias (see Rename a type with an alias); the old name stays in the schema, so the check passes. Don't rename a Content type this way yet: the check would pass, but Content aliases don't work at runtime (see the Roadmap).
    Removing or renaming a fieldYes. Fields have no aliases, so rename in steps: add the new name as an optional field, move the content over, then remove the old field once no content uses it.
    Adding a required field, or making an optional field requiredYes: existing entries don't have it
    Narrowing a field's type: a string becoming a union of allowed values, a union losing a member, a tighter limit (a lower @maximum or @maxLength, or a higher @minimum or @minLength)Yes: saved values may fall outside it
    Adding a block type, a Content type or a built-in typeNo
    Adding an optional fieldNo
    Widening a field's type: a union gaining a member, a required field becoming optionalNo
    +

    Run it in CI on every pull request. This GitHub Actions workflow first checks that the committed .deco/schema.json is up to date: it regenerates the schema and fails if the file changed or was never committed. It then compares the schema with the one on the pull request's target branch. On the first pull request that adds a schema there's nothing to compare with yet, so it skips that step. Any other problem, such as a missing branch, fails the job instead of skipping it:

    +
    .github/workflows/schema.yml
    name: Schema
    +on: pull_request
    +
    +jobs:
    +  schema:
    +    runs-on: ubuntu-latest
    +    env:
    +      BASE_REF: ${{ github.base_ref }}
    +    steps:
    +      - uses: actions/checkout@v4
    +        with:
    +          fetch-depth: 0
    +      - uses: actions/setup-node@v4
    +        with:
    +          node-version: 24
    +          cache: npm
    +      - run: npm ci
    +      - name: The committed schema matches the code
    +        run: |
    +          npx --no-install deco schema
    +          if [ -n "$(git status --porcelain -- .deco/schema.json)" ]; then
    +            git diff -- .deco/schema.json
    +            echo ".deco/schema.json is missing or out of date: run deco schema and commit it."
    +            exit 1
    +          fi
    +      - name: Fetch the base schema
    +        run: |
    +          git rev-parse --verify --quiet "origin/$BASE_REF" > /dev/null \
    +            || { echo "origin/$BASE_REF not found"; exit 1; }
    +          if git cat-file -e "origin/$BASE_REF:.deco/schema.json" 2> /dev/null; then
    +            git show "origin/$BASE_REF:.deco/schema.json" > base-schema.json
    +          fi
    +      - name: No breaking changes
    +        run: |
    +          if [ -f base-schema.json ]; then
    +            npx --no-install deco schema --check base-schema.json
    +          else
    +            echo "No .deco/schema.json on $BASE_REF yet; skipping the compatibility check."
    +          fi
    +

    --no-install makes npx fail if @decocms/blocks-cli isn't installed, instead of downloading some other package named deco. If you use --root, pass it to both deco schema calls and replace .deco/schema.json in the workflow with <dir>/schema.json. Make the job a required check in your branch protection rules, so a failing pull request can't merge.

    +

    Studio's saves are commits to the same repository. They only change content, never the schema, so --check always passes them, and deco schema still checks the content model (entry names, aliases and route collisions). When Studio saves through pull requests, this workflow checks them before merge. When it commits straight to your production branch, they're live as soon as they land, so a check can only report a problem afterwards: add push: { branches: [main] } under on and guard the two comparison steps with if: github.event_name == 'pull_request', since a push has no target branch.

    +

    Annotate fields with JSDoc

    The TypeScript type decides what kind of field each input gets: a string is a text box, a number a number field, a union of string literals a select, an array a list editors can add to and reorder. JSDoc tags on a field refine the form:

    @@ -1028,7 +1099,7 @@

    Types

    }

    Errors

    -

    Client calls never throw: resolve and list return [value, null] or [null, error]. The one exception in the SDK is matchRoute, which throws when two entries can match the same URL. That's a content bug deco schema also reports, so run it in CI on every commit, including Studio's, and catch the throw in your handler (see Path templates and match order). Check the error, not the value, because a block function can legitimately return null.

    +

    Client calls never throw: resolve and list return [value, null] or [null, error]. The one exception in the SDK is matchRoute, which throws when two entries can match the same URL. That's a content bug deco schema also reports, so run it in CI on every commit, including Studio's (see Keep the schema compatible), and catch the throw in your handler (see Path templates and match order). Check the error, not the value, because a block function can legitimately return null.

    @@ -1298,6 +1369,13 @@

    2. Create the CMS

    return pointer ? cms.forDraft(pointer) : cms.forRelease() }

    The content module is ordinary imports, so Vite bundles it into the Worker like any other module; nothing here is specific to Workers. Its revision is the same content hash the Deco API computes, so a new Worker instance whose build already holds the current release skips downloading it (see Loader internals). Workers run no timers between requests, so when a release check is due, the SDK runs it 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 cms.update().

    +

    Large content on Workers. When you pass both content and token, the content module stays in memory as the fallback, alongside the release the loader fetched. Once you publish, the live release and the fallback are different revisions, so your content is in memory more than once. A Worker isolate has a 128 MB memory limit, and the bundled content also counts toward the Worker's script size limit. For a few hundred kilobytes of JSON this doesn't matter; for several megabytes, it can. On large sites, pass a loader that reads from KV as content instead of the content module. No copy is bundled into the Worker, and none is held in memory just because the module was imported: the fallback is read from KV only when it's needed. Once read, it's held in memory like any other content, so this removes the bundled copy, not every duplicate:

    +
    src/cms.ts (large sites)
    import { env } from "cloudflare:workers"
    +// kvContent: the KV loader from "Write your own loader"
    +
    +// The fallback comes from KV, not from the bundle
    +export const cms = createCMS({ blocks, content: kvContent, token: env.DECO_SITE_TOKEN })
    +

    The trade-off: the fallback is now only as good as the KV key. Your deploy pipeline must write the current content to it before the new Worker serves traffic. Until a new isolate's first release check (about a minute), and whenever the Deco API can't be reached, reads come from the KV key. So if the key is missing, every new isolate fails its first reads with LOADER_FAILED until it has fetched a release, and keeps failing if it can't reach the API. A first-party kvLoader helper is planned (see the Roadmap).

    3. Match the URL to a page

    This helper is shared by both TanStack guides. T is what your product block returns: ProductDescriptor here, ReactNode in the RSC guide, which reuses this file unchanged. The server function hands the incoming request to client(), and draftPointer finds the pointer in the URL or in the cookie from step 6. Each block promise resolves to the value, or to null if the block failed, so no internal error details reach the browser.

    @@ -1428,7 +1506,8 @@

    Deploying checklist

  • Run deco schema && deco content before each build (the prebuild script).
  • Pass token to createCMS so releases arrive without a deploy.
  • Set the token with npx wrangler secret put DECO_SITE_TOKEN.
  • -
  • Expect each new Worker instance (Cloudflare calls it an isolate) to serve the build's content module until its first check. Checks run after responses, about once a minute.
  • +
  • Expect each new Worker instance (Cloudflare calls it an isolate) to serve the build's content module until its first check, or the content in your KV key if you pass a KV loader as content (write the key before the deploy). Checks run after responses, about once a minute.
  • +
  • If your content is several megabytes, use a KV loader instead of the bundled content module, and write the KV key before the new Worker takes traffic (see Create the CMS).
  • Keep both compatibility flags.
  • The Next.js guide targets Node, not Workers.
  • @@ -1528,7 +1607,7 @@

    Publishing

    With the hosted Deco CMS, publishing is committing. A developer or an agent edits the entry files in your content directory (.deco/blocks, or the folder you give the CLI's --root) and pushes; an editor saves in Studio, which commits to the same repository. (If you load from your own store, see Content and loaders.) Either way, the production branch (the branch the hosted Deco CMS publishes from, usually main) moves.

    Publishing takes seconds: the Deco API serves that commit as the current release as soon as it lands. Each server checks for a new release about once a minute, at an idle moment, so a publish is live everywhere within about a minute. Calling cms.update(), for example from a webhook, 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 (see Loaders). Requests never wait on this: load() always returns from memory, and the check runs in the background. Step by step: under the hood.

    Because content and code share one history, a deploy and a content change are the same kind of event: a commit. Rolling content back is reverting one.

    -

    Content can't get ahead of code. remoteLoader tells the Deco API which schema your build shipped with (a hash of the committed .deco/schema.json), and the API serves only releases whose content fits that schema. That's why the schema must be regenerated and committed with every code change; run deco schema in CI. If a commit adds a block type the deployed build doesn't have, visitors see that commit only after you deploy the code that defines the type; until then, the loader keeps serving the previous release. (Sending the schema hash is part of the Deco API release service, still on the Roadmap.)

    +

    Publishing without a deploy means the code that's already running renders the new content. Nothing at runtime checks that the two fit: the Deco API serves each commit as it lands, and remoteLoader doesn't compare schemas. They're checked before anything merges instead; the rules and a CI workflow are in Keep the schema compatible.

    Fallback

    Until a server has fetched its first release (a new server, or one that has never reached the Deco API), remoteLoader serves its fallback's content. If the Deco API becomes unreachable later, the server keeps serving the newest release it already has. With the content module, that's the content the build shipped with, the content this deploy was tested against, so it's always there and always compatible with the code. If your content is a loader you wrote (see Write your own loader), keeping those guarantees is up to you. remoteLoader always switches between whole revisions, never mixing entries from two of them, because a mix can break references or bring back deleted entries. The loader reports the revision it actually served.

    @@ -1539,7 +1618,7 @@

    One revision per response

    A revision only selects content; it isn't a secret and unlocks nothing. Unpublished drafts are protected by the signed token in the draft pointer (see Preview).

    Deploying on Workers

    -

    Workers need nothing special. The content module is bundled like any other import, and because Workers run no timers between requests, the SDK runs a due release 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 cms.update(). The deploying checklist in the TanStack Start guide lists every step.

    +

    Workers need nothing special, except on sites with several megabytes of content (see “Large content on Workers” under Create the CMS). The content module is bundled like any other import, and because Workers run no timers between requests, the SDK runs a due release 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 cms.update(). The deploying checklist in the TanStack Start guide lists every step.

    @@ -1617,7 +1696,8 @@

    Checklist for migrating existing content

    - + + @@ -1701,7 +1781,7 @@

    Contributing

    Updating never competes with rendering, and a request never waits. There's no push signal: servers ask, the API never calls them.
  • Compare. If the hash equals what's in memory, there's nothing to do. 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's revision is the CLI's copy of the same hash. A loader you write with its own revision scheme is downloaded once.
  • -
  • Fetch and swap. Otherwise it fetches that revision, checks that the content hashes to it, and swaps it in whole. The API answers only with releases whose content fits the schema the build shipped with (see Publishing), so content never gets ahead of code. Requests in flight keep the revision they started with.
  • +
  • Fetch and swap. Otherwise it fetches that revision, checks that the content hashes to it, and swaps it in whole. Whether that content fits this build was settled before it merged, not here (see Keep the schema compatible). Requests in flight keep the revision they started with.
  • Fail quietly. Any network or validation error 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, within about a minute (or at once 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.)

    @@ -1729,7 +1809,7 @@

    One instance per process

    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.

    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. 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. -
    3. Reject 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), throw with both names in the message. The CLI runs the same build at schema time, so running deco schema in CI on every commit, including Studio's, catches it before it ships.
    4. +
    5. Reject 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), throw with both names in the message. The CLI runs the same build at schema time, so deco schema in CI reports it: a pull request check catches it before merge, and a check on pushes to the production branch reports a Studio commit that went straight there, after it's live (see Keep the schema compatible).
    6. 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. If a branch dead-ends, go back up and try the parameter child you skipped. Each node has at most one literal match and one parameter, so a lookup visits about as many nodes as the URL has segments, however many entries exist.
    7. Precedence falls out of the walk order. Trying the literal child before the parameter child at every node is exactly "exact paths win over templates", per segment rather than per path: /blog/archive beats /blog/:slug for /blog/archive, and /blog/:slug still serves /blog/hello-world. No sorting, no regex.
    8. Redirects are a second trie, checked first, with the same rules. A hit fills the parameters into the redirect's to path and returns { kind: "redirect", location, status }.
    9. @@ -1801,7 +1881,8 @@

      Studio compatibility today

    - + +
    CodeWhen
    Serialization error when sending a block's result to the browserJSON can't carry functions or React elements. Return plain data (descriptors, see Rendering), or use an integration that renders on the server, like TanStack RSC or Next.js.
    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.
    Content changes mid-responseUse one client for the whole response; a client never changes revision. If you wrote the loader, check that it never returns different content under an existing revision.
    Stale or fallback contentCompare the revision your server serves (see above) 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 newest release may be held back because its schema doesn't match this build (deploy the matching code), or the server hasn't checked yet: allow about a minute after publishing. See Releases and deployment and how remoteLoader stays current.
    Stale or fallback contentCompare the revision your server serves (see above) 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 about a minute after publishing. See Releases and deployment and how remoteLoader stays current.
    UNKNOWN_BLOCK right after publishingThe 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 the schema compatible.
    A new content file isn't found in developmentRun deco content, or keep deco content --watch running beside your dev server. Only adding or removing a file needs it; edits to an existing file hot-reload.
    Warning about an instance with different optionscreateCMS (or remoteLoader) ran twice with the same token and content but different options, such as another interval. The first instance is kept. Create the CMS once, at module scope (see One instance per process).
    Warning that interval was raisedThe minimum is 60 000 ms (one minute); lower values, from interval or DECO_CONTENT_INTERVAL, are raised to it.
    Short type names; aliases for the old file-path names.A type name is a column name, not a file path. Content saved under the previous framework's names, like website/pages/Page.tsx, keeps working through aliases, while new code uses names that survive a refactor.
    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.
    Two CLI commands, no dev wrapper.deco schema and deco content each do one job, run from predev and prebuild, and take --watch.
    Two CLI commands, no dev wrapper.deco schema and deco content each do one job and run from predev and prebuild. Both take --root and --watch; deco schema also takes --entry, for a block map outside the default locations, and --check, for CI.
    Compatibility is a CI check, not a runtime gate.Publishing without a deploy exists so code that's already live can render new content; holding content back until a deploy would undo that. deco schema --check (planned) flags a code change that would break saved content before it merges, and Studio is meant to offer only what the deployed schema has.
    Poll a hash once a minute, at idle moments; no push.A few bytes per server per minute, no infrastructure to reach every server, and never in front of rendering.
    diff --git a/site/src/landing.html b/site/src/landing.html index e3ac382d..65c47d6f 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -217,7 +217,7 @@

    Publish in seconds, not on
    • Not tied to code releasesContent needs no build and no deploy, so editors publish when a campaign is ready, not when the next code release goes out.
    • Preview on the real siteEditors open drafts on your actual pages before anything goes live, and only people Studio lets in can see them.
    • -
    • Never ahead of your codeIf a change needs something the live site doesn't support yet, the site keeps showing the previous version until the code catches up.
    • +
    • Safe with the live codeEditors only see options your live site supports, and a check you run on every pull request catches code changes that would break published pages.
    • Undo any changeEvery publish is saved in your repository's history, with who changed what and when, so going back is one step.
    For developers: publishing and releases{{icon:arrow-right}} From f4a7a423faabf2a2caf563d1b9cea274d9829063 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 18:23:48 -0300 Subject: [PATCH 016/132] docs(site): Studio uses the schema you point it at; drop a techy home line - Studio builds its forms from whichever schema the user points it at, usually main's; the docs no longer say it is meant to read only the deployed schema. The Roadmap item becomes an option to follow the deployed schema. - The home page's "Safe with the live code" point is gone (too technical for that page); the points row is three columns now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/data/roadmap.json | 6 +++--- site/src/content.html | 6 +++--- site/src/landing.html | 1 - site/src/style.css | 3 ++- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/site/data/roadmap.json b/site/data/roadmap.json index b21a0d06..66a877fe 100644 --- a/site/data/roadmap.json +++ b/site/data/roadmap.json @@ -657,10 +657,10 @@ "pinned": false }, { - "id": "roadmap-api--build-studio-forms-only-from-the-deployed-schema", + "id": "roadmap-api--let-studio-follow-the-deployed-schema", "group": "api", - "title": "Build Studio forms only from the deployed schema", - "plan": "

    Publishing without a deploy relies on editors only using block types and fields the deployed code has. Studio must build its forms from the schema the deployed site serves at /live/_meta, never from the schema file committed in the repository, which changes as soon as a code change merges and before it's deployed. Until then, an editor can add a block type the live code lacks, and it fails with UNKNOWN_BLOCK. Update Schema generation once it ships.

    ", + "title": "Let Studio follow the deployed schema", + "plan": "

    Studio builds its forms from whichever schema the user points it at, usually the one on the main branch. An option to follow the schema the deployed site serves at /live/_meta would keep editors from adding a block type before the code that defines it is live. Update Schema generation once it ships.

    ", "features": [ "admin-protocol-endpoints", "content-schema-drift", diff --git a/site/src/content.html b/site/src/content.html index 456edfd3..95d7f794 100644 --- a/site/src/content.html +++ b/site/src/content.html @@ -418,7 +418,7 @@

    Keep the schema compatible

    With the hosted Deco CMS, content goes live without a deploy, so the code that's already running renders it. That's the point of publishing without a deploy. Nothing at runtime checks that the two fit: the Deco API serves each commit as it lands, and remoteLoader doesn't compare schemas. They're kept compatible before anything merges, in two directions. (deco schema --check, which the first one relies on, is planned; see the Roadmap.)

    • Backward compatible: new code still renders all existing content. A code change must not break content that's already saved. deco schema --check <base> enforces this. It generates the schema from your code and compares it with <base>, the path to an earlier .deco/schema.json, such as the one on main. It lists every breaking change and exits with a non-zero code if it finds one. It writes nothing.
    • -
    • Forward: already-deployed code renders new content. This holds as long as content only uses block types and fields that the deployed schema has. Studio is meant to build its forms from the schema the deployed site serves (see What Studio reads), so editors can't add a block type before the code that defines it is deployed. Today Studio can also read the schema file committed in the repository, which changes as soon as a code change merges; using only the deployed schema is on the Roadmap. Deploy the code first, then publish content that uses it. An entry written by hand or by an agent that uses a type the deployed code lacks fails that block with UNKNOWN_BLOCK (see Errors) until the code ships. Rolling code back is the exception: content that already uses the newer types or fields fails on the older code until you redeploy or revert that content, so check what's published before you roll back.
    • +
    • Forward: already-deployed code renders new content. This holds as long as content only uses block types and fields that the deployed code has. Studio builds its forms from whichever schema you point it at, usually the one on your main branch (see What Studio reads). Keep that schema in step with what's deployed, and deploy the code first, then publish content that uses it. An entry written by hand or by an agent that uses a type the deployed code lacks fails that block with UNKNOWN_BLOCK (see Errors) until the code ships. Rolling code back is the exception: content that already uses the newer types or fields fails on the older code until you redeploy or revert that content, so check what's published before you roll back.

    What --check treats as breaking:

    @@ -1821,7 +1821,7 @@

    One instance per process

    Under the hood

    Deco Studio compatibility

    What Studio reads

    -

    Studio learns about your site from your site. Your app serves a few HTTP endpoints for it. The main one, /live/_meta, returns the schema the CLI wrote; Studio also calls your app to render previews (/live/previews), to run functions for pickers and the Run button (/deco/invoke), and to encrypt secret fields (/live/invoke). In today's packages, these handlers come from @decocms/blocks-admin; how the new API mounts them is on the Roadmap. Studio also reads your content files in .deco/blocks. Everything Studio knows about the shape of your content comes from the schema, so it's all inferred from your types; the other endpoints run your code, they don't describe it.

    +

    Studio learns about your site from your site. Your app serves a few HTTP endpoints for it. The main one, /live/_meta, returns the schema the CLI wrote; Studio also calls your app to render previews (/live/previews), to run functions for pickers and the Run button (/deco/invoke), and to encrypt secret fields (/live/invoke). In today's packages, these handlers come from @decocms/blocks-admin; how the new API mounts them is on the Roadmap. Studio also reads your content files in .deco/blocks. Which schema Studio uses is up to you: usually the one on your main branch, but you can point it at another. Everything Studio knows about the shape of your content comes from the schema, so it's all inferred from your types; the other endpoints run your code, they don't describe it.

    The schema file has two parts. definitions holds one JSON Schema per type: the form Studio shows. manifest lists every key of your block map and your Content type, grouped by kind, so Studio knows which keys render UI, which are rules, and so on. The group names are Studio's, inherited from Deco's previous framework. Each manifest entry points at its definition with a JSON Schema $ref. The CLI picks the group from the type:

    @@ -1882,7 +1882,7 @@

    Studio compatibility today

    - +
    Manifest groupWhat lands thereStudio uses it for
    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.
    Two CLI commands, no dev wrapper.deco schema and deco content each do one job and run from predev and prebuild. Both take --root and --watch; deco schema also takes --entry, for a block map outside the default locations, and --check, for CI.
    Compatibility is a CI check, not a runtime gate.Publishing without a deploy exists so code that's already live can render new content; holding content back until a deploy would undo that. deco schema --check (planned) flags a code change that would break saved content before it merges, and Studio is meant to offer only what the deployed schema has.
    Compatibility is a CI check, not a runtime gate.Publishing without a deploy exists so code that's already live can render new content; holding content back until a deploy would undo that. deco schema --check (planned) flags a code change that would break saved content before it merges, and Studio offers what the schema you point it at has, usually main's.
    Poll a hash once a minute, at idle moments; no push.A few bytes per server per minute, no infrastructure to reach every server, and never in front of rendering.
    diff --git a/site/src/landing.html b/site/src/landing.html index 65c47d6f..95380c08 100644 --- a/site/src/landing.html +++ b/site/src/landing.html @@ -217,7 +217,6 @@

    Publish in seconds, not on
    • Not tied to code releasesContent needs no build and no deploy, so editors publish when a campaign is ready, not when the next code release goes out.
    • Preview on the real siteEditors open drafts on your actual pages before anything goes live, and only people Studio lets in can see them.
    • -
    • Safe with the live codeEditors only see options your live site supports, and a check you run on every pull request catches code changes that would break published pages.
    • Undo any changeEvery publish is saved in your repository's history, with who changed what and when, so going back is one step.
    For developers: publishing and releases{{icon:arrow-right}} diff --git a/site/src/style.css b/site/src/style.css index cb75fcad..856f8057 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -1427,7 +1427,7 @@ dialog::backdrop { background: rgba(14, 26, 16, .3); -webkit-backdrop-filter: bl .pub-bracket.is-skip::before { border-style: dashed; } .pub-cap { grid-column: 6; justify-self: center; padding-top: 12px; text-align: center; white-space: nowrap; } .pub-cap.is-live { font-weight: 500; color: var(--tint-fg); } -.pub-points { list-style: none; margin: 28px 0 0; padding: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; border: 1px solid var(--border); border-radius: 16px; background: var(--border); overflow: hidden; } +.pub-points { list-style: none; margin: 28px 0 0; padding: 0; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; border: 1px solid var(--border); border-radius: 16px; background: var(--border); overflow: hidden; } .pub-points li { min-width: 0; display: flex; flex-direction: column; padding: 24px 26px 30px; background: var(--bg); } .pub-points strong { margin: 0 0 8px; font-size: 19px; line-height: 1.3; font-weight: 400; letter-spacing: -.012em; color: var(--fg); } .pub-points li > span:last-child { font-size: 14.5px; line-height: 1.6; color: var(--muted-fg); } @@ -1460,6 +1460,7 @@ dialog::backdrop { background: rgba(14, 26, 16, .3); -webkit-backdrop-filter: bl } @media (max-width: 1099px) { .pub-points { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .pub-points li:last-child:nth-child(odd) { grid-column: 1 / -1; } .pub-lane { padding: 24px 22px 18px; } .pub-steps, .pub-notes { --gap: 20px; } .pub-steps li { font-size: 13.5px; padding: 0 8px; } From f76231f20717a4fbfb7c8dd85679f1dfd8558fb2 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 20:09:16 -0300 Subject: [PATCH 017/132] docs(site): rebuild the docs site with TanStack Start, Tailwind and MDX site/ is now a standalone Bun package (its own package.json and bun.lock; the monorepo lockfile is untouched): - TanStack Start (Vite + React 19), file-based routes, every page prerendered to static HTML for GitHub Pages; BASE_PATH sets the base. - Content in MDX (content/next/*.mdx, content/v7/*.mdx) with frontmatter-driven navigation, Shiki highlighting at build time, and the docs' components (callouts, flows, terms, the walkthrough). - Versions: /next/ for the next major, a /v7/ stub, and a version select in the header. Home at /, Roadmap at /roadmap (React port of the old generator, its checks in scripts/check-roadmap.ts). - Pagefind for the search dialog; the build checks every internal link and a size budget. - Text and code of all 25 doc pages are identical to the previous single-page site; the Python build is removed, and the Pages workflow builds the new site with Bun. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .github/workflows/pages.yml | 59 +- site/.gitignore | 10 + site/ARCHITECTURE.md | 230 ++ site/README.md | 86 +- site/__pycache__/gen_roadmap.cpython-314.pyc | Bin 80799 -> 0 bytes site/{src => assets}/brand/favicon.svg | 0 site/{src => assets}/brand/symbol-on-dark.svg | 0 .../{src => assets}/brand/symbol-on-light.svg | 0 .../brand/wordmark-on-dark.svg | 0 .../brand/wordmark-on-light.svg | 0 site/{src => assets}/cobogo-defs.svg | 0 site/build.py | 257 --- site/build/manifest.ts | 197 ++ site/build/rehype-docs.ts | 202 ++ site/build/shiki-theme.ts | 80 + site/build/slugify.ts | 28 + site/bun.lock | 780 +++++++ site/components/home/Footer.tsx | 97 + site/components/home/Hero.tsx | 80 + site/components/home/Journey.tsx | 413 ++++ site/components/home/Sections.tsx | 460 ++++ site/components/home/Stepper.tsx | 192 ++ site/components/home/index.tsx | 40 + site/components/home/tokens.tsx | 58 + site/components/home/useReveal.ts | 71 + site/components/mdx/Callout.tsx | 27 + site/components/mdx/CodeBlock.tsx | 187 ++ site/components/mdx/Flow.tsx | 42 + site/components/mdx/Heading.tsx | 38 + site/components/mdx/Kbd.tsx | 6 + site/components/mdx/MdxLink.tsx | 35 + site/components/mdx/README.md | 187 ++ site/components/mdx/Small.tsx | 20 + site/components/mdx/Steps.tsx | 19 + site/components/mdx/Table.tsx | 10 + site/components/mdx/Terms.tsx | 22 + site/components/mdx/TocInline.tsx | 9 + site/components/mdx/index.tsx | 49 + site/components/roadmap/RoadmapPage.tsx | 141 ++ site/components/roadmap/SectionViews.tsx | 775 +++++++ site/components/roadmap/index.tsx | 7 + site/components/roadmap/instance.ts | 9 + site/components/roadmap/model.ts | 486 +++++ site/{src => components/roadmap}/roadmap.css | 51 +- site/components/roadmap/sections.ts | 134 ++ site/components/search/index.tsx | 373 ++++ site/components/search/pagefind.ts | 163 ++ site/components/ui/Brand.tsx | 36 + site/components/ui/Icon.tsx | 106 + site/components/widgets/Walkthrough.tsx | 154 ++ site/components/widgets/index.tsx | 7 + site/components/widgets/walkthrough-code.mdx | 78 + site/content/next/adoption.mdx | 54 + site/content/next/architecture.mdx | 74 + site/content/next/content-types.mdx | 108 + site/content/next/content.mdx | 133 ++ site/content/next/decisions.mdx | 29 + site/content/next/internals.mdx | 46 + site/content/next/loader-internals.mdx | 49 + site/content/next/manifest.mdx | 69 + site/content/next/model.mdx | 138 ++ site/content/next/nextjs.mdx | 269 +++ site/content/next/operations.mdx | 26 + site/content/next/preview.mdx | 84 + site/content/next/quickstart.mdx | 180 ++ site/content/next/releases.mdx | 44 + site/content/next/rendering.mdx | 38 + site/content/next/router-internals.mdx | 36 + site/content/next/routing.mdx | 141 ++ site/content/next/schema.mdx | 183 ++ site/content/next/standalone.mdx | 222 ++ site/content/next/studio.mdx | 60 + site/content/next/tanstack-data.mdx | 234 ++ site/content/next/tanstack-rsc.mdx | 96 + site/content/next/troubleshooting.mdx | 27 + site/content/next/variants.mdx | 133 ++ site/content/next/walkthrough.mdx | 14 + site/content/v7/index.mdx | 20 + site/gen_roadmap.py | 714 ------ site/package.json | 45 + site/public/favicon.svg | 5 + site/scripts/check-content.ts | 69 + site/scripts/check-roadmap.ts | 195 ++ site/scripts/postbuild.ts | 131 ++ site/scripts/preview.ts | 30 + site/scripts/site-files.ts | 64 + site/src/app.js | 920 -------- site/src/content.html | 1940 ----------------- site/src/dark-tokens.css | 82 - site/src/env.d.ts | 18 + site/src/landing.html | 352 --- site/src/layout/DocPage.tsx | 29 + site/src/layout/DocsShell.tsx | 132 ++ site/src/layout/GlobalUi.tsx | 99 + site/src/layout/Header.tsx | 152 ++ site/src/layout/Menu.tsx | 74 + site/src/layout/NotFound.tsx | 36 + site/src/layout/PageTools.tsx | 45 + site/src/layout/PlainLink.tsx | 20 + site/src/layout/Rail.tsx | 148 ++ site/src/layout/Sidebar.tsx | 110 + site/src/layout/ThemeToggle.tsx | 30 + site/src/lib/chrome.ts | 44 + site/src/lib/content.ts | 103 + site/src/lib/docRoute.ts | 25 + site/src/lib/nav.ts | 96 + site/src/lib/theme.ts | 29 + site/src/lib/ui.ts | 52 + site/src/lib/versions.ts | 23 + site/src/routeTree.gen.ts | 147 ++ site/src/router.tsx | 39 + site/src/routes/$version/$slug.tsx | 16 + site/src/routes/$version/index.tsx | 20 + site/src/routes/__root.tsx | 85 + site/src/routes/index.tsx | 33 + site/src/routes/roadmap/$section.tsx | 24 + site/src/routes/roadmap/index.tsx | 14 + site/src/shell.html | 140 -- site/src/styles/app.css | 68 + site/src/{style.css => styles/legacy.css} | 258 +-- site/src/styles/prose.css | 67 + site/src/styles/tokens.css | 300 +++ site/tsconfig.json | 24 + site/vite.config.ts | 69 + 124 files changed, 11525 insertions(+), 4609 deletions(-) create mode 100644 site/.gitignore create mode 100644 site/ARCHITECTURE.md delete mode 100644 site/__pycache__/gen_roadmap.cpython-314.pyc rename site/{src => assets}/brand/favicon.svg (100%) rename site/{src => assets}/brand/symbol-on-dark.svg (100%) rename site/{src => assets}/brand/symbol-on-light.svg (100%) rename site/{src => assets}/brand/wordmark-on-dark.svg (100%) rename site/{src => assets}/brand/wordmark-on-light.svg (100%) rename site/{src => assets}/cobogo-defs.svg (100%) delete mode 100755 site/build.py create mode 100644 site/build/manifest.ts create mode 100644 site/build/rehype-docs.ts create mode 100644 site/build/shiki-theme.ts create mode 100644 site/build/slugify.ts create mode 100644 site/bun.lock create mode 100644 site/components/home/Footer.tsx create mode 100644 site/components/home/Hero.tsx create mode 100644 site/components/home/Journey.tsx create mode 100644 site/components/home/Sections.tsx create mode 100644 site/components/home/Stepper.tsx create mode 100644 site/components/home/index.tsx create mode 100644 site/components/home/tokens.tsx create mode 100644 site/components/home/useReveal.ts create mode 100644 site/components/mdx/Callout.tsx create mode 100644 site/components/mdx/CodeBlock.tsx create mode 100644 site/components/mdx/Flow.tsx create mode 100644 site/components/mdx/Heading.tsx create mode 100644 site/components/mdx/Kbd.tsx create mode 100644 site/components/mdx/MdxLink.tsx create mode 100644 site/components/mdx/README.md create mode 100644 site/components/mdx/Small.tsx create mode 100644 site/components/mdx/Steps.tsx create mode 100644 site/components/mdx/Table.tsx create mode 100644 site/components/mdx/Terms.tsx create mode 100644 site/components/mdx/TocInline.tsx create mode 100644 site/components/mdx/index.tsx create mode 100644 site/components/roadmap/RoadmapPage.tsx create mode 100644 site/components/roadmap/SectionViews.tsx create mode 100644 site/components/roadmap/index.tsx create mode 100644 site/components/roadmap/instance.ts create mode 100644 site/components/roadmap/model.ts rename site/{src => components/roadmap}/roadmap.css (90%) create mode 100644 site/components/roadmap/sections.ts create mode 100644 site/components/search/index.tsx create mode 100644 site/components/search/pagefind.ts create mode 100644 site/components/ui/Brand.tsx create mode 100644 site/components/ui/Icon.tsx create mode 100644 site/components/widgets/Walkthrough.tsx create mode 100644 site/components/widgets/index.tsx create mode 100644 site/components/widgets/walkthrough-code.mdx create mode 100644 site/content/next/adoption.mdx create mode 100644 site/content/next/architecture.mdx create mode 100644 site/content/next/content-types.mdx create mode 100644 site/content/next/content.mdx create mode 100644 site/content/next/decisions.mdx create mode 100644 site/content/next/internals.mdx create mode 100644 site/content/next/loader-internals.mdx create mode 100644 site/content/next/manifest.mdx create mode 100644 site/content/next/model.mdx create mode 100644 site/content/next/nextjs.mdx create mode 100644 site/content/next/operations.mdx create mode 100644 site/content/next/preview.mdx create mode 100644 site/content/next/quickstart.mdx create mode 100644 site/content/next/releases.mdx create mode 100644 site/content/next/rendering.mdx create mode 100644 site/content/next/router-internals.mdx create mode 100644 site/content/next/routing.mdx create mode 100644 site/content/next/schema.mdx create mode 100644 site/content/next/standalone.mdx create mode 100644 site/content/next/studio.mdx create mode 100644 site/content/next/tanstack-data.mdx create mode 100644 site/content/next/tanstack-rsc.mdx create mode 100644 site/content/next/troubleshooting.mdx create mode 100644 site/content/next/variants.mdx create mode 100644 site/content/next/walkthrough.mdx create mode 100644 site/content/v7/index.mdx delete mode 100755 site/gen_roadmap.py create mode 100644 site/package.json create mode 100644 site/public/favicon.svg create mode 100644 site/scripts/check-content.ts create mode 100644 site/scripts/check-roadmap.ts create mode 100644 site/scripts/postbuild.ts create mode 100644 site/scripts/preview.ts create mode 100644 site/scripts/site-files.ts delete mode 100644 site/src/app.js delete mode 100644 site/src/content.html delete mode 100644 site/src/dark-tokens.css create mode 100644 site/src/env.d.ts delete mode 100644 site/src/landing.html create mode 100644 site/src/layout/DocPage.tsx create mode 100644 site/src/layout/DocsShell.tsx create mode 100644 site/src/layout/GlobalUi.tsx create mode 100644 site/src/layout/Header.tsx create mode 100644 site/src/layout/Menu.tsx create mode 100644 site/src/layout/NotFound.tsx create mode 100644 site/src/layout/PageTools.tsx create mode 100644 site/src/layout/PlainLink.tsx create mode 100644 site/src/layout/Rail.tsx create mode 100644 site/src/layout/Sidebar.tsx create mode 100644 site/src/layout/ThemeToggle.tsx create mode 100644 site/src/lib/chrome.ts create mode 100644 site/src/lib/content.ts create mode 100644 site/src/lib/docRoute.ts create mode 100644 site/src/lib/nav.ts create mode 100644 site/src/lib/theme.ts create mode 100644 site/src/lib/ui.ts create mode 100644 site/src/lib/versions.ts create mode 100644 site/src/routeTree.gen.ts create mode 100644 site/src/router.tsx create mode 100644 site/src/routes/$version/$slug.tsx create mode 100644 site/src/routes/$version/index.tsx create mode 100644 site/src/routes/__root.tsx create mode 100644 site/src/routes/index.tsx create mode 100644 site/src/routes/roadmap/$section.tsx create mode 100644 site/src/routes/roadmap/index.tsx delete mode 100644 site/src/shell.html create mode 100644 site/src/styles/app.css rename site/src/{style.css => styles/legacy.css} (96%) create mode 100644 site/src/styles/prose.css create mode 100644 site/src/styles/tokens.css create mode 100644 site/tsconfig.json create mode 100644 site/vite.config.ts diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 097c2965..9969c037 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,16 +1,18 @@ name: Docs site -# Builds the Deco Blocks docs site (site/) into one self-contained page, -# site/dist/index.html (python3 site/build.py; standard library only). +# Builds the Deco Blocks docs site (site/: TanStack Start + MDX, prerendered to static HTML, +# with a Pagefind search index). site/ is a standalone Bun package with its own bun.lock, so +# every step runs in site/. # -# - Pull requests that touch site/: build, then upload site/dist as the -# workflow artifact "docs-site" so reviewers can download the page and -# open it locally. Nothing is deployed. -# - Pushes to main that touch site/, and manual runs on main: build, then -# deploy site/dist 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. +# - Pull requests that touch site/: check, build, then upload site/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 site/dist/client and +# run `bun run preview`). Nothing is deployed. +# - Pushes to main that touch site/, and manual runs on main: check, build for the /blocks/ +# base path GitHub Pages serves this repository under, then deploy site/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: @@ -36,20 +38,45 @@ concurrency: jobs: build: runs-on: ubuntu-latest + defaults: + run: + working-directory: site + 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 - - name: Build site/dist - run: python3 site/build.py + # `vite build` (and its prerender) runs under Node: the vite bin is `#!/usr/bin/env node`, and + # TanStack Start needs Node >= 22.12 (site/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 site/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: site/dist + path: site/dist/client if-no-files-found: error retention-days: 14 @@ -57,7 +84,7 @@ jobs: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && github.repository == 'decocms/blocks' uses: actions/upload-pages-artifact@v5 with: - path: site/dist + path: site/dist/client deploy: if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && github.repository == 'decocms/blocks' @@ -70,8 +97,8 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - # The page is one self-contained file, so nothing uses this step's - # outputs (base URL, origin); it checks Pages is set up before deploying. + # 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 diff --git a/site/.gitignore b/site/.gitignore new file mode 100644 index 00000000..7829c8fa --- /dev/null +++ b/site/.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/site/ARCHITECTURE.md b/site/ARCHITECTURE.md new file mode 100644 index 00000000..f358c6d8 --- /dev/null +++ b/site/ARCHITECTURE.md @@ -0,0 +1,230 @@ +# 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 `site/`). | +| `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 + +``` +site/ +├── content/ MDX pages, one folder per docs version +│ ├── next/*.mdx the next major (served at /next/) +│ └── v7/*.mdx the current release (/v7/…); index.mdx is /v7/ +├── components/ +│ ├── mdx/ components MDX pages use (API: components/mdx/README.md) +│ ├── ui/ Icon/Mark (the icon set), Brand (wordmark, symbol) +│ ├── home/ the Home page: 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 +│ │ ├── roadmap/ /roadmap/ Roadmap overview (index.tsx), /roadmap/
    ($section.tsx) +│ │ └── $version/ +│ │ ├── index.tsx /next/, /v7/ the version's index +│ │ └── $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) → tokens.css, legacy.css, prose.css +├── 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 (`next`: "Next major", `v7`) and the default (`next`, used by Home, +"Get started" and the tabs outside a doc page). The header's version `<select>` (in the drawer on +small screens) goes to the same slug in the other version if it exists, else to that version's +index. A version index without `index.mdx` 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/…`) and Home (`/`) are version-less. + +## 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). `__root.tsx` reads the deepest match and sets +`<body data-layout data-page>`, which the CSS keys off, exactly as the old single page did. + +- `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 inline outline, 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/`. + +## Styles + +`src/styles/app.css` is the entry: Tailwind v4 **without preflight** (the ported CSS expects +browser defaults), in layers `theme < base < legacy < components < utilities`. + +- `tokens.css`: the design tokens (`--forest`, `--lime`, warm neutrals, `--syn-*` syntax colours, + …) for light, dark (`prefers-color-scheme`, overridden by `data-theme`) and print. +- `legacy.css`: the old `style.css`, verbatim apart from the tokens and the rules that switched + hash-routed pages. It is the source of the look (header, sidebar, rail, prose, code panels, + tables, callouts, flows, landing, search dialog, responsive, print). +- `prose.css`: doc pages put the title in an h1 and sections in h2/h3, where the old page had + h2/h3/h4; this maps those levels to the old styles (`article.doc-section.doc-page`), plus the + version select. + +The tokens are also Tailwind theme values (`bg-bg`, `text-fg`, `text-muted-fg`, `border-hairline`, +`bg-forest`, `text-lime`, `font-mono`, `shadow-deco-win`, …), and the `dark:` variant follows the +same rule as the tokens. New code can use utilities or the existing classes. + +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 renders the whole page, inside + `<LandingShell nav={sidebarFor(DEFAULT_VERSION, 'docs')} footer={<SiteFooter/>}>…</LandingShell>` + (the drawer shows the Docs navigation). Its classes are in `legacy.css`; 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. Its stylesheet (`roadmap.css`, in the + `legacy` layer) is linked from the routes' `head`. 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/site/README.md b/site/README.md index a7bb3960..0e376244 100644 --- a/site/README.md +++ b/site/README.md @@ -1,40 +1,59 @@ # Deco Blocks docs site -The source of the Deco Blocks documentation site: one self-contained HTML page with four -hash-routed parts — **Home**, **Docs**, **Under the hood** and **Roadmap** — plus ⌘K search, a -light/dark theme toggle and a printable layout. It documents the proposed API for the next major -version, and the Roadmap is the to-do list that gets that API to a release. +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 +`site/` 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 `site/`: + ```sh -python3 site/build.py && open site/dist/index.html +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 ``` -Python 3.9 or later, standard library only; no install step. The output is a single file, -`site/dist/index.html` (`dist/` is gitignored). On Linux, use `xdg-open` instead of `open`. +- `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`) builds the page on every pull request that touches `site/` -and uploads it as the `docs-site` workflow artifact: download it from the run's summary page and -open `index.html`. Pushes to `main` that touch `site/`, and manual runs of the workflow on `main`, -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. +CI (`.github/workflows/pages.yml`) checks and builds the site on every pull request that touches +`site/` 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 `site/`, 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 | |---|---| -| `build.py` | Assembles `dist/index.html`: fills `src/shell.html`'s placeholders, inlines and compacts the CSS and JS, and renders the Roadmap. | -| `gen_roadmap.py` | Renders the Roadmap from `data/roadmap.json` and checks it (ids, links, counts). `python3 site/gen_roadmap.py` checks the data alone and prints a summary. | +| `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. | -| `src/shell.html` | The page frame: header, sidebar, "on this page" rail, search dialog. | -| `src/landing.html` | The Home page. | -| `src/content.html` | The Docs and Under-the-hood pages, and the Prism syntax-highlighting bundle (MIT, license notice inside). | -| `src/style.css`, `src/dark-tokens.css`, `src/roadmap.css` | Styles. `dark-tokens.css` is spliced into `style.css`'s dark-theme blocks. | -| `src/app.js` | Routing, sidebar, search, theme, copy/print, and the Home page's interactive examples. | -| `src/brand/*.svg`, `src/cobogo-defs.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 page makes are the Fontshare and Google Fonts stylesheets and the +| `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 @@ -50,9 +69,18 @@ in doubt, describe the problem in terms of the framework, not of a particular pr ## Editing the Roadmap -`data/roadmap.json` holds the page's content; `gen_roadmap.py` holds its structure and the -framing sentences around the data. Run `python3 site/gen_roadmap.py` after an edit: it fails with -a message naming the item when an id, link or count doesn't hold together. +`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, +`roadmap.css` styles it). 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: @@ -95,5 +123,9 @@ Conventions: 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 into the docs use the section ids in `src/content.html`; the build fails on a link to an - id that doesn't exist. +- 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` → `/next/studio`, `#releases--publishing` → `/next/releases#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/site/__pycache__/gen_roadmap.cpython-314.pyc b/site/__pycache__/gen_roadmap.cpython-314.pyc deleted file mode 100644 index a1d3477cc29d9f86a409b53dd603732d1442e0ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 80799 zcmc${4OAT0ohRDW-84<}{S6Wl1xS1ekN{a92#^q<Zy{75&<6-NbOSA#?pAdpiI!|@ zGiP^koQ#oUkMJZOqr@JgWIRDhJjRn`BF{`_<ei;OwHg_@<9*}J=A7Nx_s)4rl)M?~ zym$8f{<o^StDBbW%-h{p=X6zd)vf#SzyJHc|F8SMo6=G(Ivj6(?=Q|3zNgduXZoWa zDay^mUvN6zRh^*o>CWl|?jYym*j4Y-v#Y^pU{|Bh$gT;#1a>v~OzfKIOT<-w(0m}t zmsG49G<N5xMMSP8y20eFD2I;jLiwsaZ&m(uuhBl0?|o^5X@a39Ir2s@YXAAt@jfBy zNk*Mcc;*=A%M>hvNk|qFg%rUoqzXx!jlL`)O-L6qgiIkz$QE*hTp>@$-<;sf-l7w> zO>({*p+G1U)(C5bB4M4dUMLoLp+qPZ$~K#Pxxxmtb>mr1C_k&;Z1ClswfORLbtO8X zB3~y|4i;=RD0MCrs`7QdH9|GcYlTfX7YQ{uuM=u<UN6+)Tr6z%@$Bm@__}_pUir2} z*e*2W>ztggRA@xHvcV0))*UBxIvb!<q#HDKABw<2y#s{HN^~k5Rah6JN6Xl+YKQgf zsGMMT+3X_k8@Ka<+iIJ%dU%g@%+9+<*|QG&h@0Q*bdOAUd7FF0%X=L@J3lJACwZII zXWcBy-_)J+x?Ob^ORpvyhnMBxbJyD3ywl<J@uP0JAea4;kDs)jbBlF+=LNes?HhNv z#(0O1_l~<SdifFeq}|I3v8*bDuk_kc)y}q#qn&k=ws4-vd(!Hw+J!Pvuy;gsczpc0 z+i8<)L2atQbDuah;+qofH7Lz%85OOQRI=CZ^4VP@DAek*(HlH*j@FKh+tKbC_Hfkd zaC&*Cd&=gV<}Z%hU953bJ;258bydiv@jmyM9l#VVl~b+>m;0iNci51{u;`w^H%^C( zG8}PFxlo92-0I_P4zG1gM4K);eB&(B5w~Z$3c&1g%W!eaWhtQM(L9^mW#^~uz8c=^ z=IsPQluKY1S#NszDHp4|-B!m_mkhfvQQJg2ZxgMk((p9j-qT~D#?nKV+s8Wq0ns|* zb6l`D@m8LF&N^+v4nR6wJQXS0tw0vQ5zV>i1=?J6PdROXihBZGhpdnv0g%9f1{|C6 zd8T}noj8RIfKhHwtrPWi^37g5%0kOVoK~;5r4+bTt2`=g<%ea2^EjuxtX%J;)#*ef z06sfDWu4n<sT{RisV`AkhlgNn9hs<AAWYRDoc8*V#JgF>T89&DR54rqm>OAC#}7|A zoVGg8H1C-7xJBRw!=p*JZOUn9jT%7}yb6FTfoIets}oQmRFdH(qZ-2rD&Hdlr~o_f zotm7qiqlON3y;GCba%TN*+|+vW_Jy#V+e(xsjYQS`S^VYk8}=Nx~jNPVmqOVT?`p1 zTYrk0md0Di;1x$6Co7L?tsY0@&xq6UI75Boaaw&eJ|3%|164-6Ap^b$8IUq$^7^K1 z4mS$H^T%f35(7tw8+)9DKcfzp!#f`0JdcwIkhLRjn?1x?DY*+bBaf5DFa&C?7p>DF z4%GtM`xEy$E&;JFh72Cj?$6k3M+NNsq+?99`Y_}|iL8&&utY{(sD&5Z;)Fk`hX@Sa zh7w2IG%S4t1o_4=>bMj#VqBoqBO&f+i0c_q=^b5_2}SoX4}Y%e26aA8&|%uz>@x_Q z&nW22bXA5>QU^fUDT;3Krmm|hA!HsJa#<(sLqj3U(2(5YxK9}xIzMG~DsR$<hDIHt z*GCxWa^rRG&=BF4V`Rwc^NEh(DWBatG$d|B!4Ih_p~lT8FhabW#~hP(m)*OWfVNpi z>{@~%!^xQb7dPNbcG`>^ZvsyLM)z~0S=^*PA5rE6{MYG>_!;nf*nrbjU7xN3S31Vh zX<ism!GXEFk4w=t=+#+ZoTFKwO7BnA;9nCDgkyzQ=|e`q-WD=UddIv}VtKh(gG(rZ z(FJh}?i=y*7U6VN_YcYGpQh!{)h-z3r=)_~U|OAIuKSfimy*FMJ`%16Qd65)ojcg4 z131e^x3*=<vl86^rDsQ<POZIw`D?q9np)9|aguO;3Rr>8HIDlxogtImJ7V?NF+zOe zO)kWFy;KLK5eB>zci=z$y!kj?)qR?r{_?Ib?|RicSO2vO^S#T-)snG#b?u3JLhX$Y z8<5{s-RIR_;0BENf*su^^)7M+pP&~E17`I}H{;IKfAX~i{i?QH<tDmQqtnp8@Jz2j z#={AQZs0-eAsF+44H=R2O7D0+zpe>_sgDcSAu~2-FcT)Vrn|T>E&^UfI`AEd=qt1T znH~%^5Kp42pp706SO=Pzol|3u(P_-J9*@(3dBo@DLAEgO_`odGVbJe8-0?WE`OMkY z!A-3pT@+3cj0qhd4&zjcvaafSb^9<B(WY=E<Kq|FqqJYGuZ`9BaET63x}>|BP^>$Q zS4q0{7)52eXABPRCf9`#l04;d(3mE=97+PqG7Ku|8oQ|v>Fc(L#PZ4`hnGiAQZt5{ z{gOv)4dqi~>zXmgS)Jb2y6}tL^i45N|A+=i=`F9<&syI)ys}~E-3>dJN>(;BU*}%o zzFz;xOZf7o^_#i3Et@&N<;+=Yt$$1H&cRI~t~QjY%%W9EBIOrKauH+XbolKdy+{LM zYFH##h0If+N^FoMINcYC{+@F?T%iPv7>9=_00BP4`J!53q^gVE_`VrG?<7uFb-y%c zub9`}HLsf+eQWrg(Ql3}oL<WMQSVO%|9tSy)?iC_u>AN+d4Hh1KUjV$Xg+<l?Y`Oa za^j1L0cv6}y(MUFx!U$=a_Y<5zPxR=c0POF8ceQSNv;bd*DZL1$&D+?d!*z&i_?;E z&*~AGh$=98a+=lU^ZNU9$0KM9#vA2`L&XiEGU6^g5UJT!Mg~EVm~Qa^oet7z8%|m> zlfdv-6!5s6UaiMTCNwQyZuv&mn|XhjH*Z<kyT}C#8_-J0*toiN3biyoyu?0^Q1l2* z@aP7@l(XB2WHAS53@7L#ZTbS~4T3R}9&JzXw|6pBsUe=q#Xn1U|19H*&N!{ZcIRMa znZs5kbGLjY@m(r+C9^&bySJ*&pY&|ynX}IhZmN3LpWaMav<}oavw5-Azi!|RUJeZ6 z?ZDtbWm)AJ8L4aOL6twJ*~*WL_R*HovVn$D_MNr0%D|}jQ@TJuX%l+^kNx<03F`@2 zlV{pqG6CU$gr+Z>X0Ax<HZNud&5dCkl(HJ6t%vU%4<;XzjK}0o(E^nOPauf#;rC)D z0fEyBo!tr&X+I@$zg;D`y3a*M>IS8AGju1oDg9>MlztzkEf3enbth?amhaDo$=N`7 zD(O}QdrfjoD)GM$vj{5c59<#%$>h(Y$CQ%Lrx#4+$Y~%vwWt%1#~_$9bo)9{J7b?A z@?O)*3{4f3TcBrRWYG3S3G$x@K@~P?U2BNyDu>5#7`;wpyT7aveBYG57(Wl^&{ZY* z7Y{-#Bo<wjLKqf9){uo>(GFo%xo46?We51;8vaCBR33M`Z8f|TS%Bn23LCdOomEXV zIxK=BMKeK!#40xvM2NE{xeH0Wc3CE*3XrU+2#NgU6pAHTY{cz!I&2Y%aI)@kioy%m z5qtAEVahjJy911k8B(j8>fo;oOH>Y9ODV=uDMn5y6Vhv4)(eqG{<P?3gp!!(=fYg{ zIBj59`!xt%AHeBPQ5wn$_gh$N<@@vz_po~mqNjb-Tj#wnR+SVo^a$;tB$5<|Xa-@U zGh~JcVFS}Eh7!g^_mn3>KrtiP!Qs<;2v}7KVi8IVag!mF+$SLe<mZr)*l@-VlLVzq zsb=b%A-onx@l6+g-oL`>s_r)?T}Jlo*?Z|_D`|Csw7R84DXlJ;w&&{pU*;EHzx>K& zsjN%t?qAM7`Ov_no#sBtKRH|Sz@SUdczNu_v03X&=f1Jw&DuY#UEsc(cr$U4`;H~J zrtxlCBhn#NLh=J0ef7YkTf6D%zL{MCWB#X>)R#71ZU1@3@fqWNbMjZbuQpt7dZkHP zzxfAcOUC!ASL*i%>i0{>PfGRsgY~C^*{6f)XYQHLeAxgtIaA<1OU|6xB^mP`{W_hp zW-WccwLQmZ-II;EHX`vtuxDa(WfgxJ(&PoJD64B)NINqHNyArnehT`49=j6)wp*;M zs3X<K8RAlE>8_<zkqS!(E2?%O3&$v5Swe5BAlyQncF{x1cX;}sjc?&c!$lH_zpNyR z6bE%jr_)X)@5Kj|NVJQ8PR#@33t3Q3l@+Qqs!R=Dx%kbS`F0ZDn|Ql*WE=y7ABM__ z^gmWMpAaKRg9HkP8lJEKDj(85(IkUsd?izxFqI7x^_fBl@Uq)oXa{nabrzU^px<J; zB_}~f{+?7P7>#VGveApwPP-M46zNo^5+seH681?iG(T><WeNvY8_H+0PK6j05NPgY z*8DSrbr5+w(Wp979aIvCqx+@}Pb=!iE<w)(0LWc5ie9PFz#N(zxu@km87FAEFV#V& zzTj|Ac@@a_IEK*%B1Fpj5htqY^;K3V1WtxEkLo+Xcvp_|<>mZ%ozFVfMcjBvOAB9d zps}Kgr~JpCkURCMTnnfjC>>?urPJoKYTs6>aJvHC!WF0%uBf_)D?|XRHf*kgob0Rg z))6x8MjlPPYs%@A^QM*oo!V4^y0U_y?Px_+oflF8n#1E(fof$8VzS!F^&`Akg>|(R z{3f6mplij;@Mr<1SbcT{j+GTw0>4*$N(!&X>Qcd}j2@Jy9XtdkYaOvG$flx9h0Ul^ zgI#zDnit#33l%kl(D49pp`wZnRm#{a)-jbC>o{cZ6>Hrt=XAv`dP4+P(UqWR$}N@{ z0NXBj07PxdO?*WYUm<q^)f^%i)QFW{QLa!-7UNr^KM&W8dQbio1KMe;w1(dhi2=@3 z@s*^aJL9koqITiiD_8h4gH?5&DepK-C0FD*_O*OIHhd04wr;{c?PWPU9S0aqd`btR z`En7SXh;GyqZo?{Ak^x`LAP;rM9SFqx#;9VWya(61n>wPb-cHPWKCcNDv3O}s?-`s z;T7TYIL49MaRg--QD|#a?*c~@4I58ixU??`6@3mLNJ0zmi+=GOZ+C*yS?e4wmt8#2 zd-MoP6aJ@`>QT|fT7&%6ipu@Q$5MWE4Aw#EtIiq>S)vffqv?7!5n(cFAy$nM9Q#N- zP>wR)u3S8u(T1&}c1%(}7!O1rL72Tx$B11f%g8o5zc5uEy~MX-YEZ@=Wk9Op-z^8t z$HP9?>TglYIHg0G=ySW231I>=0s{!ufEf<ZhE?Cm6UiuhFGGQnlZKM|_A-9~BOC+< z(`-eBJUtUM;vl!vK`11Dj4z#z==#a!czq~?-VITc(Ct91N-(dxQKA-&!eo13YRi`4 z?L@sv$-wA1D1QZJ91tJS9$rS6Fi9T=v3ELZ_=y@+#Yr`ic@w?_e|b=hN=&pgYh9>7 zp{PilX#-S~rJ*mH<jMk>ZT3sYMyUcQnclGStI*T6^~AVf7715^R8i9kqoquIFi+8F z-XdfF=YkOS4GevyEvPFxfO@3vLKT~U)fe9I3$F~;afi(YIlF}~al3Xg4$X~`O~x_T zrcEla7<bdgk08S_H!MJm5pua<So49QfK`j(JF)^V1-8WryUsYca>gg%88f1g1%|{B z!ffJVcy<>LFQ%v$G;^$blxDY*DQ7HxI30{|osrux2-;P{6Ca6`PvTmL>#z|-V1S9s z*r>z?d4xfO3do=VBVmj4IEKez*dp^d(TQ1`RC)z|kvKM3gJ=ZG!%ep8Fkr&aBU`+v zD{A;L7|Uea94vLPEXhPjh9eF4IKyi=8j8d&4#z{@(FuYZA?TA50KIE?H(qN;LWQ@2 z#uZ^2q0ew^=R`9v!!dk_Rdhf|fb{FN+mJ&KG=Z+MN>&MsHHNxpCI%zCh%)0uO(`f< z9u|xsYnU{duqeY=9TV#KCU#UA4Q^$SoN*7*fUK%>+FfJ5aTx{FeqwY^OKDIn8y;-L zvkJ(cfjU(5ptEQ^fD32CUQS2@KP-v>X!27{a-tBCq)-RY18vr^IaGAH)g0wPh=Df@ z%uw*ct8f-!))SUr4~<orz+Jvdm;EB&4h?{NQvR>XOHJz3CeMmMS0esan~fzqf`JRY zGUPlk+anX`wJ`arghVHgSfb{`fRG&=zX(bRzK4kiG~{BRRaUH;ePqzfv#NGZ1v5(2 z?93Apn6Y_ru^Y$>-b1FZl)c=q7=5KZWC_P$vNH*-O9iNxjI2|TX{bz>t+WZ&RVY$q zIgaUQWH2cjN~iLMBs<7y7aU_mc2N+M!hUDq5sMcKsQAFM%Ck1OHmZ~&V#{KJm8LO; z{v*E%V%gbjKy#=y9+;F|w6J0`R2W)qAlGOhL}V{SX4zYS1Y;Q%7*a(0M<CIPG7(dr zGfO6hnXH+DK8DQVm=K_LLOPPS2~_Bfy@b6qHxP<J28YReST^L!;x~O*0iJ^XG5llJ z`bt<8$Sz-n5r;BH+$3dd#CfEavmcof;jjS539EO!hGbJvUepbB#xzC`N5-wLG5apf zy_y@b=WOPJG<eY}+Ni=|%%5^m%PX0_0?&Eghw1S$=@u~0K)YbGpAZhVyJ6^uG1o`w zKxk+pMfsI>5sc$yVg%$l-%cbLpJJ>qJqD#_FKIyFC7u%xN%}dMtGsx`CU?729c7gj z>Rbp$?kOlh2%!!Mxtmc;m{|Nz#aWV7&qB8zqZ$h1tpd<kB&E;5(lKtjvR<ASp2G+f zr|b+ypt?55psTAM>DwrRMbJ@-BI6h-y6PyYrUZpj5D7#d%S_TJ(9BMKqC(H|TopVt zEX|u0z1aw~g5krU+&#>I9^MLVVc1LH&wPeFog3y7xLCng0Oth3fU}8AroKcW0cW#d z!Z}Gu#MvU4aZVP>1q*Y|N)alAWOkP-q~J}OkcxA<kcM-HknYPI%n~Yv3`(Ku%zfE- zmYE+m#=_YQPH3AsAqT1Sa4*Znw|PPy&iO(<&ILl1P{7J46bkWwjj#siwZdAQi-aPa z*9q%zUN5Z2xmYO1nHPARON0`fONCOL%Y-tVHwYVW-Y9JJmG|j<6+#kBJ=LK^K~`W# zc^VMV@H0OoeK73aiU}iPb-oBQMW4<UevbPv#QSvJVJm+4Aq)_z+8(xHSXRYWyn{)H zbh)7<;QbI&0EdiD)Q9Ym<dP*)MWQ?s;Jg95s}CL-8A`?L)F<y#+I2X=>Q=esJC=ZX z<BahuX>-PJq~15Dyqxr6((H!cPnYdGVG|mXEA}1pLm%8o2t)edMN*@2M1(CyKc%lD zTMW{kFNNJl^R`QW80LAhJiy+d@6iBaK*c>>kB)4pu}8$&VIb@*paN+J42^>RX(>{4 zf+2psaQ!f<wvyr}w1!!hU@3}iO`jeXpftq-WT5uMt2rQIRIF23$fKiws}>?~&(y#N z`N2-3z{d#q7*#m@i}Fr*YTjYQ`xoVHdTQR`KKd8so%qze$*j$ee^K7%r{*1YHvwKJ zX;aF0{Y5ZA!R)XFGJ@Ih^Za6CWgu+7W7uwq!r7Rb?z6+rsMnOM?<P;4m_rCV8Alva zY`{=*Na>6JMT$D&gjCo%qeeDHSXzT2QY-XL9M@%hm9B+qynNt_rBEEWmyF4y0g%hk zeut3}cJo0=nh~Tn68)8+p+hI4Ya5|M9+M{lxo5`Yj!`Wic$cMp_dC+Juq7C^B<o$w z(OnYr0Dsl_FGJVK?d#F$X!;N@X?oKvWNT~GXWEIcgq*%a^g(W4%-be?G=+=GCr_JC zgsy7x%SV1ush*l&K~#RFYTGpV6>4!LsxDDb)?j3njFAHy)!a~{sI)3hMp6LQ<WLfG zsOff8hoQA5Hm_({35Q^_e+FYIDN@t^7qx8^)*jMD>N*A+K8>E}G;!ft)R%<bTGx=E z!S(G5uA7DR+A@G~76p?riqC%=tt*dd8vgDCdohMNv`BY27wm=w7>@BrGl?hJV~Nzi zFS$bbxLU=T;=bhQc570{)J4HEUfYYE++lD&RMG(YN<L6Wa?6Z4A~SUS(V+HF>=mOO z+=hI_?_0nq<wW9?fzfC9cAfwC@k>CuL<N?>r}J0hr|OHEf1<ICa9M$K8Q`1<I0K-{ zjGc)2yG{9L@FaRtQ1dY5jZaBfjbX=L&07d1S}Z`$=vSp2;+zUpi2Tm^mOeFKV2ynI z&iR%-HQz9ej$l_5KB#0*EgsiyjELAnO{U@q7OxyW#<4kZLu4%d&UN4T)VhZ$_hxXX zE@GS6(Wh&~;uc68W=|?gtkC9xbYU6<ENfQ3iCjTjFo${4`cmO>lm_XiN_DNnyoY&% zji@kJJU||c?1-P63Xg$3N-`aTN%a@~4irE$@1ixEw5`Ga8twln!ocv{h5InANA32i zHHci5wpv(QEkdnOx4NzPvLo_^dKdHM=6GMCc3ZTyLwztd<pa6yQ|}{J<oXAIeL@5m z`x1nDZ5{*UyvPnVdo|#FEWgtj`%UcJ$Xc5a$tgCywme~L1R_ZHziI}E*RECb!QnX> z8=nMA;<Un?aSW%`mk^PWz!@i?Z^ASAW*vQnC*k=F_v-XSvRKqSk3L%_z7{;>Gg`=@ zR!7vnid<v)Qz~NMxcWXe5BLUx%c29Nydm>ko^($J>%k19j-qYCwn$%TFepRVuC3Pq zdAG8IVbG4gj8z!46VIZ)iN&C(+?%wyt6-|u@*kiljC+sCRoKO7LA)9?YiIIUnDwRi zWi*o9O#WjrM{H{dzK7qG@#OHw@|96ba9i>bfj)AKLl2|+z@hX(Cg@|D(4uWQdI<c6 zmM7~=LXPBd9CI+Zx8nQQk-NJui6AZP(ehu&qc<i4{O#^v{j4vkF6?{SeJalTOqCjg zlAlyyG7%m_ap)t;jzjxsS&4d})t{i%mOjfWXncg{SsF~qdNMRt^QwK6I!^yG*tMaw z)v(KAu-mJJ9n0kjJz!f)SQ)=geNiJkS_(VL$FEuRH)r))+=$M>lO2IdL^|lpR(nVD z%o6>jnxY0=wR#Bgpi$H(L!(^_jl+MZ^!d-_E8`P77@v@XHpRhx^ll}3F|99$@dBON zHmP+{-tCL`Zb}a<@1!w^>pnIXG46<iKzaXCd;`A;e4`p!g!nbWb5<63T&1jiDzqY3 zw2g8~U^yiSaqEv6Pp-9+QykilSz|TrSMttad1naw<G}`$YQMIYD!;zE4ANY(d8{xF zKTdGa3kTwrfEf-ruHnsV)z+hogW4~iF7Ka6f~HWGVW59ta{sG+Nvv%~6$?-nq)b+p z!DIA*^115N(uB2PUv9kvGb{7zj@%i;&%Wqg<lD$yLO7SmGfVhcqH@QipYYR2%BSB& z@>yL9Yb`v~kA%!*8YFvXNbj)uGu3~xkNB{i_vg@>T(*GNEn-bBoDtwS9!gMj=I~gx zh0Jswq9vL}AKX6SQEkT>Bs^D~LcRV5^-j*0EPc$DM^;i=l%=fEg^M*?0Zav9b*J@0 zDA7J`r!~NSH80+u$GhxyB*bfGm{U@O5}1Qo$fEeEU?~W^!R3X!UaU5Pl@t$DCl<f@ ziuY$X!4@R1pjO}Vv{046;?In{l%L?0eH6AppMMQJiP>EkR!3_1qdb<L_xLk3pHabl z*JFJX?=K1`wK9_|Rvp9OhcC!ARixz;{^}+&#nE#zPSR5LDe~TT!RvAaOITdAPL;XD zBfC`iw7SE|(Z+GqOSB^!kw}OCS=MN|Osu!Z`e7`NmOD{dETFoK!W#f4HWAsAD<)f= zUkeXgxH6z{^}#57=~zOwzr3MjjepNbSiC~nN%%n!*FBsE(QoFomYhCVVWNhy_B(t+ z2ZlPQov_sb+eeHB))+<Sz@|_GtvnUD29wHs7Hh0Wv0wBUy2Hi*xkNgX+J{yot<GI5 z&f(j{$T#X8zKSvs#IGp?L#<lZ5N~M>a*8qse36`DOOM~a*p?9?msd-Q=W*-T*VcwO z-k&LB4Delq8xjQz@Tc0Q;1^B9gAi8qBei%1C#-5_Yji^vmfB0HLkYbHdyjO6lGzg8 zA$e4$$$!-GSY-?u$<IAxf_DX5Ykd(R3PdNe^is!^mzQ6amopkf`&g(@wYZUqF0!Bz zul^f-@#i@GGi+}unX?;aeXmuoWK{&RDi%zOIq#+hv$id5T`IrL-QIB9_tWYR*GtEq zm8@gZv*XhEIcdkla*8wj-F(M8hi)7SWYsO!-ZtLu{i)@H=cO%O%PGh1r(~?86a`X> z=6dHV-WXi)EsQR<E!vj0zISeMK&sihoYKyc6bDj@=Z*8H@9|p}dl&aDl`M7K&iV17 zrDmz&z;epL`x~nkD&8KP>3I3ji-%@?a~-ewgDIuVE*LVa4(*ZE^y(84GpNqrNWKq5 zFL|$Z+RImf5Ug560K-0|B4>7TW#Fk>XubUqe11Zy$~}m)b!7T+mO8BE$F)e}PwrvK z*%E}uxiJak??M(@q%cIC=M{&jV(bC?gxAtNw)}?oCsA_tmR5Y)JzhUs+7I$)_k@fS zFx+FE4{~D<cz=3?aMC-TmX!`sv<SCLBo&aqqzSGch>T(7X>~q)Dd0S372$RPN+_ob zrP%CFILskP#L)1xUj;17Tl)r-pF&&^k+rfo0IZh&6b%e`KRgpwB_V<xuCjj(e76vg zf>NnIMDn0<_@dShC!qF>BDUg&C}N);3NIaFWIuQHu)y_asA4j$5sk{3s1eJV216{Z zjkmNdvUsnDi%SQQ53Npp`nN=hwbV>7e1R{F@k{)?FSYXp%1vlU02ECBNCJgF@$zND z#wLH}bI-A*GOR0Dn3Bhq=J0aP$NJLN$HwN(Y&qu^e8{j@{I|Hnn%iM|z{n4&F2clz zc$%vC#0N{PF*!zv5t6P!eY`X#h!|z$&RVaJT^pOTe(jt>Sn}!?tCre+vj2nqx7&V5 zRAK*VY0H`AjDh=wW%F(C?7y*pq3v6T=9?G9#SQOQ->P0Z{*(R>`fnfq;puw~2c-Ig zcXFisE-9mnQ4D|Kz~uqC<xy)`pBs1%?IDF5TP+MnhwXGfPbbzunr_Q^g}j>NW`v5K z*2qF?7b$AduHWkQsmC}he9OtZzU`H5(wc2chG6z?DP^}z#}a&Q8=`X<u^h)Gk|-Q9 zlQS^f$Lt|9IcQyQ*e}wmcIF#4gdiPuTPT}dhGdtoA@a;c8iSTZsq9H4vIwy!k26&o z73NK<Hiyi8okGXK_TC<OYBCbH8PfN)hfIA(+V*xH2_^LP9}_z8moD`oV=tnhgiJ>| z_jh)6U|C^jdngg7p@SVgAp^V^#TjabvAwOgCzP<SlU;<)?qm4J$Y99axo_W)(AITW zd=@Fib1b>dG2#oEnE$9ZWa>TE+jgWUWTx*}#tA(KjtRX8H_&#pGi2^L*xNZo1)Jam zI|W8Hi6!jl>}h8h8Zz`A>kb*<Y8tY14edR0to?AO&=a!s^q%NAcx<Svvp<xo+@l$7 z?WYjR<Um{3{?49I(thFCiEfs;5$!!3GIk$n>k1irdON#ANp=_3@)7hy#={5EdQ<z6 z&Nj*!E{|AiX`OPS5pZ{6?EC=)LppM>t23lO)>oCwuqnjZ7=DHHfH8N0g(E;b6|0Yy zONJ0e#TMd5L)=7&yA(1ETk%g6ze@1ohD9<%!P|x=0uJ$Ehyk?`{Jx2k7gDZnw48k7 zVh+X@h)@9OLMd9L{Skfj9q^q%($A_lEgbsO+F-$so3-=D=eE9?KbL>AHc+qwZaJx+ znp0<NFHgQW`3>J&d%u1ukX9jWYW`90Uzh&y<kBH&-)ZU0(8`(dz?pIBv_q;ow``uc zUs^VQ=#OiIsatN;&K;jMesyg2{Fl$&sGZSEsax(hY<s`<d$qy5J-2EXj?b08v2i}} zjk>$}mA7gGd3#V`^8GDa-%tHsYB0C;R_a3iT-F=8a~IxNcQ>#6R%#%(6)7zDpV|5T z;P(cDdF{6b7de)P<=&c2ck?&h8VuyMvrINOe)qX=KNrktx%u2e$(;U;gt^f-Qt#$& zy!l)prv)jJ?l*3KzvX)^!MwIxEelqbiTRDqck`=mwFL6oD38x7s}`#NG&PvL?PltH z{jBY^{d31(Idn5Mki8AB%>c|iH(&k7sfep`BXzERHs|GwGZ$~9VqxnKDgtRcrCsi2 zv*&(h)@;-Db+hYc+K6!~xxVq*#<_&A)vRPSN?DEb_3u1$<C%rRU|HkhC1SdMnz%GA z9XKmJGqjxYY=n<GK6mkr(+fL@i+XSW;!dfm<>QprUnS_WiXWJDxp}kB*N(9^ZK!<b z;5QEjv+8afoI5|;dwu)t_8SLh5~ZxV`$*+~Z4jv}_bVzF^8eT$%x$>gpD&p+ULTts zyWtPyHn5j@Yp$QWc5WrFJdjtul2`fSzL|Db@!3PK)y{7ZWK}PWFM1d4(yp#M=cVIk z{{Fc@*N{~6>~e}#PIln6O>bodvdZQ!EgBZ4q^+H|t#`~no%nD_+IV6)r7x1GAdpqH zaB^{E@r2aWD{YFr+)pfNyOh=bR`&ey`HOF#{#Nl1+Lw$!v3y|p{;@zw`|V3och8+E zsn;giN2M{>J-b`7d8E$s%PHc|b9c>hY%+PxHfNi4uH<cz^0v%d-?sh9@Qrhe2bOx4 zI&Wv)ZoAzm?HiUFMn2B4VWn?g^JnXe=S_d)2xhgv?wHG5?0CQHR##xt-s|<V^{+c- zs4`UXkyk6`PR@_apOC71r4uKm-c!=r)5|Gmen~w$yYIECxl0slXW^){u4yG@*ROJQ z`BlHk)8((lnA-LFuD5&(9p624^H8v2dvNWJVBSsuJo*0c=*qD3?yz&YU@|c5lm^dI zqHiAx7M;9#XufC3_Y?mI{ySwqe&No=d%ODsP5p27%=O$n6ev1*x9}uBxLYuZY*IgK z+`8ERXH7I}nt;y6*T&|~&*#74znfQet4SF*pVc=kZvV42!JOt>YZltT`uERS=S^=o z?&en9S`)}=Mm|aRckEoM_zO$0p#1~OV*T9KH}dC8<`2GIbGNYW152QQ1{aZpuh?d{ zUT?Y9vXWg9$gT)xR|V6m@76g2X^#78p5GX_B$4~9rFH4zUz`pW9{Aw&5;xcJM#X&n z{NTN^#=C18KR6vIJb<jyuvj~<05_K38WWNlAEfDWa%WG!Mg!D*KQs4w!K(!`ZJ(s1 z&vH`s#`)=wQ|i%MS!;fY3XIHdxL$d!@}<dd_ycKGQth+LR^WqU#XcFZPX?_n=~=h5 z$+K)ef4^??V#>SjrSrFo@9jP=nKvQx^tqhn!c9~{@vFt*5-JwfeVnqLN+|g(Gj}Dk zY&o+m{IYuC=*KBd^m4;z`2}-Duf2eLQYed}SBsRvJqy%*D~a$LEneF2<3t2U+0!i@ z7o?uk_l}<lbf1+r(ZFUcdUfX<8`k%-D;G8`r)<0bg;DVI0=KZ?-NgB6X$#2jS?TnU zwD#GLQ>?#A(q*lGV9{0AeYfb_MZuhHH;d+#ZhqzT&7wdK(XFKW#U=9%lDUZPXScq( zlPJ{o>uYA$%(UGvDV^W`_8LfB2VXom%djYrvi{Sx8{~1gnECPA#%qbQ-0ap*u#mrF zu4L}u8#N2b%Nbi~$l2z$z2<z&7Rak!IJcCuWc%@&rIg#pq~4R#siAwl&jxx%q#E0D zhW(crxpN!lE`wq(=WSZa>yYv~7JQ5Q-o3Ik71-Qy$0GIgOD6~J^$beFS?TNx4-hAW zYt#Qmr_0={zh70ou=U;i#gfHdsj_L-cs=P_5;F6>Q60?K_%K;lw3Vo0(Ssaaejyr| z{Atd*Ioq4#ua7TqALmrR*fG;KbDl`y^DDXKQf|3ID=<8FzP|ICYr(vjxw!Sc{KcfD z;yaliB}hk~mCCKl8N>H8a;_&|OJ2z+4`h_jpI;~qX4I}^Yz<^=T|EAA#`cE^y1WXY zM(!p+KP&Hg@wMXltt(~E1j?RS$$Uo2d<LKe&a7X_uaxpDXAK0ck-6SCPrZJMQQ0Ob zuW6xdG3WilTZMt@rX}Z{`a8XUzx_^?)H5uNj0T1s0Kz%$kRF$o!weKh^!K-JU)uVU zogeI6-r6y1yY9H=m^ZxUnm_$<-j+KXepdBSmDJn+_jS^#)AtUYkp@QZ9U5Z=zdABk z_O)?FTK#Vi21^?6<uxu|0Y$X^bpP$0clO@#el#YXa7)j4KF&D*Fi}@fML56hw`s<t zJ=}v7YMwkQH&idwFZ93L6x^`w0~1D9-jAn)I}S=IC4lkRi-+d8x$>_k(YI?bnBsxt zlfq(5pcQXhf`yyto0kr;k|;GBQlAv82Mx=6V>(z+h4-T`9i;c`KhxlALE&7*o0d7t ztnriLjq|pJZErh+#f`JZ*}ku(JT&ME%jk<D5ShJPJ9m5g->eB19Qkm~QXA2=8|4d` z3r+8?3zlxbTe$thHGzU7z?jtgHMI-p-aWcfvpZ0;d%31<IcsmArtL-tMpFH?(YfPa zbG*`w2Y0jf&KQ3`@zJmQQOwRqzewHkD3rJ7Q7E?+*Y-!DoEBW$9{nPF+oMog-J@Tp z=(0CI`bBQTqhDv}vOBm(zqaVso#Gw^3nuZj_0cbKnjeJ<+8_Peq+4?kZyQ*mU*v3i z^y_X;S9p|r^z*E}-Wy=rzM45<F@Cr;Ya-qFmw5#fImW*-7EBZx|Ei&2V!iRN`GScu z<4@BHCMu0T-I+B}Yy6wC%!vl`N12%u+l?Q!WKJ|2|HHN{CujOustM;nQnoYM6e!}I zrnI}9iOz|McyhP2(3xorrWHE#jKQWt=UU^vf<h;63~kSHZZ!Qo&4lx(rYvW*@gJ*; zotusSZBveOtLgtRCE(jA#ij{0h6H7P_zT=Xt%<XI#1H#LA><;V%shsOmk#3M#8D%v z9yxu?3nyY>l!Jv8>v}m^9jH}EM)K6u#;n#WaxH=1c3)WiSfRYAh9UwYwXu(5_VVz1 zILk93+iz~(yqg74Vlk;m0b`%^_z+-*t>-3Ph)=Xqs8R?+SZ6k3_m`e&ZXLW*Ub(xZ za(9z;(sRY>yE5jxLXq>XlnvN6Rqd+smt!#x%YgJ$2wFKY<>BSUjj%PicsXnnMG8!a z$g)hH6OW?e(9dJ7(a1Og$B;}b9;0UoY&loR2xW{<)gsCfTx1C@TNM{HipfnR+LG{( zfKBO{FJJibh1rSuhWYcs^r{&nNeWhq)bf&JuKq>WN?NIuRyv<KKQ%ul)pki6jxC$J zk>=IX>s7B*Nkwh9w+6EgET<i~bK=f^sb^a9Uk*$^FCBW}|AIceP0!@x__*{3tO67) z?}S0)o!AJq2;RgVG2V#=jd!9E7)J6vJ9;?{{s14CH-j;z-*tV)Xr(j-Y*egW23Uyn z)Zwaq1f4zU@Oo*DqoT=Wb_ztP6F)$atbfp9tV@_E_h0Ghf2Pwv;KZWMK((vH)e&FB zvrt}iuc#X0dg_=CB)F>kWX-x@+B2`0%^qLa@ZIX0)q#?&FQ1<||9aVsK}vguI%(u( z_l$e4?A5XPjq~|Z<-t2;cXFlTV;`HlAN|^(OW)?TAftbs)n+n&!-P}RiW3?rY(T$) z8(M@BS#!cn%}hfVH;y5kZQ>1jO(#OaJ>LlTZ_@q69DRnfVYi1yJ-QGsh>FqE5Y+)( zl&+6Dw-LXG@PvS_FC2rg7zSOgM;{Me`l6f;qHP;W+`$sLZ0dT7JBaRl0oixm<XE@f zG>CigSMJAEDkQNT??NTvUWC2M=Z&bv!a)Zq8krXb>He8T9n9W&t7nm$?YMsE+M&7g zuN|GQT*=vVH)qrLdmgc|`+F7D9CTNDD)?PGy^oVR+!=1=(9=(FV(Cd&Agc!c0*^vz z(e>Fzg*W5HKa-gtdznfPX73E9?4055gS)K#c5N`7DGO%Xu6JJR1S5F-YlnjwJhTSs zJ0$atM|abAF!X)9sLg1M8pTvahCoDpNv(U9V<EFqzalJE`ur{_qg0ej%n{vjP<V3U zH;@Fq^F3U|TXb)b2OEKb=~KLfAQ)n@@g(5_v(Kigz+iuy2o_~Xc2!60vGuLI8Q;s7 zU%Wh5|JCOubJIfacTe90xBu=S{>U^-TQ|y|wRQ{30RdmXKns~+4rpDapQ7)7arGO@ z^$oO%8iO`5qQSb8ic?#lzi>XJMmnpzaK44=PR_5Wdu9&w0cWnAna_Oftdz51zFkVM zl+2Z@t4yPhRe2lxP>xZ{^xOC!dqe}B>9}bcG>C9y(}{mXr#Epzy%o(dtM`Azqo~zl z30S4-nh1p<&;IVA+GC`$+OM5!n`@jKp3iw_%?${zZ?BtAU+_ukTP5>W)`YGRE!D`x zS7<}Z!~dIo{zZTu<B%Ce1;X~!@N{4UGg3shRZIthp}}yJ!(u9wr5+xWkkCsOR#Rkx zful*3bGMo+QknY<>O^A$@kP!_eFklQlt0p0nAJA9&xHFFcAtt|(_-}f>5N6mXvjoQ zWcrV^DRwZ~YoLxlh+rv-7XUK`!Sv7UV2WD-91IW#8%%*5W`VCPZP-eyk8SSKsycBR zE%fI%Q@{c>Mz}bM$kQNWFc>6aZ71He%Dhi%RO~x{iZ<+>c!@GvTdHOPE4~_NoTv5G z_7U-4Q;O_TghEI71Li>CwA+TKTT1;!wTu}mZM8Ie)C`msmA3j*nm2n|o5ve4*&?l3 zP0P%%epB=2@rG85Kbe-H$`EPAaC#4Av8a_*iEL;g{2g?$#$mT@nnH2IBZ4(iQQq2z zz36`;9Ff_XTnIwxD(O(NZYLD}Cf-2Fnv(JIffo<V_I&l|4EJej#%$*7&ew{9sq24c zmp1HL>Rj4>yCP6}NIG@qUh2S%{(e^RykTx?{>Trq7mqJay?65a>lV|c-KSU1O-chp z(y;yG0lVa#l1^WE0Cyc(G(3!n5&sV9kKw`{)0f{bubl5)C?Xx(QswRX+x<Unx?OW; zOzJx=oj4<%8GB&hG94V!CY|F}^IOzJHi&4dze=3BK8&QeA@q+-{%1=g=o|@r8cQDH zr*AL<y$wKVI1|J(u*K%08npcTdYIb@?{PStz-t%!jDo@=L=p>pC;Q=8ofK+rI*jTp zcG(5Q=(y-=*+)y1>--tb!zvm9_bzyVHFAW;3oQ(jLmab(4I{KEuwh&jzYB<ni#Ta# zl#l^g#?B~IwFq6iKz|PrQXF6~{VcQOXJzvT{-kFy>pT4m2Y)cUl=+^0vHwRsx3hlO zzjQEAx=-pkA(iw=eWVguIXMzIIU?EY_fC#V{bSPDgp}@-%+A#`+=$#+*AWe&t`qbS zBUc@fwY~s8s2J#l(VB#W`?L}Ku~&hcpRn6KGL-;3>$D^GJfc&{_I26>fW>!U+%0Wf zz(NziQwLi&FqasLETHTXATRyqvB3hF-eD)<Ws@365{olPaXFjAR!C8Ct{JU8*r8C5 z3oUhZtrTUAY?E-mkwfQTTLA2rKtc1!1xN8cQUVg9EQL)5X@heVg+!tV@jOM(Lj1vA zd|Ils*jtL|p@^JIc5H0LQ0HIYY;#=DAP0*Hs8j`EZ)GB7E^TezjC8GF0gnL6H<kI> zhWnU#jzS7X@PXylMfd<GK6Dhc`B_^Q4ey(8nU}J^pStkE(zukqUo!7s-NoT)x({E< ztD(cAlV_t7-5$5oY2^r3(L2rY*mM;U1S60k0Reusl%5DP$oy{!z4*U;pO4RLAHrJ= zuMJN9i}BhDcMX1pID0j!x4izG6F)?*Hx<_UGu$&WEsr+EIxDl*8^R<Q0`?F>DaPZj zPpBZ5x$F94ALZU|Q?1BkpZdF8g|FtzIPw3X;+pA{g6|aOE0iGGJ#H`S-cf~tWWE}# z%uwd4YV=Z>G#>vMk@*L#Qs&0_(RU_pOe|CB!TMvt@?+A*Zp1&8(oafehcxEEd3C=l zRN4ISF0!)ftop0uXN&vT1A>*_s;-AWsMERi5xTsKJ8nFrv+;x{wBi<`n_hOV(Bj0t z6kTNN0+17|?@QKE*KkL3Sr_S#W|C)W{IAvdK*dUB<=TFou7?D;!(jnV9~R)?p;={! zLMp;ECcvG=O|w2Mz?I5K1;?$}X5)YKd=V|e?ZkTp(Kf6S<DSFIF7f}w8KTo(T*Uv4 zPM^`qiIc1*;jhZx05ZOnX*=3G<Q|P9%$=mJ--rxkbgStL2q*3wUpTH}+<UdR6XCP) zaqWSia6&=6>RPUMA8?JlwXh|KYg(OZY*d@iaAL?mTK=poys)UdRDLAj0`q6YlYT0G z559N=Ibdz^n@xY%w4C1mdef}+yV>8#zQ?K)tm*r1_II<t9r?Rb)pwnn<z8<Jr1wka zeu#Aa%*^~sdfNu$H*<0N^CFx+Tx-JFkL_QgSwgQ^+KPF@=2lq3C?QZm;*^Ipsz?Y_ zk;KLnFT`pH`qVIIjS$ueeL9dPQy;MjiH!3#H_*-nN&f%Z2JRF)rx4#NPX;nc5AU@@ zky#abju5MjO--`n1j3&o<_v9nasgpc*mOkx3CM=|T3A4vv4;-x9xG)HDP;kDSO{A- z?I3zi9p46L)X8DQ>7jXtCLvi(2hcM|;EM<W1h~Lwmsk_TnWk%v1>twBN=oJ{d{MM| zP<=$zp*@0V<0mx$HLDAS69Su(9R*_$V^?Ww$gris_83Y>#&`2F<85vpZ)DsrlfWLE z*msOEW~smeH@1p|k8mtBAYMzz!fHK4L9Asz0|QzWtrQF;!d1mRG~O6ta+L>zgkGeH zy{h{si*CnGDFf~~u##?Hs0^evE)CBZ?q?RxmCfZse9N9snD1LSw9veGMN03G%pEd; zLk{6Xnb=Yuk~_?kc^3)+(}yKfstQ;oH$-98BU&b-3C&7QN4?~8!*Axh;dH}SQpd4y zO%y{6v5_bwlEzP$F*9|<Hz1%=C1Vb1cmkBdImnq2TQLI1I>=HM?9zldnn(>C03Og* zNenaCz602FNF0UMSKhsf5QcK*t)mp{2_I14KZ+CO<SP~Cr*6bcQId~9K(3`oPn6z* zT2CvS3gUu+L8BH?OQ=m%OhZg%Iz--vfapLsO=hSl{G&<r%&J9gM+H1^U#)wb)ogQW zxSflmrLrs+MU+WB$cVBLzzmb*3(B&AEYcVVvQnq|$znoOKSjy9@q`3zYDE@S<ZG0b zOhjE)`3uIQmGQ+AZ4QvbRTRPX^U4FoF*AuesUb<3Iv~8V3DBZw08{)%;uGP^OC8}) zR6yuALN@lvV=|$&+2(5{^8hZ*Hk;p!cMSGo0>Jn<882)K8%Y}CYCMGV5p^dgCgKn1 zKTSz+YqAcD{~dP<^P}kT+UZ@1IvLsQh<fFWGZmdfPTpmtCAH!MoxZN@KdmddmNaX4 z0L^dqQqS%EKRP?R^BdmzhOb||mtDS)cP|^U(iexnU-5DFPU(1$l+{aa52pvEQ!vhs zN|T-kI^7hvkBpKoTmoZct30AVK@U#q&yYYmpg*gBNdJcPzomCW^l(`3l>a#?rCTr3 zLofIhq;%^q$nP%D!!hn6T`uY`(Pdiy99=HypQp?7`hy1KnR>|Z^K~VUyku_rQd{d@ z9rri^@N~Ay92*BK2S$x{7Z8&Sp@`;F{%<Gn)FT%F$D+&FFhBgx*p0DYDR6q20C+vr z=_|^9tJ7zceYUaYw+Z@;T7WUBmJu=p#D|;hzOcp|xv`y<FyEy-{0hrfGuIi|=-`f~ zk&k{i-o_rZFxq46Z_{Ecb`QdLu?O2d$nZJTFhQFSsG-R}q0F6#0F60Py9pfyvBW-d z<jzh_YUxGrFi<AC4?ktmOd+pfgS$$&^CF8HzO)M|;>?uTXQZ*&=}>74aG$b*+(a{y z<|(lal!Y>(VO%*KaY&FJ0AM6&2X4Y7C<6wBOqG>T_{|jJ2bE!)6-Ts?=Rv*9?jZw3 zVAGDOQgu}MXa+=7+tqsnohQ7>!PiV%U%K<AeIfJv=ijOSX8m{U%EmxRYq(j<8qN>I zM3dmtnb;!?^&aau#zaQN4iwUlH;RqOER;Y589OWxPor3ZHdM>CQl?^T5Es@rDK(=& zU)~l}<Erkz95@od3`^b@gDa=(fm3z~S_5GW8zH1;e|37U;mz%@Zx2vSgQYuzYj@sD z-?_B^UV6J^ZeJ~Qgw41okv9pOg6@f;CBn2h%@9fAU};3(uo_c=k9tNa<4-j+C|Dy@ z!bz8{QOk-9C#Am;y+F{|nWJR^kaEQ$49Ou*1^ctq<uFKTXh>A>2J;Ie_mGL1!6)Hy zQ54>S6?2Y-^%JF!6M)ylt<=-QtvnMSiJ~*55on$6rXpN?f_uhpX@!d@VKz+9<t1Wq z5YI<FE+8W&T7V+v@7Mnd!vG#LfpMoa?m&T^`hEK4{1K_}8CAgqC%BT^*|$64C{q1V zwREB{c<8ir5Gujpd-)?E!1VbeW(NGdlr}y0Y8_5!!4ax38H~hKDwYPJBKnG${WWGJ zxw=Gez0cqUB;NZl7hinwB5H#QN*>>e=W@VvS-SE9o=|Bh6fbbj*iUplX}@9sMi$}2 zh&yzenQ?|+5DTe}F~=9hJu-DN8uLxuXmGFNiz+l?DA9U`nc)hp#!MI_iTRB=`nbsI zbYP{vAwj`PXq*{VLT$&e(kSi*v}B_TsVus{G5iEq4dN;K6NY$Byo#egS+5d1g!PYu zZ<nY~PvO%rzP0NE!)~}OO5Ter-sc0}=h1z8^lkco(qWs&y@ZT(!KN-+{M)PeHJ02t z9^8FQY9+nLz5J8GVYh;IYunPfZ`I&*Q$ahjYJwADwI-w(mBn5Isu@bw)cFEca1&J! zI`;_Dwd*Bvv~=Gcb`z?#GgND5sMfBaTDWKMaT0zE)&3YaznhtxC`>%sBIqL~Y@#kt zNQKRtH8cqOI>pTkTRLk}B{EUi97Q>ay8VB`-e|o^M0W&Ys74{GBwbE7{>L8CxE;NY zKL&fD{bAT^U`BNDDzZ`?Btphx-JM+`@F^amh82BoxZ4lWx~VwmdYQ0+YAkDBa?jrK zE|x6Wesb=Eb9XX-JbCB9z19=Urv?M9gUflf@S=n3GYq7Y^9NS)YVYRNe&4I0<3?cO zyQMh!(~hAywg9S$2ay+fWImOO6C?IVNG(c_OQIehRUen)G1;InFZ6(*63WS@dc`tr z!-J3k3uP7MjxymVh1(cWEQTNXsg0xs2F8Dymb*-i4CbB<rky3p8)l?;5^p3f7#5Cy zD_ttyvY5Iw@{_R-#%_<?ssEu{YU#Q|2BnpOk-z{<3uBVwTyS7QI^&d_qLl8H%wCxr zAlj=Q8MtAC;|@xu!GYNxcd<ubc+)bdB8>>tVqoD0!Gy7?yR$DLhCpKK?(byY9XT>- z^O!tFPXc%iLgtu*d7Cg^qtCROqI(iy@rd&k(;CD}Zzh*649kRwdsw9Ycy1a14=*1B zL>$-=;b&Cs15zfkV0h*}Gwvxk2vnxAM^w%hq#^u?Iik`eH>7JU#DETrZz)U{mf8>{ zJHr;N$)^m{h4z2!TjscA@FaM!D8DNGu^u~MYx|K}%t%NtZZs3%x(M@yHtN7iX}?m+ z*RoO#24&F0n$M;zP8sxiW<R0*ZNUcU#5Uxk=vbrJpfCfD_`=wh`C^=bew982JyHgG z!QKzVTfW8f%xl0pZ+ypc!?I8x<hMx0_4f)Jekdwh){T|iYNnI8UDj^WK!t@1SZHLH z^NX;T;Yk{>ik_tX$;}ScxLE2>Erqda+KKJIYHM9hk4<Dqa<mdah+9-de&)8L@mEn@ zciNFeBvZz}qQ;Xxk4YbS!bjqVzT4;L*1jbyaBrWO-@nkZl(}SB+IBC0@82mc|5DjT z&c7+fxCQV6x~N0gst(Q;4{OJv1}Z@z{@to6&8rA#%+?-lBEtuz&^J+|9Ft&+BldgP z%l;MB`Hyh&uaQk=tIBf1gg+R|UZYmBzO<#ZQ;s5_>;tGez>#j2ZH^5~BvzKPow1oh z%xNEmtr2QiD0kt}V1vv_x)wzd>S=|EE4H;ZdFi946`E6K)`L?3<9d%!<^PsWBzCeb zL>c2N(g48DuEd;#EKElz>rxpxH!^J^<GJ!EfgXE&8wn^BJ9*6OC2}&#JPELNOXg}4 z6-18+c*=w4si!yNL@ag5#&_1<Si8`(TvETBx;;=*|9Z|`<`0g4Wpw8JOQZNZqYtER zhqU+d^o!HWG$MipdxPolTUMCy0AWfnPY9+9c$U?&bZ%+jPTq2N4?M?%-6!w(;Tdq6 zYyGsf?XNf9u>|+_1Y3KhmJ`wmGDb`NgVNxT^sG%9vLoFnS8=~$(>s@MTwZL%`WC5t z2d3?veZ*k(Lkk3EuwQQ{W{@s;II4FL3)ZhcNB{ct6Lgt?-seGrKJ%jfpG>-)EmCgP z!iI%B`1xdS{cWNlb06*PcaS^I(Vye+TQDRE^r)TdWRK#$e*_=)v>i;A`Gb`9^`>_c zaf%{yG_|qm>@;rvM~W5Lms8Is6~(l^n$m<tj6Xop8VcJ=BF59{3>Ysh!;0_eh-SP% zo%Kkkox9~^tJ6h<-8~A$3JbgbQX&&feHrB^p%r=MA)QK$4TcO;aJGu%ElffP6MPp@ zviJi!?IRqfxmjISD-t0Kg$-%4@Hpg&L*-wjPhmM!r4O!P%O@pub8Qa{y0tAo5^i%p zKC!g_cEfGk-%fluAqhP{bxEDTS1h6!TN!Z$MqHBHb8qClG%QNubN33K|9MHBtTg<+ zHd9*?=daPIzezpI0<%O@FxlnA?gd4paVQ?Z37i}aees7l$x^X0j?HRYB1Sp&$-kwh zkfxF3l#HzFiPsW;qeFXWRImHyI_!X1u;rnS%iJ=P@L6Wdl6|TFcBhnnNHQN<t)5Y& zm6V779{F9>^=EYB8ha2IZ0jV}A-=LP8g(wBgTykA5z-7|?#DQo8SpGBb~q3X2D-Qi z8T+kb!fQZOdwdaB-c^|?sD#Y^1Ch-%tdY0_P)=fJ3erxJ35C&i+3^EjDzZV2%>!2X z6)l>ri)W&=!@i;MHaQOv$?~%wcmB2HVzSDxiG{bNt(AV08iJuJJejaL1^E6m%H~5l zJ;q7(@ctz|_>4|83yAk|QuKOZvTsJ-&@aLhG8{BteezX8muY+hY)MYLUUjW%IeG2< z{I%DouT9TqzxMo#2WHx4OYi4CvpBqz^WOO4=_T=YDIC5w-CiekIiz#0m2<wpIiECj z;oiB6lH-zeNtM$<cVB>jj>}&Cak}IPbWRoYG<Eo2^v{*O^nzqwFQXl*5k82nFv2Oj zB50R)z`?)(2ZJg?H#iuO%L(%g2d^84YDM^Wt;>OI(Fwa8tY>_b{^%OG9DL#YR8#^r zMZ_Cb-mqc7fP;?m*aWY<N<SVoO%SRwuF!=W4XuWai}|-`A)|l@wyCJtJ!);@({2is zsPM@((R?y=AXkuO7p_GphWP~CRCGCMcp+;=R+H2URWG8%Mj3(wr9BQ=eGQpDB;d$W za{h^r4uE|$zM1-Z>b!Nq^7dq~V5__U_v3=Se|O-~L+bh8Yj}p+*XzG3SX~p?{S~%t z?B|47I3we-D;Io7<?ULrLo<ts1yd&rRRFtr1%GbJHb$G%hPS(6k(OXeuzOt<6vxTw zL~b;=6%J4FMwNGd8n!cgI=PHtT=Up+2_a3WK&+~PEfdyLA2vae34^ze&AU*z8}LH> z8&qAP*P$dl7$V}KxbY}dJ4FkwP)<A`z6ywWj}ye;%#)M<wf5!w6L&g(cHrL~2<D%- z-hRFPm8d7doD(xi5A<AS<3GS{bKA+^!tv7094|fG{fSBK-_^s6#4t9@_Tmm6KY7|k z7x4#F7}4VTQ__Ch?mT=+f=<j4jj-_oE^Sr5c*6Hlj?+*3UTi=PWJ-=XJjObCfXhB= z#t3mZhC$o~N9ZoB{E_4O4TJ^feu3sU%t8rlU6v-E@Zo%AFqs%Hme^qE$rGY>Vjw}i zL64`Fn0YRlJxz&un#!K8a=LENHzuH7AntG_kJPJ&)hk1;myoFq0i!Nr5wg0W`iMRH z@|a&$wqj1nh<#7-V%5G-``cpzOtV*~6mkY~)u%`ihXkuRs9MD*)QJc$_P${To;ZEC zx<;U6;R8yUxGkl9bWkgr7GsXp`FY5+6u0CPNy=KVHRypPAs^`qVzR^EzNq~w(DMZi zv?8oX#u1MNUlzvg4U4cw*v08oI0<XDEkl&bU0N*BzVjqU91Kt=)Gqc=^NdP|8pR%J zx~SY!2B1`t587u4CGhJ1$hEsHa<AU0xDyA)smLV`ZRjQyL(CzpQ)?BTTmf}VuIuCH zA*|EF3Ggpwizl)?X#)R*FG^y*C~Yu!(vda}7C)_>o(v77u+LIoMx2&sYSLgArnqUc zG-)<IAx*X>4WhKi%_k@RXaM!Y3RBRziY~4%jonptacHj!U`-FGeaKQ(Aw86i__7*z zaFoN{Cfs4M2<~cdhg~{wSBpFBx`4Yn+@-U-&A3ZpcU!u+C_E&cTFjyLI7?rTFQGU^ zZVk9gWp|A+d4NTVJ)-j1iZ3(Sm(SoXi`{L*T{gShjyt)89k|P3&vtf&x2f{v0yed= z`C~3<>dRHBS=hrg35w2U1R`?zM0Y4nb{@y<55~x9i{kdeIzW;HG~C`6SuzI)x4OjD zQZ*N!(~B0hFbE<>y#{5nQjB^q6~BRkWTE6u+>veMujt8N(kYwRkkx!&Sd>;HNX6m# z-lR_ne`KC7yC-m5xYIU!4lAtI-B`D9{$5GF#5XLKF802E>ei{H^Y0BTcHQ1^FK2)7 zxS%LcckJT)2YbPyX+)?<G|Z6+FnXn}@dEELxr(-aWDK5&<r-G6-M?#fHnqw-Txo|~ z^i*l9ASYKm|M7@&^B0Ai%KBE0$*64p$NN*dh&fbLhW;d#r}T<6Pb07$En4s*7%TWj zMU5!ZjO<TiZ^8;iF_RwVv4^YliXzRhkIe}9%3CkO32bAyM&ZgcG>fU)2ELNGTsiU; zE?B~#tXl}PzTT?UnxhmH#-x!y$Bo7@8ff|XnxobD7vCKM@=JJuJFwIkv5g>ooCzX2 z-^dWhf*sX!PEeUK#R8Cs<yvqHk{9J*xz-v8<XV>?D4;1wF1flgM~Z#^g1GOYv!#V! zidq*AHeGPxW{M|T&m!w<zYkDpeJ?^>uPix{I0I$WRi=Dp+9;6>hkr~%<oEGTv2|;; zO%-p63ng!}rS1FgaCb`n-gx`Moqp-Wsg)B$ffLY+TBVWk;0cG+d+q@gn_N42xpnB7 zbHf4s5mJaAfVllD`GO>*srxPc>m|Kt2OQ||upK)N;6j_d;a%Z~UZb!oXj{wq^I&Ui zixziC2Ny-kh+AT)l7{wZNEs;GlLS{7b-tK-kq8_?FXo6w1ew4g<>#UdcFNOtlTtM1 zSe2g<>EdvKvFWhC9#KaeieL>fnyC@6NR?TgO)`0<rKaSXN!m-RZ0ZnsJnO15iO=Gy zA-T7anfOyW-J?@{3e3=F0UvsQfJ6%VVArye|CC!i%RyV2n)&jP7mv&}&D-9YyfL}h zcCUP!v~m05)Kc$HPJM9d_W2(VEOp(flnQ$oMhyo@g=xEY!Y(=7%z5VdfJfEe05Q(X z`Wp|hj3Lcox*S0d0?Ur*PZ5^U1rJX{7mp8)U@Zv#9o7%hWt=-pDN~tWCeA<4EmpLm zd-Z!0qF2<1k?8^&7PgITmZ=W;{l+kBP=E`Dm}kZagMqhEaey^6!xVPEs#iJ<{{F1f z%>FIp6&OVapsfwBIDlbZAq0_yWap`~$nGRB1BLO)8l3?V8fPu3b%7%~e{Oh=Q1c?M zJzC@cw|I?7crWEe$`^Tom#jL7(2nm==byoA#m_dc_&?>YV_l!V8^LmNetdDsaa+7o za%cbFSKlc@z@24}SMp7vH!i{(3SBgXJ_R8-FZZd3>!6<MKriB5>S^@RI(ZiU()OL) zV*|Mym<UmOaO(0WFw<8Q6`)bdr~sL+!X1}E9jEOMwF8v#lBIwi8-Wt&*<vj5ODF@X zC<cK~o(O?k1Obx3m{Br!XP|t?d?um~m0Ta49SxT6khMr(Dp<pX5)spQ$c3=wyJHNJ zc*dh=vGf==$>{!y+u<;-(%N5Uw6pjRXmcBZt_M3R^VpTxMG-lEmuwLk9TlWN<9;j~ zRtty%FOeAr-hEo|LiKPyGg_sha`B40Vzy3qR&`G;RDi8m#{uF6WY)zzR8@F?iYM_g z?tOwcNtZIbx8KN|Yg<TNDc=?--*(;liuGHWj~)_f{Zd8=cimDFJ*@xdRR3nU`r=De zJCfMSV@Jvoy`klgvhjM)4Fr>rs#;enb_XhUUqAoK`EQjx62F2(@!{QpFT>~z6+$e$ zu@d28*kK9i9ELVfQ`RWUsD0CbSom%iLU7u#Y!!aDj<InW^iQe#2hnwJqq;8?z5T*c zd7xsCQg<2X={)xb7XE($&_lUc_(bgxClS%vJ2gCn)hLe1&uz+|M>QpT_v`29N~H3Z zl?|<d4XxMPUTORKdAS=xoW*}6yue7|_iMq!)`Ha!Gwf<KZY%-=cN<>_z$zpx;BWQ2 z_)c2;D=$O=vn}s4PpfBWPk96<phXq1=O81jp}_(fWvNv-RyM^iz$#xQTs4-_)X^xl zG`qEHS$uB9WT)k;{*LC&ZYN%=i=@;xd@*4|t#y-J##B@pU{$e%SnKhiE-Yq?zu0nh z)Bu*HDXPIL#|g|o{{LN}Ur~MuvbQ{g6I=mQae+V*_JDwrowVQH5qO3*#VWxW_^>jb z08JY2IzH^j7B{nKX{bi^sKA;42x}M6qZ*Hj8fqJW$ClD5yF4`UJ<9eDN9y<<+G#-v zHq918VRbdF9ml2&Cb_GuSAc%2dt{1&Cjqw{-daju+IoUTQ=$hf1GbXQ(j3(X*t&rN zLlA=ynf_OarK%LE;wkY1h_R*MDlG(5kSbP8t&w5KY>)p#5ET`DLTw|V=1W_ZFzena zj|Z@nYfGB|u)y^+W+-kvHrn9rHV1a6fX#)iBy-s>`D!Pv=kOwIBxO~H85&{j(e-SL z48GF86Uc)`SD`<iTqCQ;QQF$ChNx$fH~Nt0M`-Dy9fjKL;jIsB5jobs%zNx&t!l~f zh;FQ$M(71*7{ZnvBG1C#W62zKKwI}gd9b%1!Hv>zb<B5Q&8D0}dE!L?e@x6zt#)L; z4geC(L|CpdTKMU9DNP6u3#|T>Nerw=XqA0rf@lV-r~Q(BWQx9#HwD4M!N0(K#;m}f z12uJombNOyAX;~U2yx7QrGkg`4;#shTma#i#}h1Ns5<He>Ssk1eHv2UsYwKbQ)weD z7*UiQ3RGxCA-Z_PF1i406^5cb8srX_NvG9jLmW8lqvEUM)k?D91o&uLMsw}Jl^0B+ zvqYE^Wqgqdnun2lf*ERpRyk=O6)ZnQQmjQ(lpHoUnfOp7k0^G)UDD0?+&-&Q?tg_0 z%f>_KkXXGZ-3Umd*=qn#p|c5ZP(TzYG9AcGrTK)iN7<9|6E6YU>Xe59+g<@VP_S+q zEQESqiXcJx)X{zkY<&|b4Z{PNINhV8{IuOCuNB5>Cv*md;^WB^R{<u0SZ*Go{EbZT zK=m;KtW4!16x~ZfGoT4VIQP=l1I*Hif(UM4P$^p*h@%!ORUG0k0_Ir3323>;rcf!S zEFx?n-!{c3f(7TGssIECvT;G?X=E6%k%sS}CUf)tKcTGbV{TfwS|?A#VKhWN0255N zUhH?|#V6J=tHUL4MWo=DoF%XkqY}lb_T4bVSPSIu$=DmtLGDNlHWejDML?bak7v{n zTEY$D#vqR~Q5Rqf+@ff}$vZHiY*}B2_*iI)?DF@Et8b`v!gPcswQ`lhSzu}AB!c7D z@rdxEssme<7E%pmv;ybkn3|-Kqp*YFdQz}NhIzyu$|d>XJ_srb(9p1)8m?vhmW)oZ zU~x}5ZG`(QXK^wz*1{@6hEYl|6uE)`HL#O<n0dZ}u?ZzABlsWxJC3S+*(LD<07qW$ z@iE;Ur4uO!<uH&(=<Z!QF@46L(%s+C=|ehwM5lj;6XG6CO^W}4{{A(c-lvlfbF=t& zbhkt&l5kL{lkUl8Ctk$+kdajAAx@A(Bw=?ca6$;6WEBzD9;}!68B!t+C6O&`gv72x z6tD%mOu2@<Y$KIWBHdxr6Oo*w#2Lz#+ec5?DtJmLcPHJ+eH2P?4NbW&u=TLw3;4hb zkxMrUkDArT9$CHj4pk)VU!y8%|B$|IhL6W1IMJtPA-Z+bwWgO|fah@XjpP~Q3^!AM zAED7{hl;U#6dAoHkXIAL78S<#6K^F-I}Y62a&TqKvA~vN!7ayUd=%{oyVlHb5A^!1 z<J>Y+uLrYxuv8R(?`I7@Fwo6sS;w%Z6gM+%*drn}YqsQCwZu0s9si(HTEF+>l=fdG z(pw7hUV=!PhWVXRRxN@bA9}BLu~^#EBW><QfM4m%=)F^8(n;*mb3y96Nb!v?vmkHM zh9)U%*KO|hrk|!syN}(uB(<D`V_DW|l*eM0U#pUeJ8mEUuv0oXDXnvToZ|jf0=<4< z(q(3oOWJRr<dO#0perw4k&3qd0Q>s<r0IjE;I@6i^!;~Ae^&8P#cy<^>-sFW@cQXj zPNRYNgH^MKxe{77TQXxr_E?#NNTFC*n^#QfPQG#y>GFz^EWLQffCSr`-aq}l)4|L= zw@xGQVLQB0EU#PU>)%M7ePP}f%-VG8^o&W$+(Vs0p^r`T-Ua=9axkxE;o`l#ZA*E# z^*>&751Robf0~^)>%0EKwHJcf6@OB;U|Xr%6R6u0tZuuP-8N(XG%GidyAx_S#|?)p zD=(Mt2<Gk#X6>9wL^Y<l=eU(iF9a^V@K0EK3@r?FX>@@yrW0XI*u!J`U(>^5dMO94 zvnN-2&jflAa@l^bcU0;@m?m!^%ZKzQ^!@so#QV8}(%Ipav-ZGQyEN*#clNwA2yetI zQucE*W<>lav{^}67f4wbOesdBFsdJ9`C#t$VAl4TM6?F`B-|*$1`5IKJ*YqZhun#M z41%mZ8QB36-mu9?Fk|;j!awP=GrUO;b#$KXoZbF_-N9jZE!<D&;eF10HR*wYZsDL} zG0L}}8O<xk+<-ARXv~)k1rHL~dm~q<-kNlUMYB^664`T;ew|UdwdfL(u5Npf%${2~ zi+Y=)+upp`{~(n;PvKhfl-o2!$Di$ckj|c`aYc56a-X3aHJmp9Y<7cac#z57XK<o{ zQ|_~LddpSIgKYL9%a{z+r+mK#TCyY5KkfRX`Uh*-`!(EAeT{ton<8E97L3}ITPeYs z7HL!KgLN!Pk#6@87ruX%vvSf|tNNcC<|fsb>vb+}ij$mE4~p4`>x~!S=PUpDO*s_j z(N53L62|oFpCx5p4+8R_Vgl6p4=UKl<+@>gS3>yDG5D__L03Y5<XL~h2~#*hpXsa# z2~L=XBF~0Qqme&jCO0J*HF+Y>Jf=&LKhq{ZCAeg|9C>!xG@TeO!k_p;A`(m|?lFg- z?J;|je#2^v!CR@z$f3bMcW~YpOs|p5HIIJX&grt+IJW-&Z_@d$B4d=YhKyCvFqt3D z;w*bu!)6PL8+%0T+1Q%S0L>(6jMx#!p^*6pA)h$*hlpkWi4KK^qq0MxG3-$Ih4NGN zqaJ;o4$*@X)%wM+M`Yz@hOU#_2lbo0^hrpF=NZM^8ci%RS4^nLNPE=s`x4?UEE3G> zdsWS7gNhp77)b~d8SC?8VV)PsHEI)ZMcB#|q)gQ+>=79%4bReJo@Mlz8ufM9YcsL} zTM5CDe6(7~1U$&L8gt-jJWr>D=-sx}HmFzFkFtRc2$I>I8IvLYB2Oq$V-GydjeQ0~ zT7r<HZ3q76M*R=li~7jVh<J&Zqc2gd72ZQ(8+#zfsC|Xi)LRuSRsR<QAIP<pRFKGt z^q(<@kjMPh@`dEMxMEOq0(1%*j6&f6mKw<i@{2PjH0jqg=+*kb$9gTy2hhU3AhZ80 z;E^oxF#!k)CDOt{kcv{m$-FPgOcAjQJa3d1P8O78F;XLpA=u3*B5#uHqU<-yq@yT` z@&BW<t>^?{5+~`z!YP1cJ!z$asyvG0UU84U_t1$#L+tBi3*DlcNL>`R7--D0V5y&E zmW`}8RLnD~4qI!U!P>FM$XZ=H_8)Qo1pi?1T~zHpe)4W*O#fck9;n~D&^EW>&Fa^y z=a0WxJ69X5->Z21t}o%l*O6Z+Ei8D$Y~!>03s!l_+hm2MCUzbLv=QH;+R)C{;@h}; zoYJB0JqQ8*adt#LhBa;yA#4<DoOp`9U?w~mt##roPX1i2eizmpS-DGbU!dH5Y(@@D zq4n5AIsluyDqWUiM9PXuJ`!cc<f8ZKE{|Rm(P^3f!q-nHZlqH^oyc}BlH?=X|CF^} z5PA@-%Zqmi%j59MF~7+u7}5_<i(BZqk=h}z@lr(+Sq^!bl7;0E6_xJd8!xS^{bwxj z%EG29D|r=xyoz96m6Tfzwrvxl&2X8uGsge&1-*{%r)A6-G~1x8$lIVG9`yBsYX!@N z|8I3)0v5-0=h@Zt#nsRpLPCgBAV2~Mfo=&2fg}(JL4z6{9BOVPj#f43Xi2sv*^E)_ z8R2V$9On~?oi%ct6^i3Aj_nCfoH5SjYFA?j)=Zp<zs=6KJ3F@Ij7Qns`S$mJRn=YH zG`2GH&Fr`J!K>=3_g=mC>K*_0KYssGxI8KTpkTMStW>m=V&N~<mJwN7sx2qVg-*Cx z0l_1?d6a0~QteT#P+=EZVKF>7(>f&BPYb6ny7!St`naU45Ywg|-aQwe1P?>TZ=Mk1 za>n<<dsXKv6`;7Lk_kv?SG^E>Xt?ax%hDfTFXZlkQFi}p{nPdDHND#;9BUMsx@L}b ziwAnd;uFG-lfuaXA=@!rHWv{kc>}$A;qeRb6IgYlN^;o(=b`)F%$>4}E6b->3N`h! zHE_2BBK){m15FHA|Ma=r`h<Q+&s{V9=f$=QV#3AQh=<@ZhdFJLC>kLtf1+q2OW0gJ z&A)f%-7`YN(VtzI?iYa8xUN-b@Ao)Q3un&Ho_W}P=3$U?gOJps#!kh0g0<8su7XIt zXy&XXbia%(pq`Iu1!UCFFY%d)8nOYn2i~_qQ4DGKLPqV-VFt&uzyo(_G730gi{ocx zfZu*Xctr;escBXXI0(YpUqFvloos&m?0dY_L>8ka^j1SKZybz%F*5okApJu6h2C<2 z%h#x>4`qcyNnxN7c%V#v6pRvE(eQv4@0hfnl0h@_2xfTAff9mtr8Nwzs+V^2y~(Cm zaz&ho^`L}&n%wXOB7YM+Gpkon+5dx%jHp$!lyAbLIg=-O_<!gs&`mE4uAkxq3X?c5 zK^CyTLu%(b6kOI`f#9@K+WhX<3eG{chdt?TJ}LC}%{KQ7&Hd=~=YT_fpT9-#B_#@; zlptKo0_9}hxteLU(S!|I82^1r^$G$Q+Xl?NY>p0?fDID=>0jtIe@RKn_D+K9WP)%5 zeo98Q(rine!3Xvw)%w>+=S)L@x%N|&ri9zefe8_{kNeH8J(C5W=#_m=+4ZVxRd5z0 zIiA}0O0Jl+V=gK6dgZms@l_KA6AiCyg}_?K-igVe7QmG_(M}KZWi2K{=zY;?su>>R zW)C&F4>jQqHKuU(9AciweDP0G)1d|FK2A2A@N5rPMNM9Y-79`|!&Vl`A2}x}#)@7o z6=DCi{cuN>`0a~g(uRKm7TG2g9R&}K0HX;N0RuzctCfxwjt!1=3TyUG@$Z~@>&&$C z#}}sh3Cb<)D7-^?PQo;DP&_##oWN9mLFm3HM!~(oL-+3*DIX`%@_$+qTbizWE3qUR zCojYq9%!g>*^D-c83q<AoZ1wu&TlqyXjOptoXRXrJwsBSIDE5)6+jPG?Iw9QsNQ!m zK!+f}XAYeYrlAftBV=5q^6$cSMKDa$HEaE!bj8vPm!e^5y8P0hOG!Rqnl+Ths#j)K z`2H~fro!0hopJo;3X@GwK4Yw*i#!jLpNB8<96K_WUbo0|*rTk4qhR$u#$mwj)JW?J zcAe8$Q2<Bb?<T+z>f5`rc{_^D+o8TTS7tj$nCiWu%3aKX(ZTM+o5vY17^4MHjnL-C z-Ywh~=3PT_sU45}AXw@l?KUm*27;Y(5(WdiX6=4Jo1!_OwZ<O64$c;ax5oqcLbbzm zgk!%1t#)ey?0f#0y{ETEO3l<Ok|V;p6fL(*-L|xHIU;d4_zDRY1}tnNgY8sdL9#s9 zUX`^!Y&U?&9gyq7uTiK~%8A}<^0%fyz2l^jqYk&=NBeOqRq*G5xv1;vFxLmKNwVte znUxaZdp+g<?bU7h1$9qhbvJ)WZH>X&6m?2a*H*g5-C%q3fc8SoQ`NOq#<MkzY$2?$ zGeJH<cILK7rN(&f-F5p)Ve|DVc~(>6vt9qsWW}Ql=Eh?s@<{QeRj$yD*nx6n!cGLU zQsCj^L?BMqdLy%mNehex-s4*9D%ICgQ!&|UDkdk#Z+l9VW+DFX?M2|fJq=W`s$*kT zR%nBYl^1VO*`9K;;-;245tmevl>I67SiXW*VtS)LB!Cz9Mb<9}LZ<w8N|}U|k{Lt7 zo*Uao?X)6K#kvd2u5-_D&uy0g7{3}7MDL9#Vrtua%?Ln=2c^5MUaK$f9EP%js|M_< zW=^%90bS6qKcQ@N<N#eAe3p=TgQKUvzoVTIM>*QRr)T_65DDs}uhR896q#dQp=nR4 zkzwUc+k5SOt?l+=AKA9BLF6d^gW4kpX)kHUPu&{2IW%$j*26D8JZ2Lz8>ec29y?t- z?fONN2&}*IV?tRY?lOZ@=(W-}@aqB8cUcdx;lit8hrW8s5^Q&}u$AN<ff3|Yx=WM$ zzOP5Tjn|S|Eo*=hylJ79;ExdHAMo$fZd|hMM&@YktqnIfOf0=6DG6rEFq=zrxa(nP z#0X^Ns_HmvZyW4|_dNh{lbVQ{TSW>4(g%@DFxYe2&_fO29N1n1z(6I=7obg<t36kK zeRs#XRb(mxAtu;Yipeu!F)OUB)cy`U0bi^ypQlZXt5%jhLzvD~5mM{QDkb%n)}DUY zv;gs25rRuz{B{v6d0U@zE&PZ(^K!SZW!?m#?$mY?Do~Kp^Q43AIvAL*l$sJNNuWOi zaXOS<9YauIp=`QHoR2Db^<Pk^0kGz}kSh@{u2OHVvPOg?sfWe{nuSuzmfVUO4)Ryj zHJs$EzKuldP%4o0fs`tD4Iw!cQ236v9*3;L0$>AZGC(y2qJDfFN!Cp=cOJ2r>L1jR zm_(j&73pK0;Rm3&%NUW2wMgnqWG>UglZuYgie3@@1AZmisrdDK@62}sPgFBsw#=Br z-N3`&?!bRPE<s_-V_~IgEPxJDE1+URd>HxiMAS%52ow|lzopx~2o_F}JdKV2Lt}%c zCTR{_X1!52#!0TGCr*9$z-0E!n$jtkdrj^2v}e+ugU2{fSDLS;6>>Mj3OQIT$$ghe z?%AHq6^0(F7($Uoxy$1HK+64vb~wc7^4jV6n0&6t3Z`5mtBfq%0#j+&JQJ+F758I4 z9d%fKc%?!}DIfk{Kv{3GGWtyGl=hZQ>v$I`2DR$V$bqL<^FOC{T(AnUCf4DVDr>oo z=iCElg%c+y6UmkMR4eAy`uCdOZ5A4vW)3uq6)i&faX=%@Z0ZmP&dSWwb%h*b4!ELf zpr}RdDf9gNI-d~*VU6)UT&Ox<6?HylmV^6&2SJ3c3G>}mFGBuH5;X`K&==Kv^@VvX zu;B3Gn^nu5IKZHW@oK@!lFTib4>$rf`)j&`t?XjW97@fMs>(e%^Nf4&G$4D%!zPMe z-F$oVWC;*{_Pnxls%WOLTv%T*9YdH4@0J6=mi!zaI{J%)LSx%ZRl88x0bk%VStrE7 z(+a>(TuB1Qj6eAA;X6w#WyYY`m0E%ctH*)4tbG784`3`XS(7M39vFBaDILMEB)~Qe z=%disO_OazWGyeBI;xM=@mKWD?^D2d92WlPs3r7a*h(TP*N_T|q^v`9Kp;PwT7`d~ zf&lIaV~o(^&Dhg9(Gp{X-C*1iVvLZ&jI`7Na}2MPSSG|MVZ;XCPH~`T!w}Twwf&tu zX|_uYY;9Yw1UDazNYH@uI~a3i-YC!aKDaXZ#_y1e^Fw@8(yB2hjE4MK|8NL2T`UQn zggs+LFKqwTb}?bkua-B$WxS=C8`ghJ>I_)i#U<Os*lkd6Aa|uBw#$99<~+fiH)a|y zxe+;Qt`W>N6S0$q+bd=ZYJ`HCsm;@;-r6x|rhG!1xsRfD4(mROAZwG%Cr^wt3TZsh zX$AhQ5PNP;ecK_N8WJ3*h1fH55pmDPjvo3(;%J1hvP%GTa#x?Qq<=0V<|*gsh9_Mk zz2LiNR1;=LJi<^zbl4{mnsD>)BMs3}5Q>|j(E>iZWeHq;$Hb28e>!it6ctTK^w4xH zF0%q>ZFt|EIJCi&<s(~155wnI>~{`NT3%|p(Ke+OGD`(Z+3@aPM=hC)j)Bj&x|f%J zkX1ZcJDXMfc<)r}bi#C~P}?e$warAgjcDiM^Tu1p>n9R~*e#P~KdgSOdb;Sn9q;ZC zjx>suP2%?Enb>A9%j1{B#r)}S9ueYJ39;F@5fweM<jT3n&dL7FM_b0*Zgt=6_G~<d zQ6`)^^TEJbapO5L|D2HI@<gAf_;G)B2Ew4#*H(L?GD%a1xsV(y`gX!hd=3gCIYt*& z<i8O+X?;Cms^E>~w=<^GXI31TTakMG@U_DpHi*T{GvbOf@He;O90!;yi;Mf{i#hq? zO6Vj(3Sbu+TY=mw>;p1s-)zsIy9XFX=foah(`hb(|Nc3h800mbaI;*?IA|BaI=iRQ zEImLmBCEBZW@us(K3S=OGEn=7O;|1|;dHv2JH_U1p$Se4xDy0nm-E7z8)wFy&s`Yn zpV&kG@wTBSNy<*6rDLw|w@qfgd}5+;YKc%-E=E-driz7dT(p8?88O}aTZC*=fg6E` z|AOTp4*(b^fk!~B5X2&~Jm3h3rSq|N2zAM(f>2QwMzE_m<V8zwz~P@4TR=mAofH&w zBWPT5;-_NFDA_ZBFtFVMj|F)c)~noZ<NW|^f&UFJR;;XK5DL~(*cw%4)^NO+rl^2p zLH-equ}Ty_HeKX;Km4bBox|V*O#!2jQ`iF#)))_&WHj5a$SNIY@%98IU*8#bgEJ!h z;A6mu@KdkA)}F`^9M)(&v#8f|v`9=NnR&4z@M%EIScotH3gNHxAbnGmEEwy;<gXB+ z^cUo@M4h)aVUsLOg4pFhNi={X)L{gp(GZNCVO?qst4UKz?K}HeB`DQ0fa74npd&@2 z`bs+Gu1&9|a*zUw462?1+6kNT<-{(vCNbp}zaHWUL!C3E{M8|RO|1x8HiPnBYqBlO zr!XO-=9(Nv2z*lfs5km*jCv!ciFeD4ei6trA!?nD2eJYjMMRltB3oT<!kq>C<asY5 zHW>=EkR3x1hDjV|8J}5Z+~ynl(Sq@9w~}urU#)$j_Bp*|<i$icj7D<#qrp<vglD?Y zOrMeeCnb9?iY3!TuK1u<PW)Giz+fh;M{#AZMgT1o?64(^3Z`U@&1Qhrg=~ETw|-Cv zX8_!Ld(L8qeuhD6XWOCi4&$WOp{_NmJuZx2ppAgtJgj7Qcfd%-YXH*UC8z6$DFRGF zu*qeFAMGn;KVWjQ-T}h1kYe`@K?%H-*Z{q!i47oS$&8Yy;>^EN9~6tXK`$O&JP7B) z3+WKd5>9$W9VI1Y8hQyUYpG<MBfXT_u(V^xD;ez2ZkIq4K(5-crIuM0s37bX(Dbdv z`L3+gl12TsbVIpca@iuGqq$ZG0r60H8nR~#XjD-30MZZ*Ga8_)DOy4uWCEl_hNgPz z5DW$+wIMQ8VpOqDF6t@>qUN!Ht_E*Je(g!1246*gm#nwRl^%~xhb&>Fp@DrxA?^am z*JtnNBfMXx1VnT57K)^2O~PNL>u5zsggg$@aLI|04E?7l|6fwTX!04l`UwS0cm79o z^%ez;P8aCPN0YnhC!@(dbj8SanhkQoCBpn8y2}W2BD(p%KrnCUXKFP37Aod%={5~; zp3&zV?m3A!L#~G9L!!DRZ<ADRA1FM1AU5)&X_#jsfEtN8=1Sk?zR{8yb21R&{eOXe z3w=CvH0|k#Vf`IToSP;DF>bSH**t8RW17<!AHO(OJCm?VSdu-i6D)aiv9*HLHfufZ zwjLMktuxj(p|)LU?-ndQbFtauakr9gCIM`^?1vSvRZQ96tePyIjuSRk3pI6OPQ8!~ zIM!o=rIGaFc@8kYCEPyF9k+dYH$Z)3Y9O)X>bT;$;@t{F|743$)#z!$tJ=k;4u~p2 z<8=|K7lMf8+TR;>G0?j(8Z8ytVf|-unxxFJL*pf$<@ujMl)LUX%kv*E8u=nw?xbBz z+9i|?38&8h6LaSD!^r=%q}WlSl~Frn6&R+{Q3=!zNMS1==f$ZEyJ;jS1!@OQ^^$9= zQtr5RE;(&1ZEWwibH<jBmkCJ)*uZYRy>-$)74~MISXd?0)eDD?&(zsJ3DHC+{gZ}^ zZsSl^Cc1@EeAGHfZbQviA}&V=39H9<-`aO`-$dzms>X{a<AwO09@8$;Y9D%X>zDyf zx`dcjW2c4a{P(gZ$|jdgnx^#b^?L=|;hE?o!+HYoBxT)F0Q)Svv21*U2w<PWJySzs z)B(YC;QqaEyp6fD{&C)J)4m*opJdm7j)z6fbdPkIE@`Y95UK$n4^}Oc_<{WZxtA8Y zBr4*M#SShfTPepU+*srtc;GF5>6E1yOFIa}1@G{4tgbAs?aDh$i@w7zo$4K-LEhnq z4(NS{Qt$A9G7-l=f^pOM$7xtUfxwjo?04oIa7Q|(oWZX2;sb%EMuh*)?EYOR10yR4 z=(I>mMpXkmcoUL~NU~?_@7k6M3%8!m9$0merJ~AW3o8d!H)&rEHZJU-_Y&VUm6oUW zRQTDV_uj;3aFu+~dnHClDi+!x=2ZY^l5&et7DdTkhvoej)Bqie<xOiHS(hPae;X>P zJ{`<>(9d`Z#=t&9cV8lU7gmJNQ4?@fdvW6LP;d}|#N3k+2<A<&r;?oYdbRMBJ2Y`R ze}xoq0|&dDs9|m{JYu-<&vuS>dcs%EMMMggEi(~Yu-C`!rrGf21jdWIu{3*>X!p>j zvqVy$qZ4jcXyw`Curzxdq1|Z-^yu(xm*ghzm^6FD9H$tO$F(eXAu@e7Ji`;7!LrAs z0f3=K_aW?zn}wFanWiD}&}pIm401UuIf0L<WXUU)<SS>xGd<y%tjy<TBF-V#sOVwS zLYPtLok~Of&oSts#1UnWesG{s?1{KT+M$rOq{3#D_e3!bX|38yNI{6rJrv>~*wJW+ zJ9T(dz%dYIC3n?G8F<*DARfnLOCn;V#fJ$z7K)A4`$G7R4Ot{-0<+Y~`3Bhue@VVN z_zIzQnH?xugmUdWt0okKU)&)3U{ze>dOh;9`0`P%6rnKo!{i4y)TTqIQqTC(QiQKJ zLS>(F4^jfAEb=k<%mgADq&C5X&`OE?_Qc8FJ+aB^2cBv(_?s4xdWXCNf(X}U#9lJh zZ!Z}dREa;D^$0V4IcwA=dGUZcguxoE-lICgpac_cgG$R@=&U-7!E0snTLK)|Z3pdz z)K_Adnu5t1tA5MEUgC(T4-du*fZ_@O7_IS*OO@{#xT{v@i<k+GUgZPCT&jMm>MhGq zui!g*{~ddx!oNVy%aLD_IzQZ7fqa4}A|Ph6I;QW<h@Ym^#CNuNO;C_$N@Ird1o`+Y zTgcxHvC`H@+akORD&_#};?tQ5MjG42bL#PI%~1bRN|U0g{7mXO_4=S~zp^pYTaqv( z72chxYGd{NEZ=zb<58$pDZFYPeo9Z-;7kFo(wd{n*<j5L-iFrIXho&!N&#{9d*_-( z=C5#7J@VE^DZiMK$?v4rsEe}ZtKW>@Yt_H`f57`mT_E7FuCo>d9S2?oS?hWN`?MDN z+6g%YnKhJB*~|gFn<4@?wQg8%Sio7qI?;o5B!$)tiOaIlbtZ6!*a<%|b<7l80qBAw z<Ux{JpL1Z3D*ceRke%2=o>s0;??{&sS{!7V-_gD!eGtw~(;1GQMBa1&7DCD(i0e_3 zvoRje6fRWAi*=HC*q`61d|G*yivvgR3!PIodcT8zK4>W|Fc*WYehcCOLT|x6ofm)c zkkl`%UU?my06loaxfV2CCty6#b|C8%2uHpFBl{0VNmN$+hiK*VC@t!Bg*kz`^30(P z5d>L&_>1$|3hKJdS7-r4pyO;1e%3}J)&2w-C@6b%+BLt4PaolcT+hAGexv;}pzSql z1@o3S(kE-*%$!URcC~uiV0+s&)7CAt_9&2gl0V9eGmeLZ0V3meDPWBiM5FD8SOH`i zK{w*pJ~A!UKq0Y-5nIiGV@to!cIjWWlq7T4O7sX_e9%6N9lFvaU)=Bj5@K!I`NcgJ z`T4r5A2*UlBkQUE6BjC3@(Jpxi_4PL^8@;wO2G+|;~=z~e{WO9JL|EFNSXuQpf;m? z0Qg7M8+`q`_mA$^>gIJN2Tlo6zgBi!67T81qGR(U(yWG0kGIgKlJ3Bd=;|E`rYML- zgdkc3{|&ddP+xrwk(9n#t@R>8Ms1y3`NQ1Ta;IwF%$rOR$~!$>Ct&q5dvefylDX|T zc}DCyD|DU{&YefsTSWF%LH+Tfv=g`>DUnx#c*fcoXqVhB*(JAAKkm?WQo06h7wt;w zwB2;ms_mgukM<nv<1Q5}WXbxH4V=peel1~-x_}NP8}gCj6aKxf7l;$MMgsT2CX+#q z*iWG$RH!48;*P{hWN;yb27_rBz+d^P4e;>7a5ZF<5+L39UL`B2_$dM5{iJL%;A99@ z5+=wRKvs47`v7CgLR|ycFf*)PnXAlPP!L1+oD8{>nq37DD_{`i$k-^@AIdORR8i)k zSyqg}3z_~H-It3<yjq65LvI5{R)^%#!VX;}ilyjHDVATXOo7Ifi{tWiVBTF6n1^Ya zp-3pN1U=(w4B)~8A_nj%>2R3Q4!}D&cj`H<;!JeHZJXr5jlsppgM&FX+mXJPC8bu9 zR!b;A?3N(Q{*}34216deQ)6zX)ZW-S8Dfy~da(9)q&uLmM)B*Qrop}+^}txA%`p=y z@QnOJG#W_}#cxui2`m=>_h{H|*It6A{~`)8epXffI$i1s%x9ziK>7G-h^q5>pwxce zlnSBAq-c#@LT&gC1kfd=8HRs@u0xnb%)G(YVlAnzWZV#GYErO{{~CjF-s>i2v9(s# zE!{`c*B<2i&zCe~d&i4zZN0g5!v5VIlS^k-@19v%`u5@J_V-S_djh&RzkpNG_L=IA znLVA*rI_vJ-Q7^Z7@X+_pY_5__eFQcLyQB#lc}SmmGd=Z?DwVqU+RVN?@_rHQ3#`{ zyo%alJ+%chn@wqTAGU+3JC-|MJ6<}@e?McQ{nh^4{Zq9w8~2Ip%Y}l9>A0EHYVolB zJ}*$-v^cwB2>ZGR6sQd7lh>K&3X_+8tolqL&_zxmyNin1*M-*IT3q`{PN3vQk( zl93z5vVTPW5?&yg-(I9w0UR46LmFW~5R#F@BoUv#;VtqbG;Khsve*Rg1!s)`V2R2! zoR`PeMzZpffDu)*2qtZ0#Q%b-%LE!Ef#JVOrCAb48X+!ZimqgZG-CLTK1C{UOFPdZ zFQ!IC!&gFEkM_2kyZ}jp9cKJX^rA~g)AJB1;$nKn!UFHSJV6cS#Pb5>@?8pkNI?|^ z3@?c-<!lU1(!CBUG$~Q>9=h_-)h!A(QxHn`nCUPX%<=VfuZDut6fnV$MdCvKmTnMF zab9=2b+8|bSO}TOXFdeA=Rc(9cPMD5fEhXy*PZngp6WcW>p~qE%n{G@-ibvNppdmo zLOGg`^c&0b@j=PyLh8i72?4nd^{k9F<Mp?iZZ>%`ws|bu-5J}i6^#~*q&=|`m+xD) zLmUKfn##+SglVLSW<+t7gn&~8A;_mRBnZJA!5Y#NYI>px#&q}rNS`Y08bbbA!;pWX z8F<b<Z3Qt&_aO%)r4vAdKo0ppgYW}M_i4SLK_*Sq5@>WW%k(D)g!n8W%j&7K2@S_) z8;-jhj*E46!P*M1kZoKk^t3`E5t;~T`@rP`j~<*e6FDWEa6r@N6j#a3RUVwq!gVbo z-znWYHTaI}E!U5C3cKrsx=uKujO^w<i8NVypp^DWj3#dB)h$nN87`g6%z5GHjia-f z8{C;2Jeiw3F*bMRrfVBV3nvREkG{EMx)ip@zo>9;J9M>hq!5pWOWiRxvRW>`Tt2hx zq2Y2*#KUvY0y92cHN0moHh#G5!-T|<kU6|y|Ks~dolhSe;pP$&ubzGUtS6;aIDAAn zdi;aKb}^+@OlTd^$@*4vkj2H0>&K72p~ZU-zq#$rC}C4MYE!{2zq34f^wijrZ=U-` zv5=T6<nEp-dS~lfTc_<p&Ea3bv-5GW)Gq9XQWb2WPYJwh#&KR4xByD;q5^BgzRVu! zqtXe_>LomSo3!Dn*ESQOxJP1GmjT=cQS5N+i7#r`_R|-o6K)P@9Td~9Jw?Bc!{apy z$!k;Yq>y;5;@fM)l(j-K@OaST@f&{!b+2vJ(}UBUfKhU9IU;oR{2upDav$}%=;A#0 zPaz?e3*4th&C=yixr>Y;6!p27H80gqWPaz^_<_loH%h1UukWASj@OiPS$DE>#xtJl z9y>aI_6_G$;p=Co3*NZ+T8Ln+7dAl<dBk%RdSI|)ADC%&h(}KeM@Tu<35!ge_Dh|6 zKWdKp&<Qtfu!Bc^_DhWW3dXqSj%hRJGFQFOaHC<o{<)^HgA*IbiRsqQLqa0AeY#8& zx8jp@&60$X(@!56<HlBh+dSMZ3+2e(T=#0|?a;|gaow(|T%q=mP-7EpM?J^D-EW(1 z>2bI8h{sL{eWzxQof3`$CrY9HQI-pe-8qvi9F#0aquKs6v`Tyb-acUR?6`jqI&Te} z(}Jnu*~qGGhPRWJ9pFrFZ#5wFTW*Q3fvLcBuN$)hnWclDgXt&mD3WjBDPjj6s;BzE zXT<xkk|P@uDh3>iz7w7XO9!zco&@31tI~OS@xH9BUd<t-4))y3&06HSR|9B~=Uy<F ze25sqAvVd<1X|n!4j-TRAhiL300bOL+f{SeorebF|NHPitf6aEsT*UIAL_gQ`f)J4 zjZ<O;bG4PGv4#WuN2|2x1H_NChN<JDR?Cz(_|AwS_aboUwRM_)uXxx-B2qK|B;vYe z8UnFwvA2QzQr6<Zri^LU@IxdaRA49xkp}Wl$wWoop<!>(C)w}F=(rAmbCT^1;!684 zY13r)Spp#ohWXk?Sbzk93KY%m9^fRaHe)JiX&_UQ9JR+mc%5E^QSSzX^j`)C-~|z; zI{Z^qw;#xQJZyspx$RJ&gF(+KaHjTNLVtw&L_17mq&L(4kf3{0CFow#)`8sxjB46@ zVK1|XK%5`6qLkH_R!@NkmM~GJ_uvhZ2@|t7V&*}N6!DuhXg5`e{~%U^|5gwivd&+G znc}m9g9Q-)Sc0$xNsB=RYZF;?GjXhBxcy51<^IvyS#yeDPWeuvgjMhdZCIE@H|iVv zQ&(?yX~VX=baaW{`2s4(U!j2J4<Aq_Pq;Y}z4#ej5&1Z8s4KNr)YS9k6t#lgVF;#x zz=Rc+ERH6x-)&C*e<Ky!i-CZX^mz<YWqc?F((j|JCWwt)7r{zO)QtK9M47O34OYCh zd$pj=yzXThXyGac>L4&5BM)mSF^jquQP-d1=J?ZCubtc;ibf}jwwFaC-z4$CAB4_G z(v76?4l%6=`<dR`y;GaTjaAs?^}gFH9J7nnt)E1NMTdPBtub$a=1<~_iDG2YCl=fS z%@k>wjYxJ!B#RNLpTy!8sJ|tesQ4=<FP|J;DVkHpvS!TLFKwJL7fh7Rm^VXv-W+|U z^Xr`>hewCT+C+1%?@q&L`&g-H&W8GuDdI}_<?xYGk7+6NlZJJVndYKmhQlOzHk!(N zMB|FBXNg$*AkR)ygAxSeq+<re`efr8y;%g~b^?ZgIz0}|klvLn*xHn);`A@_z7VU< zH-jqk<UpDYwpms^ttF6NmqC76ElX7Q_@phWQU+OV(lqtDx-@{18JGz@S|CYXnjq5O z16;F4nfn!~MJ@o-z{drU0DSIx*HSX*S7eEhyVXdXS6|5>o#sfBO$E1rb|UBmNUYft zYnU&+svh*k0uEZ0u<FU*)nTw1zBGP>FTS5jj-md`(?<Hzl0;jE<T2t~8v%l6ECfmD z)o0K;jQ;uC3`k=_st^X`Q!Ib8M^q&a`lYN<DqUwUMWIR+<ZA_fDOtX>U6!KnlD-Kv zvLQe7eHmM*@5@57JQQD2<tSD4#^7JV;%gm%Lc}Yd3cAtw+H?eyyed=;+9v!^gxogB zp8+AhVT+JJ1LX3frnYdjG3Xi68F559!W|K+@<*!5A4Bp-zw&#j50&KATQ*Q9Nm41* zAwiKThf)iNS@lM<>W#4^vGjYRk~FC00s3y7s>PyIxkfcYM?yLnlt?_eVf4#6O?fUm zM@?6L`_94Kar#<Yx$@R3x>2bLQTExse3UCCreC~V<*Dzi#8cCnerePX<YZr(Cf}F8 zauqa<uU!(Dv(tZbyQm9mi&o319hNU05ldCyH^%pUeXU`OQEE|dVc;ep4OH_Y#t?&) z;c5z$TKmq{WlCD=BL}o35w|7=%Nuj_a&n*vs|OV*jjuem;Q4!9x7wmvFHBbESy*3q zcT0^#-m$}GQOZ-77BIg<cZ}+xj*GTb65{Ni(wf3bo2p(%RB!aA3#LziQI%Ggrj%6a z(z(zWQSPZrzevq9l=Apm^Z(y5!M@!}RqcK47knPiRQ1|eRl6&Vsj<ZdZ69Sy@#gRQ zu2cq9zs9I!{lTZd<jc`}4=w&BIa;$+weqevUpgvR`Rb?C!x5*dC-I`$VT)6qqb$VE z4LH7JJ&QlIMAmr3Y)<Q$Bi^yZwnS-0c}>GNXxyq~Z1abkT>|~cRB5e)uFDcfJo}c} z<mA?GHBgc-w$9}EYV1g8^sa-p1gyGChqUWRD-$!mW2tTFAF(zXy|2~Kc$e#wg*AHV zNo^%o2Bz1kbu3j{(Xq_e%C==nJ78?)+Lry1HCpYf5$cfWrK-?-9ErBXnjOBfDOXBo z0=*0h{nVN2kV?><RK`l^$1!?hxg$|c57jSM_1SH5p9S3}9WvxJbmG_?P5hlTOapMa zkC(G3yma1yIC`Ts|3EZo=b&koR<o{E^>XjLyU5J7PD$mPxxREf6!_XwNzMA%dewU$ zd~_8q%*~M$d@O$vh2mJD>MKdsBDE;A(YMNPfc@$UUwf;|uljZ!@;6GdZd@dvO%Ie$ zi8r6ki{!KAf$~9rV|^ycx^<C!wk@8Ib-OL;j~p#KeBT74CHYW_JTF1;#pb1CM~W@k zmV(&^{fmu+R0r<)zCa^tE*-X1rHy?ws<l{M3)Xjbs``$%&#J!LE=Ha0R&w>7zo<_v z{LZQzOIVJW#R89osaOejt4ipd@#~}NQ+*@HN1dte`Bo}r0@K)*D{WGrP@lBO{0gmJ znqM&z$^uIW%^%#7=TFc|Z2p7|FMWw2)%<BK-DHx|f=`;E<YF~@-Iv4SRMv}xrLFfm z!V&Jh#u_4DBVX@bqa)FKo#IGV$H>_O3r%Mm-{Tl;Y|YQz2>~qoA!$D5aP#`kUVE35 z$MhHkzLc2tvG~77;>zvZgRod^eweR-CqYW(sQVJ;i?Z#t`sqQC7KAG-|NOc&Me8nR z@7(G#<;Xv_R{uSfuo|p45GWenj$odv$kEP+I3RMZ#!tzN_L9Vc)Ae`Q`S~P8SSm5^ zeB4EP%43~p09*eInQ3hN)vZr#9qk)$n@KL1sGLdOITiOd|IXlBgJNvev~?!7Mu@0k ztf@a3RyiFVZL6TFB10&VLpwM|JUa*vYTEPXUD}HmT`S<-a0rZF2ROapd7nQI&TU&q z_dqY0oOrx^dv^D+^@T-EIgPEynv0uqi?i{?a<;E+EGTvz-=5t*ctKJrywIu=!=HAf zUkHRPA@j-$0k%jNyqY&1=Q>)UI+UK{_YzkOQ%CH#Lo*@!SaW()Zcci&E3~n-%kFUQ zY;1MKtishQsvnsLLEmX>Dt`=3?27U_g@dvKyu7*A-~=To^6)g!+e_F|%;}k=76qdu z2@Q(z{1F@d6Od43&qS&c?A5Dt(p_QlU1S)XCvC&Ky++tr#!p#58YJMH?yt?GjB|N_ zqCb1_%0NodAg#oL#FleT_JezeLvPAN{^(gW$uYq`Q*w}1|0JF+Uy5fwZgpUP+73J} zd~Ck>GIgqS#Fc!v*1FRQM@xQCJIPH|zLWn}KDlpwt@d%dm{usGoW<pE-!SL+yA$>D z;)#;=)*IyKzs|!i|25Qcfsr{MhN3=7#l?Il6*pm<JA3O`!LuQwCD#q31~Gf<eg038 z)>ZU{UYZq64_UFq`&m~$NUA@W1_$64nEFyUH0ekP&;cDgSA;UUsH?g*l6sA7ZwHHD z36kDV^8|OvZM0-GEjf?xE`(Dl7*RW2$xL=a!&JfqvO@@imm-Aswbbl+E{AEBG`2QC zJ_9dgLqtT`Q&-79^YioJj%-ybjMe#UYLAF^AY}FQGB<BdsMIXb{qeRzgrY)73Vg1d zg)wRPt@bGjc@vsRLs3y;N8?067qfFmdIpPt@#;z;nH9#~0F6Xx2ul9yI?lrR98%;3 zDDg2#m!gU68-22qWxs(Jg6cD2&@No4^o=o~PFaGhx6!&N`!Yb|u_31RWx)R<o-0G_ zAg)y^Y^l<m&4w83mCsVI#PF6?veGMFtC~19uDca>GweU_e7#C?m;Ke{$=u$0R|HiH zUxx4M7<9U#eKwg=b)gjRuMio~3R3gSZQzRTfJ6^qbd)7?edx*vG;@?E(qkogN1Kk> z^IT1<bLI__?Pu^NBpSjyhCwm}0!TclTVsAinW+|WV@%_Dj9LymD``Au*4(y?mrO>y zGBCZwz471+_HWr=w%q5RL`L&^X5bmT$a~dArfK$H?fIJj^9LyO4ZlKXTW^<)pAxp! z%x<Z5Z>haydC~H6iR`dATxBTw?_Tt$AE4;p@+*4&(bv)@YK4-+vpbKtcOJQQ>cvy9 z%Pz0y^~^~3-@VXZe}F<iM}-E_Ds__V{nXx*J7%|3xVKcOo7)vea}Xvynw*aYUAm>w z(Ae)&V^k=P;$zL*n{orEXh{pW7?ap`3><mk*vdbP!c<qg)=TbF6&e8)5Yw+#Jp0L$ zH#txoIMWY;W2k@7uR;Ig*3B2F3BBv4Ws^I7<7n+u86)<qStD6u`bKHp^eY^ZuWU{Y z99{m4x<5gq>{jHNRVHGrGVuZp;cL`#{8#YR@nbiB@V`M2e6eSRPQ_zJn-q7uTe^g! z$DmGCFx5O$T0gt<kg)TR*wQ7<uU|<?<6JSaPJ3!AKuh761uIUD2|A}8Z7o=J<|ElR zaJICbYr(B~J(M#BoD6J^0cXtXY0+{LDhBnU`A{g<w@8_U<Y--{##W-F8(Vo3niDso zvv+P!JJxt6t0^ag&8;r89d8E8ONPC_4Gt#}C!w(w8-t3v3*Ds$>MoR2)q#S(P`$Rq zwTw~R?Hw=`0Ksi<Z6%9!W;~y>(kI8~)2WT<JMjA{SVzI{C?H@+wyof;^b@~iWl?hE z!XKxfofP~f1q@B(mvr?u1@BPs*A%=<!A~jp83ly-!Ed0e*C=?C0;Z&T3s>`cCmfj% z@;pWQFZAer3QkCmU=iocNtZNENyT8^c)G{g)7sm?8>pmu3Lc~QZ4~@1CE{A<jji_f z7Le?c>gjLk5!M{M3WPg6bn0n+?B)}Zm{0;4b;LdoJ{+G!S(2jaydDF;<uv~{l>Ao| zte^y3H~(wOE2P&sIM3Eyd{(yZ^2wC+d8!9(&?P9s<#dm2%+hhMGfTldS>(KnZQS_( zMejHJ@7wy3lQY#Qfe*&SUCnwTOITJgv3|n&cKSOj-&*O3*$01$fXBG_^^4D1uiKun zjqRTZnP?DK>=2`hhjqW#aiO)`J(DJ}L3_DkxbzN?16!~5JkbL`ob?k$lbbz-r7x|& zRrF%fv_)J}>4~X??&tJr;ZV1`2d-vLi9Ni~?Gz3Seh@MAX$a-=G0+U#M>8%D%$f@X zbHUik@wOXlg+i;xRQECbZ6-dEC?sv2gdd(=(6%+1pGv=)`vjc+Xp)(qnNsau(mjmb zNA@_O`?M_1^Q|wO_|^%bVDEH@C#y<~IpEH!8ncX)jix=h1()ua12d)r!-ij_t@6aw z-B@xhZq$M@Z+UUcMC02VrY%3-{B!Gj_3zftuC`t+7%8~1WZ3AAsRQcb$g0Oq3M<x5 z7{|{E%eQz;TR(<!#)+?=5SHbRLla{ibTd-2#+=`{Fq=}~PATxD6ndg}xKj!r9~?O~ zacZ*Q_Rv+!hy^!>_3r2$lwsy$J)=9vH;k_mmaX@g3hzWO0kUG@lV`_PzL0k#Zzf@_ z7`+Y{G*V1q`MzoW)EQyfL652C4n7_lLRhh3!YRgV!$(6qc0HTmHm8p5A3rnRBcyNk zn6?0J5$KWewGz<DY+8Xkt-zC3D8|={F||lU-juF(Ki>Uh-t!r7_&IQ608{Qv%tjy= z*3a%Z=-zQq+)^_WQ={k<x49eI#JcvGn0CN6x)Zj6lnlKYI-9-OoxRzUy;V%uCdO_1 zeFzu3eXe*9o%eFXhL59Tu7*7kCM>rK^$lY5VOFEZ+D0-TJu$j>*4*GRH;lEBvr%E4 zP0XU7!m&0_I}j)P#P)unZ9o_xXhT`I^E?N)mIN<zkWNRr8nWaHt<_Qkv|Z`B+#@Wl z5zVy%AR)|+LUV`5)QLWZ*JV9=axBhm&YU$@3g$|<J?)%0Gm~E|=I)#tbmvwI2hl+X zg}xzS=(KR)jK_2qIikDeJdrb6HNH}eS?e*c6$;8drV7=o)`;djKvUMgdieI?msh+U zJ5~Qq%UdmC$)TBoL!QIrY-zT!-`&_R9v+w}81V40^>BR-4kRk^sdPg3bwbM+{dYa{ zq#DI^!S@0^hw61mdk1b|U0-(ziJK;4#i$*_I=mgVS@~$svxTFB*In0K-`MFk=gPHm zP85CT929<a?}WV-_WD8h+REu>kE!uabi%XR>tWA?2^j~4+In&6Au-w}eTVju4Ue7# z6wkKX+hz;P01)pf-0N9U<u2TNvuM0v-2U^lXS<(m9kYD1W32X>Zrpw!56x9V4b*Yl z(Y``;r^nRg{l<CYPB9ui{Ej*9M!Xnbbt8VXWIXNrg%i$~H^ZM*-)ns{g_UUTtM(E5 z4ft`b63quNaL~mw9_td8ZJvys)C&p49@EY{&>ueft)s%a<3ekPC$rO&aKW9~Ioc*T zoU@K|ZpXO~prw7X_ZiOZIDd`9qu~g5!Ug&^y|d;D!CZ0O`3<Lswmf1wuCF_v@%|E1 zDyH#@qpA;?E1Flo<oy2nSGRs|t5Doab5$WPu64T2PIR-=fK{T;QK7wKS^8|u^~7fq zg_ZlKOT}e1qNP@{0|;<Zi*i713;0scXSiwmAZFHx={0V1&F7kss7g}CM>rR=bTr}V zwa?eNiC7o2tzxFt9b<)>v^UB}h_YfPX4OlDZla3C{G;OPqwbiapX+^b0LT)%OvwmT zsF=N9%-rve**^`#qZZ-taiPlonK2}^jr+``S)F$)_eGjQH!?N$L)ybswbe%`Ty~VY zC9ZF*gf4pRsAaU|nK<dra3s!nPTjA4WQS-@d%j`Z`t4(G^EzQY>jKm--Ih(5yjI^> zJ)2(SPA~GLZxk(?MDwOM3f;xsOn<xU{gy7VxLe%T?KXD{Cm!;c9=;R5Y;4u!il4U$ z)`p**@U*nTJDJ$h?Qu}k4{;?l#h-#^_gre`c<0*&cimxep93NE_Zyo8d&jJOz>UAg zf%mv+4)@*r9OAB1z`b4K<c4*B9(Mok5l$0Z&E5YnrtUr#FH7<Ld%2qA1Kj<)2F((j zew%RN{=FEg-Tk{^nsOLJIBB_kJ1NqhYIu9o7JH`QCmRe1-_eKLR~z1mHz4%nMcNA@ zJUe3$&g{&vZ!&$b%7Ab-J<Yz|@HbncTD69c*2cD)5`SYfAk0aTe1$V|hS4XCl9{1p z{`(aCh=LC&_$38Dpx`GIOi}O(1#eOCV+!7*fRRUxP<okuK0*N_o)~4s40$H#-farL zN5L;BAi9VtTS|0T8i)eWyxVaXfYkLoN&5g7M89*g`7DaOiUN`|@HrIpQE-xiVG1r& z@K+R|3uze65c9XlY<(niAcp?tb0<g$CG!!)l<8>$NMiRuyUMR11CBdQIWfd$(zusU z=9t-0H~k!>;020ug#wbcNZO}NLzbBxeMV9Kg#sdk_*_Jpx3sh{wnlqT+hAKqZ*NNr znE8>an=LI)MApa@R5xWy0a2>Xy9oaI5zQsdU9DEX^ln%mr!VF1Hizi+CEB|YT74~d zH%zPFa@WM^x7{^x`Wy;2d}h$=PjH`^wfa8pu1Tja=k7*uxI+&QlQB+I!KHm4=`HuP zdi@^mUWmpJ^9j3zEr%iIQ>{UtC*Q=?C($ALGHz_>GYy^ZhH?6R+Pem=zEpeHq}2~% zbQ=L~dN)R=uf%Kg`joro5WNGJ8}xa19bA}xzxHmTUjHz6H#$qdpSx@2V)WJA-P{Ph z<!;hSeU<iZHD}SsexAKe@5Bs6p=6Rsjo`NjGvEezTe^EAxVQvqZs%%}?=|9=bOhJ# zY^04PJAxr;Sm88U!LCvEYYvUiX_d{ZGZV~mp-9e|i9VX@q2n#nzCfC^cah(Od<Gt{ zm2HJ#s0^G7SoGT4`|OVV6Hc(wvq|Y(!lfi$=~};su<gZ`$Uf9Nn3K;p;9dM-1UZIz z9i^Su_aZ0fysoUIzJzb0$E^2I2gF!HQ(=`yAh|D*`XxPki3svSJb)szG5jkRi1x;6 z|Ln@^`PcF%R*mM1iCe`e050DNj~OX?bjNIXiaR_-3{MkuX_9^~(s~bYnCPo$?p5Ra z*bR2$mkXc1<coM)yxHIMms7rzgB-+!dQ0HFu);pYs#DyP!OkJ5wTbf0uyc=8k~W%^ zt6<p0J{My&C(+TK#(LftrV57BWHnfgN-dxt6))um%@o?^v7?6g5&;J^n2F)Tj$jx= zR+BYU$yMpY%6Y(07ZeHD+X_G_sY#VHSDm~y#k`9#maaUiS*cw4&d{s~nuk(`pvXE} zR%&!bI?xt0$+H92EJ1UFUR`)2M!a;`v^9CkwePIdN_jJIuV|i@4{Ky3xs3E$8^OJt zp(bB>>N^h@Ri%ltMhDXhgcdSqwJ<v)qra=^g>rC}1l(84*gE3^Jm`2|{ZO*?z7i={ zO1;n)OZ?(06;~qStcsi97k8s7l6<L(yVNi4<}Zx9%$g9iZ479eMAkNjnym|SSlB|O zIf?dIh=pbJN4-M46jk|*i(y%<_v(B4^{h0sDv=lgN6pTF8~CX-DD*f&;1pIu)LSO$ zaqvaRXV=0sHb4IyHXZalz|Cjc1tAU{YzWekE@*ok(hq4FW*>3|E}WrecaYrmYVD84 zJxUD3-ltsq&bV9d`=wj~x!XIF_<D%)^-*5Dmz7uR&?|UQel3`xdau#CGH7nW#&jmW zZkAtcZ!GI7S+9b%kyj@|4^;1L{=T%9eH9gO>GSenzT?UR)X@N(5W(__t*VOH(Yxf5 zRE?oP16-p6#FnnRqt`JX4u3`i{hd8sLwv`)k?&w4eLOXE@caPp^ewM00~{P7Z<jVT zwMPTu!4;BU(CG?Et=#Pj^~$>WI+V<hJTan-w5r}ajzA@L%ODX9IxYZR{GGrAGwTH2 zs@D_%X+qZPWacr1+)ON|lYO}9QM_Y3<I+)2nCF_R`JYl_$%1fVDXInGDk<H3LLVSL zed-im@%I$IiL(F+B`}3lySx7Q^eK2OZH3p>oijPxW^;DBb9PQ~Q-xyAJ|Vk&nwv@6 zFV-KIWqPagImVpiBajheNJ1){?FCkGg|2~2bf~>&Aa$Nw?TUnz9bmSZ1)VdMelpua z$@VRH%l7-XEa+=B``zaO4nj^Z(T61R5gZ^%>HKm#Lk(tu_!Diq0eMJ_;$PxQY2=S^ zJ+I~aWMu<Y8}r-I1V5to#D`Pbv;4KQlWEV#K3_K;b1UIy!i43!iO;pnrfm??HoSiJ zKJ!rT3eABX8l=t8*&t@FD>PLya>Q@i&$wdHDFHps_e1o}nDKZ({9SZShQik2-0BK- z0H>826PI2g5aaSHa%TnsR0c<EYY&mko{gjKfr0j|d<yca&dHoNfj7+P)p>IX-!(*} zYz>jG{JgQHg(Lwjd@7>OM*te91#wx7`7oA*emm!lFqgCs4)XIM&U26=I0xG?dU>MG zBr<h9T@_M5>!U=!vb~eUFNb1KcW+PYysicp^I>efw~(IByxzg1{OoIB^a6YqHK6^Z zgdM^5qVJ;7RgC-}&`-8MeVwk@{`4)nVk~{Oi=4tw2uHMb$Gnb;FYQ%*JoJw!`c8Ta z@yMC$E{PScAU)`4Q1J&5<7NCiNjKv@RFgt2v!SW)ho-*NK%fqj8^yfcQw1}5dpx0g zFYTVw86G+KCkIFN3F)<$4tjLfx$wv<J1*~dbmyhAkB#QXoL5|zU86f@jXCcdbMBbJ zuY_F=n>8i7P03?Df+^W!T6ZTj`l&eLSU+2Kz4}`9#J<sLF=@MK*&&7&Bmc07D>;{Q z9$kH@bj}$5$k|8EUIK?P>Pp1dBSz}49)A3=XiA<n<+x2S&!033rW``F23yQ9GDye= zBPYhR&liq4UvS-UJ$FIOD4H0Y$=EhoDx~k43K7g@9@CyVQ`i;LWz(gSJG#)})kt>k z@pGP3yHI~fu(f<pe_TwpiwSm*u9cpbkJOHwdHU!`)mZpTr4!m$4Yv*7sT|)i8Txj{ zRO>r^Z}o}0>xB{k0@yqaM}=cev&T-lkDU}7dIejb(C6};C&%`?w7Uu4U>AUVKb214 z)W{fH=izMzMwM0CrE^P{UpHMdc~Z+K`5&JB!C5i2TwGc%Bvec<W0>E^X6hORYm?B_ zDRgxUJ&u{KQ$nYNI<SYBQhR}mftL~798MCX27c8OTWGI@L9j<_BlPp|2E@m{TKh4z zz{KuXD{fc3dhqr^PthJv!d`dLo||RkC8OHwq1Qsk(!LonW`8CMH{Va#I~Nu|-TGel zPrJp0hIhNCEr^2>gXtOmeEoR(t(==V$gJ{q<rMcywU}EfW|qCbwCvq(cR~ZWO!^}n zd*4%oqa~x=->ei9vOT&SG_nx38qhcAzuWwQZu|Xv0BG39-JefrV4kDj%}c3Diuh@A ziJ>apJa1}gp)L!4L<B#Isf8b*F8(A1Pf?(!fcD%xt!%uBf)^?H4h1h!@Q+AVOq)OE zEswX*RTKq`os-4yrtz|i0$Lb(HsZ_aCy^%nUb-UEYd)OR&m`S4rzE?pz%wc#E#WN? zi~%B&yga~lCj^T9gY&xbJ=Q(a#6kOiNv`*Ex+2*ee}L-armHt7m_{%kR(B3-OW&Te z5ZciuMv@*9fh!@!Sm~;sf=&wlKv{It6`Pc3U^BkdybdZlgG}j6;!|M7KvB2%4YUvS zb`<l65fMo>&T@q49~}3oE{fCLO9i<6hnjHqXZ}zV`k^N5Lru)zXi`2hnjg9Fl?x-~ zqH)EnG1G0#6pgE9jrne4{v{oT?QrMUE{;4jW6Z=aL)h@4uk~IE0VgN?A?}h9OjvX5 z$bqp9<9U-yh0;SpLzB?bCq(pL3jN3sJ(4kUeyntS`9#~~#;L;TaCkl!^hYjf|IV0q zN%s+$#pgydg|xznBU7Q%8-==N;dqY#kh{|aZt;+WLSovZ#cLy?Ml2(x;LMheS%mP6 zOQw$^qb`MgX5hHckudarE)={VZY`KY+-fkKxDxKJnd6G+vIdvoT*4>GHZCmklHqe+ z>ojss?o$mrebyVL<I?Y%W4RrltkLKqhFzYJWpg^?BjsNyzg+*Q?E_sLM0UDF*3$nM DXS{lW diff --git a/site/src/brand/favicon.svg b/site/assets/brand/favicon.svg similarity index 100% rename from site/src/brand/favicon.svg rename to site/assets/brand/favicon.svg diff --git a/site/src/brand/symbol-on-dark.svg b/site/assets/brand/symbol-on-dark.svg similarity index 100% rename from site/src/brand/symbol-on-dark.svg rename to site/assets/brand/symbol-on-dark.svg diff --git a/site/src/brand/symbol-on-light.svg b/site/assets/brand/symbol-on-light.svg similarity index 100% rename from site/src/brand/symbol-on-light.svg rename to site/assets/brand/symbol-on-light.svg diff --git a/site/src/brand/wordmark-on-dark.svg b/site/assets/brand/wordmark-on-dark.svg similarity index 100% rename from site/src/brand/wordmark-on-dark.svg rename to site/assets/brand/wordmark-on-dark.svg diff --git a/site/src/brand/wordmark-on-light.svg b/site/assets/brand/wordmark-on-light.svg similarity index 100% rename from site/src/brand/wordmark-on-light.svg rename to site/assets/brand/wordmark-on-light.svg diff --git a/site/src/cobogo-defs.svg b/site/assets/cobogo-defs.svg similarity index 100% rename from site/src/cobogo-defs.svg rename to site/assets/cobogo-defs.svg diff --git a/site/build.py b/site/build.py deleted file mode 100755 index 7c860399..00000000 --- a/site/build.py +++ /dev/null @@ -1,257 +0,0 @@ -#!/usr/bin/env python3 -"""Build the Deco Blocks docs site into one self-contained page: site/dist/index.html. - - python3 site/build.py && open site/dist/index.html - -Inputs, all under site/: - src/shell.html the page frame (header, sidebar, rail, search dialog) with {{placeholders}} - src/landing.html the Home page - src/content.html the Docs and Under-the-hood pages, and the Prism bundle (copied verbatim) - src/style.css, src/dark-tokens.css, src/roadmap.css, src/app.js, src/cobogo-defs.svg, src/brand/*.svg - data/roadmap.json the Roadmap page, rendered by gen_roadmap.py - -Python 3 standard library only. The page's only external requests are the Fontshare and Google -Fonts stylesheets in shell.html. -""" -import json -import pathlib -import re -import sys -import urllib.parse - -HERE = pathlib.Path(__file__).resolve().parent -SRC = HERE / "src" -DIST = HERE / "dist" -sys.path.insert(0, str(HERE)) -sys.dont_write_bytecode = True # no __pycache__ next to the sources -import gen_roadmap # noqa: E402 - -content = (SRC / "content.html").read_text(encoding="utf-8") - - -def balanced_sections(fragment): - """Top-level <section ...id="..."> blocks in order, balanced on <section> tags.""" - out, pos = [], 0 - while True: - m = re.compile(r'<section\b[^>]*\bid="([^"]+)"').search(fragment, pos) - if not m: - return out - depth = 0 - for t in re.finditer(r"<(/?)section\b", fragment[m.start():]): - depth += -1 if t.group(1) else 1 - if depth == 0: - end = m.start() + t.end() - end = fragment.index(">", end) + 1 - out.append((m.group(1), fragment[m.start():end])) - pos = end - break - - -def page_fragment(name): - start = content.index(f'<div class="page" data-page="{name}">') - nxt = content.find('<div class="page" data-page=', start + 10) - end = nxt if nxt != -1 else content.index('<script id="syntax-highlighter">') - return content[start:end] - - -docs = balanced_sections(page_fragment("docs")) -internals = balanced_sections(page_fragment("internals")) -assert [s for s, _ in docs][:2] == ["architecture", "quickstart"], docs[:2] -assert len(docs) == 19 and len(internals) == 6, (len(docs), len(internals)) - -prism = re.search(r'<script id="syntax-highlighter">.*?</script>', content, re.S).group(0) -assert "MIT LICENSE" in prism, "keep the Prism license notice in the bundle" - -# ------------------------------------------------------------------ roadmap page -# The fourth page ("Roadmap", the next major's to-do list) is rendered by gen_roadmap.py from -# data/roadmap.json; its styles are src/roadmap.css. The sidebar groups in app.js list these ids, -# so all of them must be present. -ROADMAP_IDS = gen_roadmap.SEC_IDS -roadmap_html = gen_roadmap.render().strip() -roadmap_ids = [s for s, _ in balanced_sections(roadmap_html)] -assert roadmap_ids == ROADMAP_IDS, ("roadmap sections differ from the sidebar's", roadmap_ids) -clash = sorted(set(roadmap_ids) & {s for s, _ in docs + internals}) -assert not clash, ("roadmap section ids collide with doc ids", clash) -roadmap_css = (SRC / "roadmap.css").read_text(encoding="utf-8") - -# ------------------------------------------------------------------ icons -P = 'fill="none" stroke="currentColor" stroke-width="{w}" stroke-linecap="round" stroke-linejoin="round"' -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"/>', -} -GITHUB = ('<svg class="icon i-github" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path fill="currentColor" d="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"/></svg>') - - -# Filled monochrome marks for the stack strip, drawn like the site's (fill: currentColor, 18px). -# Next.js and the git diamond are the site's own paths; the others are simple filled glyphs. -MARKS = { - "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"/>', -} - - -def svg_mark(name): - return (f'<svg class="mark m-{name}" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">' - f'{MARKS[name]}</svg>') - - -def svg_icon(name, width="1.75"): - if name == "github": - return GITHUB - return (f'<svg class="icon i-{name}" viewBox="0 0 24 24" {P.format(w=width)} aria-hidden="true" focusable="false">' - f'{ICON_PATHS[name]}</svg>') - - -# ------------------------------------------------------------------ brand -def brand(name, cls): - s = (SRC / "brand" / name).read_text(encoding="utf-8") - s = re.sub(r"\s+", " ", s).strip() - s = s.replace("> <", "><") - s = re.sub(r'<svg[^>]*?viewBox="([^"]+)"[^>]*>', - lambda m: f'<svg class="{cls}" viewBox="{m.group(1)}" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">', s, count=1) - s = s.replace(" />", "/>") - return s - - -wordmark = brand("wordmark-on-light.svg", "wm wm-light") + brand("wordmark-on-dark.svg", "wm wm-dark") -# Both variants of the "d" symbol; CSS shows the one that matches the theme (like the wordmark). -symbol = brand("symbol-on-light.svg", "sym sym-light") + brand("symbol-on-dark.svg", "sym sym-dark") -fav = re.sub(r"\s+", " ", (SRC / "brand" / "favicon.svg").read_text(encoding="utf-8")).strip() -# Single quotes inside the SVG and everything else percent-encoded, so the data URI -# can sit inside a double-quoted href without ending the attribute early. -favicon = "data:image/svg+xml," + urllib.parse.quote(fav.replace('"', "'"), safe="=:/'") -assert '"' not in favicon - - -def fill_icons(text): - text = re.sub(r"\{\{mark:([\w-]+)\}\}", lambda m: svg_mark(m.group(1)), text) - return re.sub(r"\{\{icon:([\w-]+)\}\}", lambda m: svg_icon(m.group(1)), text) - - -css = (SRC / "style.css").read_text(encoding="utf-8") -dark = (SRC / "dark-tokens.css").read_text(encoding="utf-8").strip() -dark = "\n".join(" " + line for line in dark.splitlines()) -css = css.replace("{{dark_tokens}}", dark) -# Print always uses the light palette, so the light :root token list is repeated -# inside @media print (where it also beats the automatic dark block). -light = re.search(r"^:root \{\n(.*?)^\}", css, re.S | re.M).group(1) -light = re.sub(r"/\*.*?\*/", "", light) -light = "\n".join(" " + l for l in light.splitlines() if l.strip()) -css = css.replace("{{light_tokens}}", light) - - -# ------------------------------------------------------------------ size -# The page is one file with a size budget, so our own CSS, JS and HTML are compacted here. -# Only whitespace and comments go: quoted strings and url() are set aside first, and nothing -# frozen (doc sections, Prism) passes through these functions. -def min_css(text): - kept = [] - - def keep(m): - kept.append(m.group(0)) - return f"\x00{len(kept) - 1}\x00" - - text = re.sub(r'"(?:\\.|[^"\\\n])*"|\'(?:\\.|[^\'\\\n])*\'|url\([^)]*\)', keep, text) - text = re.sub(r"/\*[\s\S]*?\*/", "", text) - text = re.sub(r"\s+", " ", text) - text = re.sub(r"\s*([{};,>])\s*", r"\1", text) # never around + ~ - (calc, combinators) - text = re.sub(r":\s+", ":", text) # "prop: value" and "(min-width: 640px)"; selectors never have a space after ':' - text = text.replace(";}", "}").strip() - assert "\x00" not in "".join(kept) - return re.sub(r"\x00(\d+)\x00", lambda m: kept[int(m.group(1))], text) - - -def min_js(text): - """Drop whole-line comments and indentation. app.js has no multi-line strings or - template literals (asserted), so no line break or indent inside a string is touched.""" - assert text.count("`") == len(re.findall(r"^\s*//.*`.*`", text, re.M)) * 2, "template literal in app.js" - text = re.sub(r"^[ \t]*/\*(?:(?!\*/)[\s\S])*\*/[ \t]*\n", "", text, flags=re.M) - text = re.sub(r"^[ \t]*//.*\n", "", text, flags=re.M) - return re.sub(r"^[ \t]+", "", text, flags=re.M) - - -def min_html(text): - """Indentation outside <pre> only (a newline still separates inline elements).""" - parts = re.split(r"(<pre\b[\s\S]*?</pre>)", text) - return "".join(p if p.startswith("<pre") else re.sub(r"\n[ \t]+", "\n", p) for p in parts) - - -css = min_css(css) -roadmap_css = min_css(roadmap_css) - -js = min_js((SRC / "app.js").read_text(encoding="utf-8")) -icons_for_js = {n: svg_icon(n) for n in ["link", "copy", "check", "file", "arrow-left", "arrow-right", "play", "box", "sliders", "globe", "book", "layers", "server", "cpu", "hash", "code", "terminal", "printer", "search", "corner", "list", "zap", "pencil", "git-branch", "eye"]} -js = js.replace("{{icons_json}}", json.dumps(icons_for_js, separators=(",", ":"))) - -wordmark_lime = brand("wordmark-on-dark.svg", "wm wm-lime") -landing = (fill_icons((SRC / "landing.html").read_text(encoding="utf-8")) - .replace("{{wordmark_dark}}", wordmark_lime).replace("{{wordmark}}", wordmark).replace("{{symbol}}", symbol)) - -fm = re.search(r"\n\s*<footer class=\"site-footer\">.*?</footer>\n", landing, re.S) -assert fm, "site footer not found in landing" -site_footer = min_html(fm.group(0).strip()) -landing = min_html(landing[: fm.start()] + "\n" + landing[fm.end():]) - -shell = min_html((SRC / "shell.html").read_text(encoding="utf-8")) -out = (shell - .replace("{{favicon}}", favicon) - .replace("{{cobogo}}", (SRC / "cobogo-defs.svg").read_text(encoding="utf-8").strip()) - .replace("{{wordmark}}", wordmark) - .replace("{{landing}}", landing.strip()) - .replace("{{site_footer}}", site_footer) - .replace("{{docs}}", "\n\n".join(frag for _, frag in docs)) - .replace("{{internals}}", "\n\n".join(frag for _, frag in internals)) - .replace("{{roadmap}}", roadmap_html)) -out = fill_icons(out) -# Frozen pieces and code go in last, so no placeholder expansion can touch them. -out = out.replace("{{css}}", css).replace("{{roadmap_css}}", roadmap_css).replace("{{prism}}", prism).replace("{{js}}", js) - -assert "<main" not in out[: out.index('<main id="main"')], "stray <main before main" -assert "</main>" not in out[out.index("</main>") + 7:], "stray </main> after main" -left = re.findall(r"\{\{(?:css|js|prism|landing|site_footer|docs|internals|roadmap|roadmap_css|favicon|wordmark|cobogo|dark_tokens|light_tokens|icons_json|icon:[\w-]+|mark:[\w-]+)\}\}", out) -assert not left, left[:5] - -DIST.mkdir(exist_ok=True) -(DIST / "index.html").write_text(out, encoding="utf-8") -print(f"wrote {DIST / 'index.html'} {len(out.encode()) / 1024:.1f} KB docs={len(docs)} internals={len(internals)} roadmap={len(roadmap_ids)}") diff --git a/site/build/manifest.ts b/site/build/manifest.ts new file mode 100644 index 00000000..1c3b737e --- /dev/null +++ b/site/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/site/build/rehype-docs.ts b/site/build/rehype-docs.ts new file mode 100644 index 00000000..4777c9fe --- /dev/null +++ b/site/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/site/build/shiki-theme.ts b/site/build/shiki-theme.ts new file mode 100644 index 00000000..d9ca8e41 --- /dev/null +++ b/site/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/site/build/slugify.ts b/site/build/slugify.ts new file mode 100644 index 00000000..c1578b4e --- /dev/null +++ b/site/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 + * `#content--the-content-module` maps to `/next/content#the-content-module` 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/site/bun.lock b/site/bun.lock new file mode 100644 index 00000000..7ba00b9d --- /dev/null +++ b/site/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/site/components/home/Footer.tsx b/site/components/home/Footer.tsx new file mode 100644 index 00000000..43ccc71b --- /dev/null +++ b/site/components/home/Footer.tsx @@ -0,0 +1,97 @@ +import { MdxLink } from '~/components/mdx/MdxLink' +import { WordmarkLime } from '~/components/ui/Brand' +import { d } from './Hero' + +export function FinalCta() { + return ( + <div className="final" role="group" aria-labelledby="final-title"> + <div className="container final-inner"> + <h2 id="final-title" className="reveal"> + Start with one function. <span className="dim">Ship it from Git.</span> + </h2> + <div className="cta reveal" style={d('120ms')}> + <MdxLink className="btn btn-primary" href="/next/quickstart"> + Read the quickstart → + </MdxLink> + <MdxLink className="btn btn-white" href="/next/architecture"> + How it works + </MdxLink> + </div> + <p className="final-note reveal" style={d('120ms')}> + The quickstart runs in plain Node, no framework. Or read the <MdxLink href="/next/standalone">API reference</MdxLink> ·{' '} + <MdxLink href="/next/internals">Under the hood</MdxLink>. + </p> + </div> + </div> + ) +} + +const COLUMNS: { title: string; links: [string, string][] }[] = [ + { + title: 'Docs', + links: [ + ['/next/architecture', 'How it works'], + ['/next/quickstart', 'Quickstart'], + ['/next/model', 'Blocks'], + ['/next/content', 'Content & loaders'], + ['/next/preview', 'Preview'], + ['/next/standalone', 'API reference'], + ], + }, + { + title: 'Guides', + links: [ + ['/next/nextjs', 'Next.js App Router'], + ['/next/tanstack-data', 'TanStack Start'], + ['/next/routing', 'Routing'], + ['/next/releases', 'Releases & deployment'], + ['/next/troubleshooting', 'Troubleshooting'], + ], + }, + { + title: 'Under the hood', + links: [ + ['/next/internals', 'Overview'], + ['/next/walkthrough', 'How resolution works'], + ['/next/loader-internals', 'Loader internals'], + ['/next/router-internals', 'Router internals'], + ['/next/decisions', 'Design decisions'], + ], + }, + { + title: 'Project', + links: [ + ['https://github.com/decocms/blocks', 'GitHub'], + ['/next/internals#contributing', 'Contributing'], + ['/next/adoption', 'Renaming & migrating'], + ['/roadmap', 'Roadmap'], + ], + }, +] + +/** The landing's site footer (legacy.css hides .site-footer outside the landing layout). */ +export function SiteFooter() { + return ( + <footer className="site-footer"> + <div className="footer-panel"> + <div className="container"> + <div className="footer-grid"> + {COLUMNS.map((col) => ( + <nav className="footer-col" aria-label={col.title} key={col.title}> + <p>{col.title}</p> + {col.links.map(([href, label]) => ( + <MdxLink href={href} key={href}> + {label} + </MdxLink> + ))} + </nav> + ))} + </div> + </div> + <div className="footer-mark" aria-hidden="true"> + <WordmarkLime /> + </div> + </div> + </footer> + ) +} diff --git a/site/components/home/Hero.tsx b/site/components/home/Hero.tsx new file mode 100644 index 00000000..d14e098b --- /dev/null +++ b/site/components/home/Hero.tsx @@ -0,0 +1,80 @@ +import { useState, type CSSProperties } 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' + +/** `style={d('120ms')}`: the entrance/reveal delay (`--d`) the old markup set inline. */ +export const d = (ms: string) => ({ '--d': ms }) as CSSProperties + +/** The cobogó pattern behind a band (patterns defined once by <CobogoDefs/>). */ +export function Cobogo({ className = '' }: { className?: string }) { + return ( + <svg className={`cobogo${className ? ` ${className}` : ''}`} aria-hidden="true" focusable="false"> + <rect className="cb-lg" width="100%" height="100%" fill="url(#cb-lg)" /> + <rect className="cb-sm" width="100%" height="100%" fill="url(#cb-sm)" /> + </svg> + ) +} + +const INSTALL = 'npm install @decocms/blocks' + +function InstallButton() { + const [copied, setCopied] = useState(false) + return ( + <button + className={`install${copied ? ' copied' : ''}`} + type="button" + data-copy={INSTALL} + aria-label={`Copy install command: ${INSTALL}`} + onClick={async () => { + const ok = await copyText(INSTALL) + toast(ok ? `Copied: ${INSTALL}` : 'Select the text to copy it.') + if (ok) { + setCopied(true) + setTimeout(() => setCopied(false), 1600) + } + }} + > + <span className="prompt" aria-hidden="true"> + $ + </span> + <span>{INSTALL}</span> + <span className="install-icon" aria-hidden="true"> + <Icon name="copy" /> + <Icon name="check" /> + </span> + </button> + ) +} + +export function Hero() { + return ( + <div className="hero band"> + <Cobogo /> + <div className="container hero-inner"> + <h1 id="home-title" className="enter" style={d('60ms')}> + Headless for developers. + <br /> Editable for humans. + <br /> <span className="hl">Native for AI.</span> + </h1> + <p className="lede enter" style={d('120ms')}> + Deco Blocks is the AI-native headless CMS. Developers write the functions, marketers edit the content and settings those + functions use in Deco Studio's visual editor, AI agents edit the same content as files, and every change lands in Git. + </p> + <div className="cta enter" style={d('300ms')}> + <MdxLink className="btn btn-primary" href="/next/quickstart"> + Start building + </MdxLink> + <a className="btn btn-white" href="#home-how"> + See how it works + </a> + <InstallButton /> + </div> + </div> + <div className="container"> + <Journey /> + </div> + </div> + ) +} diff --git a/site/components/home/Journey.tsx b/site/components/home/Journey.tsx new file mode 100644 index 00000000..d892892a --- /dev/null +++ b/site/components/home/Journey.tsx @@ -0,0 +1,413 @@ +/** + * The hero's product window: the four-pane "Experiments" journey (Type it → Edit it → Commit it → + * Resolve it). Dragging a Studio 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' + +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 Studio 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` : '' +} + +const Arrow = () => ( + <span className="jp-arrow" aria-hidden="true"> + <Icon name="chevron-right" /> + </span> +) + +const STEPS = ['Type', 'Edit', 'Commit', 'Resolve'] + +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 [step, setStep] = useState(0) + const journeyRef = useRef<HTMLDivElement>(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 Studio, Save commits the file to your repository.') + } + + /* ---- phone carousel: the pills follow the scroll position and drive it ---- */ + 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]) + + const prop = (k: Key, v: number, comma: string) => ( + <> + {' '} + <P>"{k}"</P>: <N>{v}</N> + {comma} + </> + ) + + return ( + <> + <div className="hero-window win enter" data-pagefind-ignore="" style={{ '--d': '420ms' } as CSSProperties}> + <div className="win-bar"> + <span className="win-dots" aria-hidden="true"> + <i /> + <i /> + <i /> + </span> + <span className="win-title">Deco Blocks · my-store — Experiments</span> + </div> + <div className="journey" role="group" aria-label="From a TypeScript type to a resolved value" ref={journeyRef}> + <div className="jp jp-type"> + <div className="jp-head"> + <span className="jp-step">01</span> + <span className="jp-title">Type it</span> + <span className="jp-meta">experiments.ts</span> + </div> + <pre className="jp-pre has-ln"> + <code> + <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="jp-foot"> + <span className="mono">npx deco schema</span> + <span className="jp-foot-sep" aria-hidden="true"> + → + </span> + <span className="mono">.deco/schema.json</span> + </div> + <Arrow /> + </div> + + <div className="jp jp-form"> + <div className="jp-head"> + <span className="jp-step">02</span> + <span className="jp-title">Edit it</span> + <span className="jp-meta">Deco Studio</span> + </div> + <div className="studio"> + <div className="studio-bar"> + <span className="studio-block"> + <BrandSymbol /> + Experiments + </span> + <span className="studio-type">experiments</span> + </div> + {KEYS.map((k) => ( + <div className="studio-field" key={k}> + <div className="studio-row"> + <label htmlFor={`exp-${k}`}>{FIELDS[k]}</label> + <output id={`out-${k}`} htmlFor={`exp-${k}`}> + {values[k]} + </output> + </div> + <input + type="range" + 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="studio-actions"> + <span className="studio-hint">0–100, from the JSDoc tags</span> + <button type="button" className="studio-save" id="studio-save" disabled={!changed} onClick={save}> + {changed ? 'Save' : 'Saved'} + </button> + </div> + </div> + <Arrow /> + </div> + + <div className="jp jp-json"> + <div className="jp-head"> + <span className="jp-step">03</span> + <span className="jp-title">Commit it</span> + <span className="jp-meta" title=".deco/blocks/Experiments.json"> + <span className="jm-dir">.deco/blocks/</span>Experiments.json + </span> + </div> + <div className="jp-src"> + <span className="src src-studio"> + <i aria-hidden="true" /> + Saved in Studio + </span> + <span className="src-or">or</span> + <span className="src src-agent"> + <i aria-hidden="true" /> + Edited by an agent + </span> + </div> + <pre className="jp-pre jp-diff" aria-label="Diff of .deco/blocks/Experiments.json"> + <code id="exp-json"> + <span className="dl"> + <span className="gut"> </span> + {'{'} + </span> + <span className="dl"> + <span className="gut"> </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"> </span> + {prop(k, values[k], comma)} + </span> + ) + return ( + <Fragment key={k}> + <span className="dl del"> + <span className="gut">-</span> + {prop(k, saved[k], comma)} + </span> + {/* Keyed by value so each change remounts the line and the flash replays. */} + <span className={`dl add${k === flashKey ? ' flash' : ''}`} key={`${k}-${values[k]}`}> + <span className="gut">+</span> + {prop(k, values[k], comma)} + </span> + </Fragment> + ) + })} + <span className="dl"> + <span className="gut"> </span> + {'}'} + </span> + </code> + </pre> + <div className="jp-foot jp-commit" id="exp-foot" data-state={state} aria-live="polite" ref={footRef}> + <span className="jc-dot" aria-hidden="true" /> + <Icon name="check" /> + <span className="jc-status" id="exp-status"> + {status} + </span> + <span className="mono jc-msg" id="exp-commit"> + {commitLine} + </span> + </div> + <Arrow /> + </div> + + <div className="jp jp-call"> + <div className="jp-head"> + <span className="jp-step">04</span> + <span className="jp-title">Resolve it</span> + <span className="jp-meta">checkout.ts</span> + </div> + <pre className="jp-pre has-ln"> + <code> + <L> + <K>import</K> {'{ createCMS } '} + <K>from</K> + </L> + <L> + {' '} + <S>"@decocms/blocks"</S>; + </L> + <L> + <K>import</K> experiments <K>from</K> + </L> + <L> + {' '} + <S>"./experiments"</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: { experiments },'}</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="jp-foot"> + <Icon name="check" /> + <span>Same result as the direct call, same type</span> + </div> + </div> + </div> + </div> + <div className="journey-nav 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" data-jp={i} aria-current={i === step ? 'step' : undefined} key={label} onClick={() => showPanel(i)}> + <span aria-hidden="true">{`0${i + 1}`}</span> + {label} + </button> + ))} + </div> + <div className="journey-caption enter" style={{ '--d': '520ms' } as CSSProperties}> + <p> + The Quickstart, end to end. Studio saves it, or an agent edits it: either way it's a commit, and the function never changes.{' '} + <span className="try">Drag a slider, then Save.</span> + </p> + <MdxLink className="text-link" href="/next/quickstart"> + Follow the quickstart + <Icon name="arrow-right" /> + </MdxLink> + </div> + </> + ) +} diff --git a/site/components/home/Sections.tsx b/site/components/home/Sections.tsx new file mode 100644 index 00000000..bfb61d23 --- /dev/null +++ b/site/components/home/Sections.tsx @@ -0,0 +1,460 @@ +/** + * 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.) Classes are the old landing's (legacy.css). + */ +import type { CSSProperties } from 'react' +import { Icon, Mark, type MarkName } from '~/components/ui/Icon' +import { MdxLink } from '~/components/mdx/MdxLink' +import { Cobogo, d } from './Hero' + +const Dots = ({ hidden }: { hidden?: boolean }) => ( + <span className="win-dots" aria-hidden={hidden ? 'true' : undefined}> + <i /> + <i /> + <i /> + </span> +) + +const STACK: [MarkName, string][] = [ + ['nextjs', 'Next.js'], + ['tanstack', 'TanStack Start'], + ['cloudflare', 'Cloudflare Workers'], + ['react', 'React Native'], + ['node', 'Node.js'], + ['git', 'Any Git repo'], +] + +export function StackStrip() { + return ( + <div className="lsec stack-sec"> + <div className="container"> + <p className="kicker is-center reveal">Runs on your stack</p> + <ul className="stack reveal" style={d('80ms')} aria-label="Runtimes"> + {STACK.map(([mark, label]) => ( + <li key={mark}> + <Mark name={mark} /> + {label} + </li> + ))} + </ul> + </div> + </div> + ) +} + +export function ContentModel() { + return ( + <div className="lsec"> + <div className="container section"> + <div className="section-head"> + <p className="kicker reveal">One content model</p> + <h2 className="reveal" style={d('60ms')}> + Every change is a commit, <span className="dim">whoever makes it.</span> + </h2> + <p className="section-lede 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. + </p> + </div> + <div className="contract-list reveal" style={d('160ms')}> + <div className="contract-item"> + <div className="ci-visual ci-code win" aria-hidden="true" data-pagefind-ignore=""> + <div className="win-bar"> + <Dots /> + <span className="win-title">blocks.ts</span> + </div> + <pre className="mini-pre"> + <code> + <span className="sy-k">export default</span> + {' {\n '} + <span className="sy-p">experiments</span> + {', '} + <span className="sy-c">// returns A/B flags</span> + {'\n '} + <span className="sy-p">hero</span> + {', '} + <span className="sy-c">// returns JSX</span> + {'\n '} + <span className="sy-p">seo</span> + {', '} + <span className="sy-c">// returns page metadata</span> + {'\n} '} + <span className="sy-k">satisfies</span> <span className="sy-t">Blocks</span>; + </code> + </pre> + </div> + <span className="num">01</span> + <h3>Headless for developers</h3> + <p> + Write a function, type its inputs, and add it to your block map. Content can now call it. Deco Blocks 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="contract-item"> + <div className="ci-visual ci-form win" aria-hidden="true" data-pagefind-ignore=""> + <div className="win-bar"> + <Dots /> + <span className="win-title">Studio — Summer campaign</span> + </div> + <div className="mf-body"> + <div className="mf-row"> + <span className="mf-label">Name</span> + <span className="mf-input">Summer campaign</span> + </div> + <div className="mf-row"> + <span className="mf-label">Path</span> + <span className="mf-input mono is-focus"> + /summer-sale + <span className="caret" /> + </span> + </div> + <div className="mf-row"> + <span className="mf-label">Blocks</span> + <span className="mf-chips"> + <span>hero</span> + <span>product</span> + <span className="add">+ Add</span> + </span> + </div> + </div> + </div> + <span className="num">02</span> + <h3>Editable for humans</h3> + <p> + Deco Studio, the visual editor in the hosted Deco CMS, turns your types into forms and live previews. Marketers and editors + change pages, campaigns, and settings without a developer, and every change is a commit you can review and roll back. + </p> + </div> + <div className="contract-item"> + <div className="ci-visual ci-agent" aria-hidden="true" data-pagefind-ignore=""> + <div className="bubble"> + <span className="bubble-who">You</span>Ramp the new checkout to 25% + </div> + <div className="agent-edit"> + <div className="ae-file"> + <Icon name="sparkle" /> + <span>Edited .deco/blocks/Experiments.json</span> + </div> + <code className="ae-line del">- "newCheckout": 10,</code> + <code className="ae-line add">+ "newCheckout": 25,</code> + <div className="ae-ok"> + <Icon name="check" /> + <span> + Valid against <b>.deco/schema.json</b> + </span> + </div> + </div> + </div> + <span className="num">03</span> + <h3>Native for AI</h3> + <p> + 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> + ) +} + +export function Stability() { + return ( + <div className="lsec prod-sec"> + <div className="container"> + <div className="prod rely"> + <Cobogo className="cobogo-right" /> + <div className="section-head"> + <p className="kicker reveal">Built for stability</p> + <h2 className="reveal" style={d('60ms')}> + Your site ships with its content. <span className="dim">Nothing stands between it and your visitors.</span> + </h2> + <p className="section-lede 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. + </p> + </div> + <div className="rely-grid"> + <div className="status win reveal" style={d('160ms')} role="group" aria-labelledby="status-title" data-pagefind-ignore=""> + <div className="win-bar"> + <Dots hidden /> + <span className="win-title" id="status-title"> + store.example.com — status + </span> + </div> + <div className="status-body"> + <div className="pkg"> + <p className="pkg-label">Every deploy ships</p> + <div className="pkg-tiles"> + <div className="pkg-tile"> + <Icon name="code" /> + <strong>Your code</strong> + <small>Design, features</small> + </div> + <span className="pkg-plus" aria-hidden="true"> + + + </span> + <div className="pkg-tile is-content"> + <Icon name="file" /> + <strong>Your content</strong> + <small>Pages, banners, settings</small> + </div> + </div> + <p className="pkg-cap">Packed together, tested together.</p> + </div> + <ul className="st-rows" role="list"> + <li> + <span className="st-k">Your site</span> + <span className="st-pill is-serving"> + <i aria-hidden="true" /> + Online + </span> + </li> + <li> + <span className="st-k">Content</span> + <span className="st-pill is-ok"> + <Icon name="check" /> + Inside this deploy + </span> + </li> + <li> + <span className="st-k">Page loads</span> + <span className="st-pill is-ok"> + <Icon name="check" /> + No extra round trip + </span> + </li> + </ul> + </div> + </div> + <ul className="rely-points 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="text-link dev-link reveal" href="/next/releases"> + <span className="dev-k">For developers:</span> how content{' '} + <span className="dl-end"> + loads + <Icon name="arrow-right" /> + </span> + </MdxLink> + </div> + </div> + </div> + </div> + ) +} + +function Point({ title, children }: { title: string; children: string }) { + return ( + <li> + <Icon name="check" /> + <span className="rp-body"> + <strong>{title}</strong> + <span>{children}</span> + </span> + </li> + ) +} + +export function Publishing() { + return ( + <div className="lsec pub-sec"> + <div className="container section"> + <div className="section-head"> + <p className="kicker reveal">Hosted Deco CMS · optional</p> + <h2 className="reveal" style={d('60ms')}> + Publish in seconds, <span className="dim">not on the next deploy.</span> + </h2> + <p className="section-lede reveal" style={d('120ms')}> + The hosted Deco CMS adds Deco Studio, the visual editor, and publishing without a redeploy: each change reaches every visitor in + seconds, and editors preview drafts on your real pages first. Without it, developers and agents edit the files, and content goes + live with your next deploy. + </p> + </div> + <div className="pub win reveal" style={d('160ms')} aria-hidden="true" data-pagefind-ignore=""> + <div className="win-bar"> + <Dots /> + <span className="win-title">Publishing a change — store.example.com</span> + </div> + <div className="pub-body"> + <div className="pub-lane is-base"> + <p className="pub-label"> + <b>Without it</b> + <span className="pub-sep">·</span> + <span className="pub-sub">content ships with your next deploy</span> + </p> + <ol className="pub-steps"> + <li style={c(1)}>Edit</li> + <li style={c(3)} className="is-far"> + Commit + </li> + <li style={c(4)}>Build</li> + <li style={c(5)}>Deploy</li> + <li style={c(6)} className="is-end"> + Live + </li> + </ol> + <div className="pub-notes"> + <span className="pub-bracket">code release</span> + <span className="pub-cap">at your next deploy</span> + </div> + </div> + <div className="pub-lane is-hosted"> + <p className="pub-label"> + <b>With the hosted Deco CMS</b> + <span className="pub-sep">·</span> + <span className="pub-sub">each publish is a commit</span> + </p> + <ol className="pub-steps"> + <li style={c(1)}>Edit</li> + <li style={c(2)} className="has-badge"> + Preview + <span className="pub-badge"> + <i /> + Previewing a draft + </span> + </li> + <li style={c(3)}>Publish</li> + <li style={c(4)} className="is-skip"> + Build + </li> + <li style={c(5)} className="is-skip"> + Deploy + </li> + <li className="is-skip is-merged">No build · no deploy</li> + <li style={c(6)} className="is-live"> + Live everywhere + </li> + </ol> + <div className="pub-notes"> + <span className="pub-bracket is-skip">not needed</span> + <span className="pub-cap is-live">in seconds</span> + </div> + </div> + </div> + </div> + <ul className="pub-points reveal" style={d('200ms')} role="list"> + <li> + <strong>Not tied to code releases</strong> + <span> + Content needs no build and no deploy, so editors publish when a campaign is ready, not when the next code release goes out. + </span> + </li> + <li> + <strong>Preview on the real site</strong> + <span>Editors open drafts on your actual pages before anything goes live, and only people Studio lets in can see them.</span> + </li> + <li> + <strong>Undo any change</strong> + <span>Every publish is saved in your repository's history, with who changed what and when, so going back is one step.</span> + </li> + </ul> + <MdxLink className="text-link dev-link reveal" href="/next/releases"> + <span className="dev-k">For developers:</span> publishing and{' '} + <span className="dl-end"> + releases + <Icon name="arrow-right" /> + </span> + </MdxLink> + </div> + </div> + ) +} + +const c = (n: number) => ({ '--c': n }) as CSSProperties + +const HOOD_LINKS: { href: string; title: string; text: string; read: string }[] = [ + { + href: '/next/model', + 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/walkthrough', + 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/content', + title: 'Swap any piece', + text: 'Where content comes from is one option you can change: files in your repository, the hosted Deco CMS, or your own storage, such as a key-value store or a database. Nothing else in your site changes.', + read: 'Read: Content & loaders', + }, + { + href: '/next/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 or Bun', 'yours'], + ['Visual editor & publishing without a redeploy', 'Hosted Deco CMS', 'opt'], +] + +export function SmallOnPurpose() { + return ( + <div className="lsec hood-sec"> + <div className="container section hood-grid"> + <div className="hood-left"> + <p className="kicker reveal">Yours to keep</p> + <h2 className="reveal" style={d('60ms')}> + Small on purpose. <span className="dim">Nothing hidden.</span> + </h2> + <p className="section-lede reveal" style={d('120ms')}> + Deco Blocks 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. + </p> + <div className="own reveal" style={d('160ms')}> + <p className="own-title" id="own-title"> + Where your site lives + </p> + <dl className="own-map" aria-labelledby="own-title"> + {OWN.map(([what, where, tag]) => ( + <div className="own-row" key={what}> + <dt>{what}</dt> + <dd className="own-where">{where}</dd> + <dd className="own-tag"> + <span className={`tag tag-${tag}`}>{tag === 'yours' ? 'Yours' : 'Optional'}</span> + </dd> + </div> + ))} + </dl> + </div> + </div> + <div className="hood-right"> + <div className="hood-links reveal" style={d('120ms')}> + {HOOD_LINKS.map((l) => ( + <MdxLink href={l.href} key={l.href}> + <span className="hl-body"> + <strong>{l.title}</strong> + <span>{l.text}</span> + <span className="hl-k">{l.read}</span> + </span> + <Icon name="arrow-right" /> + </MdxLink> + ))} + </div> + <MdxLink className="text-link hood-more reveal" href="/next/internals"> + How it works as a whole + <Icon name="arrow-right" /> + </MdxLink> + </div> + </div> + </div> + ) +} diff --git a/site/components/home/Stepper.tsx b/site/components/home/Stepper.tsx new file mode 100644 index 00000000..9c34922d --- /dev/null +++ b/site/components/home/Stepper.tsx @@ -0,0 +1,192 @@ +/** + * "Three steps, no ticket" (#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 } from 'react' +import { Icon } from '~/components/ui/Icon' +import { BrandSymbol } from '~/components/ui/Brand' +import { d } from './Hero' +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. With the hosted Deco CMS, that's the form editors use in Deco Studio, so marketing can change copy, images and campaigns without waiting on a developer.", + }, + { + 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: 'blocks.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 (Prism markup; see tokens.tsx). */ +function Code({ html }: { html: string }) { + return ( + <pre data-lang="TypeScript" data-label="TypeScript" className="language-typescript" tabIndex={0}> + <code className="language-typescript" dangerouslySetInnerHTML={{ __html: html }} /> + </pre> + ) +} + +export function Stepper() { + 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: `sw-pane${step === n ? ' is-on' : ''}`, + id: `step-pane-${n}`, + role: 'tabpanel', + 'aria-labelledby': `step-tab-${n}`, + }) + + return ( + <div className="lsec" id="home-how"> + <div className="container section"> + <p className="kicker reveal">Three steps, no ticket</p> + <div className="stepper"> + <div className="stepper-copy"> + <h2 className="reveal" style={d('60ms')}> + Your function stays. <span className="dim">Its inputs move into a file.</span> + </h2> + <p className="section-lede reveal" style={d('120ms')}> + 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. + </p> + <div className="steps 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="step" + 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="num" aria-hidden="true"> + {`0${n}`} + </span> + <span className="step-body"> + <span className="step-title">{s.title}</span> + <span className="step-text"> + <span>{s.text}</span> + </span> + </span> + </button> + ) + })} + </div> + </div> + <div className="sw win reveal" style={d('140ms')} data-pagefind-ignore=""> + <div className="win-bar"> + <span className="win-dots" aria-hidden="true"> + <i /> + <i /> + <i /> + </span> + <span className="win-title">Deco Blocks · my-store — setup</span> + </div> + <div className="sw-tabs" aria-hidden="true"> + {FILE_TABS.map((t, i) => ( + <span className={`sw-tab${step === i + 1 ? ' is-on' : ''}`} data-step={i + 1} key={t.label} onClick={() => select(i + 1)}> + <Icon name={t.icon} /> + {t.label} + </span> + ))} + </div> + <div className="sw-panes"> + <div {...pane(1)}> + <Code html={STEP1_HTML} /> + <p className="sw-result"> + <Icon name="check" /> + <span> + The key <code>experiments</code> is now a block type content can refer to. + </span> + </p> + </div> + <div {...pane(2)}> + <div className="sw-term"> + <span className="sw-prompt" aria-hidden="true"> + $ + </span> + <span>npx deco schema && npx deco content</span> + </div> + <div className="sw-form" role="group" aria-label="The form Studio builds from the schema"> + <div className="swf-head"> + <BrandSymbol /> + <span>Experiments</span> + <span className="swf-type">experiments</span> + </div> + {FORM_ROWS.map(([label, n]) => ( + <div className="swf-row" key={label}> + <span>{label}</span> + <span className="swf-num">{n}</span> + <span className="swf-range">0–100</span> + </div> + ))} + </div> + <p className="sw-result"> + <Icon name="check" /> + <span>Studio shows each experiment as a number field clamped to 0–100.</span> + </p> + </div> + <div {...pane(3)}> + <Code html={STEP3_HTML} /> + <p className="sw-result"> + <Icon name="check" /> + <span> + With the hosted Deco CMS (the token above), a commit is live everywhere in seconds, no deploy. Without it, content ships + with each deploy. + </span> + </p> + </div> + </div> + </div> + </div> + </div> + </div> + ) +} diff --git a/site/components/home/index.tsx b/site/components/home/index.tsx new file mode 100644 index 00000000..78f5082b --- /dev/null +++ b/site/components/home/index.tsx @@ -0,0 +1,40 @@ +/** + * Home (/). The landing, 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. Copy is approved as is: keep it verbatim. + * + * Styles are the old landing classes in src/styles/legacy.css. Entrance motion: `.enter` animates + * on load (CSS); `.reveal` fades up once on first scroll-in (useReveal), shown at once with + * prefers-reduced-motion, without IntersectionObserver, and before printing. + */ +import cobogoDefs from '~/assets/cobogo-defs.svg?raw' +import { LandingShell } from '~/src/layout/DocsShell' +import { sidebarFor } from '~/src/lib/nav' +import { DEFAULT_VERSION } from '~/src/lib/versions' +import { Hero } from './Hero' +import { ContentModel, Publishing, SmallOnPurpose, Stability, StackStrip } from './Sections' +import { Stepper } from './Stepper' +import { FinalCta, SiteFooter } from './Footer' +import { useReveal } from './useReveal' + +const COBOGO_DEFS = cobogoDefs.trim() + +export default function Home() { + const ref = useReveal<HTMLElement>() + return ( + <LandingShell nav={sidebarFor(DEFAULT_VERSION, 'docs')} footer={<SiteFooter />}> + {/* The cobogó <pattern>s (#cb-lg, #cb-sm) that the hero and stability bands fill with. */} + <span style={{ display: 'contents' }} dangerouslySetInnerHTML={{ __html: COBOGO_DEFS }} /> + <section id="home" className="landing" aria-labelledby="home-title" data-pagefind-body="" ref={ref}> + <Hero /> + <StackStrip /> + <ContentModel /> + <Stability /> + <Publishing /> + <Stepper /> + <SmallOnPurpose /> + <FinalCta /> + </section> + </LandingShell> + ) +} diff --git a/site/components/home/tokens.tsx b/site/components/home/tokens.tsx new file mode 100644 index 00000000..c7ce5b66 --- /dev/null +++ b/site/components/home/tokens.tsx @@ -0,0 +1,58 @@ +/** + * Hand-tokenised code for the landing's mockups (the old landing.html markup, as JSX). The + * journey panes use the `tk-*` classes, the contract card the `sy-*` classes (legacy.css styles + * both); the stepper's two code panes keep the Prism token markup the old page produced at runtime + * (legacy.css `pre .token.*`), frozen here so nothing highlights in the browser. + */ +import type { ReactNode } from 'react' + +type C = { children: ReactNode } +export const K = ({ children }: C) => <span className="tk-k">{children}</span> +export const T = ({ children }: C) => <span className="tk-t">{children}</span> +export const P = ({ children }: C) => <span className="tk-p">{children}</span> +export const S = ({ children }: C) => <span className="tk-s">{children}</span> +export const N = ({ children }: C) => <span className="tk-n">{children}</span> +export const F = ({ children }: C) => <span className="tk-f">{children}</span> +export const Cm = ({ children }: C) => <span className="tk-c">{children}</span> +export const Ck = ({ children }: C) => <span className="tk-ck">{children}</span> +/** One numbered line of a `.has-ln` pane. */ +export const L = ({ children }: C) => <span className="l">{children}</span> + +const tok = (cls: string, text: string) => `<span class="token ${cls}">${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 (blocks.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('// blocks.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')} content ${kw('from')} ${str('"./.deco/blocks.gen"')}${pu(';')}`, + `${kw('import')} blocks ${kw('from')} ${str('"./blocks"')}${pu(';')}`, + '', + cm('// .deco/blocks.gen is generated by `deco content`.'), + cm('// With a token, production reads the hosted Deco CMS;'), + cm('// without one, it reads that generated module.'), + `${kw('export')} ${kw('const')} cms ${op('=')} ${fn('createCMS')}${pu('(')}${pu('{')}`, + ` blocks${pu(',')}`, + ` content${pu(',')}`, + ` ${cm("// your site's API key for the hosted Deco CMS")}`, + ` token${op(':')} process${pu('.')}env${pu('.')}${tok('constant', 'DECO_SITE_TOKEN')}${pu(',')}`, + `${pu('}')}${pu(')')}${pu(';')}`, +].join('\n') diff --git a/site/components/home/useReveal.ts b/site/components/home/useReveal.ts new file mode 100644 index 00000000..02813f29 --- /dev/null +++ b/site/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/site/components/mdx/Callout.tsx b/site/components/mdx/Callout.tsx new file mode 100644 index 00000000..df728f59 --- /dev/null +++ b/site/components/mdx/Callout.tsx @@ -0,0 +1,27 @@ +import { Children, isValidElement, type ReactNode } from 'react' +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?: 'note' | 'warning' | 'preview'; children: ReactNode }) { + const cls = type === 'warning' ? 'callout warning' : type === 'preview' ? 'intro-note' : 'callout' + return <div className={cls}>{hasBlock(children) ? children : <p>{children}</p>}</div> +} + +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]) + +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/site/components/mdx/CodeBlock.tsx b/site/components/mdx/CodeBlock.tsx new file mode 100644 index 00000000..48633212 --- /dev/null +++ b/site/components/mdx/CodeBlock.tsx @@ -0,0 +1,187 @@ +import { useEffect, useRef, useState, type ComponentProps, type ReactNode, type RefObject } from 'react' +import { Icon } from '~/components/ui/Icon' +import { announce, copyText, 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.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 }: { target: RefObject<HTMLElement | null>; withLabel?: boolean }) { + const [copied, setCopied] = useState(false) + useEffect(() => { + if (!copied) return + const t = setTimeout(() => setCopied(false), 1800) + return () => clearTimeout(t) + }, [copied]) + return ( + <button + type="button" + className={`copy-button${withLabel ? ' has-label' : ''}${copied ? ' copied' : ''}`} + 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" /> + <Icon name="check" /> + {withLabel && <span className="copy-label">{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]) +} + +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={[className, title ? 'has-file' : ''].filter(Boolean).join(' ') || undefined} data-lang={label} data-label={title || label} aria-label={ariaLabel} data-region-label={ariaLabel}> + {children} + </pre> + ) + const panelClass = `code-panel${diagram ? ' is-diagram' : ''}` + if (cmd) { + return ( + <div className={`${panelClass} is-oneline is-cmd`}> + {pre} + <span className="code-lang" aria-hidden="true"> + {label} + </span> + <CopyButton target={ref} /> + </div> + ) + } + const path = title ? PATH.exec(title) : null + const kind = !title ? 'is-lang' : path ? '' : 'is-desc' + let name: ReactNode = title || label + if (title && path && path[0].length < title.length) + name = ( + <> + {path[0]} + <span className="cf-note">{title.slice(path[0].length)}</span> + </> + ) + const panel = ( + <div className={panelClass}> + <div className={`code-head${kind ? ` ${kind}` : ''}`}> + <Icon name={kind ? (label === 'Shell' ? 'terminal' : 'code') : 'file'} /> + <span className="code-file" aria-hidden="true"> + {name} + </span> + {title && ( + <span className="code-lang" aria-hidden="true"> + {label} + </span> + )} + <CopyButton target={ref} withLabel /> + </div> + {pre} + </div> + ) + if (!title) return panel + return ( + <figure className="code-example"> + <figcaption>{title}</figcaption> + {panel} + </figure> + ) +} diff --git a/site/components/mdx/Flow.tsx b/site/components/mdx/Flow.tsx new file mode 100644 index 00000000..01edb737 --- /dev/null +++ b/site/components/mdx/Flow.tsx @@ -0,0 +1,42 @@ +import { Children, Fragment, isValidElement, 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.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 className="flow" aria-label={label} style={nodes.length === 3 ? undefined : { gridTemplateColumns: cols }}> + {nodes.map((node, i) => ( + <Fragment key={i}> + {i > 0 && ( + <div className="flow-arrow" aria-hidden="true"> + → + </div> + )} + {node} + </Fragment> + ))} + </div> + ) +} + +export function FlowNode({ title, children }: { title: ReactNode; children?: ReactNode }) { + return ( + <div className="flow-node"> + <strong>{title}</strong> + {children != null && <small>{children}</small>} + </div> + ) +} diff --git a/site/components/mdx/Heading.tsx b/site/components/mdx/Heading.tsx new file mode 100644 index 00000000..81dd3c89 --- /dev/null +++ b/site/components/mdx/Heading.tsx @@ -0,0 +1,38 @@ +import type { ComponentProps } from 'react' +import { Icon } from '~/components/ui/Icon' + +/** + * 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. + */ +function anchor(id: string | undefined, label: string | undefined) { + if (!id) return null + return ( + <a className="heading-anchor" href={`#${id}`} aria-label={`Link to ${label ?? id}`}> + <Icon name="link" /> + </a> + ) +} + +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/site/components/mdx/Kbd.tsx b/site/components/mdx/Kbd.tsx new file mode 100644 index 00000000..186bdb23 --- /dev/null +++ b/site/components/mdx/Kbd.tsx @@ -0,0 +1,6 @@ +import type { ReactNode } from 'react' + +/** A key or shortcut: <Kbd>⌘K</Kbd>. */ +export function Kbd({ children }: { children: ReactNode }) { + return <kbd>{children}</kbd> +} diff --git a/site/components/mdx/MdxLink.tsx b/site/components/mdx/MdxLink.tsx new file mode 100644 index 00000000..e5607c66 --- /dev/null +++ b/site/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#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/site/components/mdx/README.md b/site/components/mdx/README.md new file mode 100644 index 00000000..0aa77c75 --- /dev/null +++ b/site/components/mdx/README.md @@ -0,0 +1,187 @@ +# 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#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`, +`#content--configuring-content` → `/next/content#configuring-content` (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. + +### Tables + +GFM tables render inside the scrolling `.table-wrap` (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.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 rule](/next/model#the-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. + +## 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/site/components/mdx/Small.tsx b/site/components/mdx/Small.tsx new file mode 100644 index 00000000..b3c6308c --- /dev/null +++ b/site/components/mdx/Small.tsx @@ -0,0 +1,20 @@ +import type { CSSProperties, ReactNode } from 'react' + +/** + * 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> + */ +export function Small({ children, style }: { children: ReactNode; style?: CSSProperties }) { + return ( + <p className="small muted" style={style}> + {children} + </p> + ) +} + +/** 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/site/components/mdx/Steps.tsx b/site/components/mdx/Steps.tsx new file mode 100644 index 00000000..29d7ceb9 --- /dev/null +++ b/site/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/site/components/mdx/Table.tsx b/site/components/mdx/Table.tsx new file mode 100644 index 00000000..9980220e --- /dev/null +++ b/site/components/mdx/Table.tsx @@ -0,0 +1,10 @@ +import type { ComponentProps } from 'react' + +/** Every Markdown/GFM table renders inside a horizontally scrolling, bordered wrapper. */ +export function Table(props: ComponentProps<'table'>) { + return ( + <div className="table-wrap"> + <table {...props} /> + </div> + ) +} diff --git a/site/components/mdx/Terms.tsx b/site/components/mdx/Terms.tsx new file mode 100644 index 00000000..a5986581 --- /dev/null +++ b/site/components/mdx/Terms.tsx @@ -0,0 +1,22 @@ +import type { ReactNode } from 'react' + +/** + * A glossary: bold terms with their definitions (a <dl class="terms">). + * + * <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="terms">{children}</dl> +} + +export function Term({ name, children }: { name: ReactNode; children: ReactNode }) { + return ( + <> + <dt>{name}</dt> + <dd>{children}</dd> + </> + ) +} diff --git a/site/components/mdx/TocInline.tsx b/site/components/mdx/TocInline.tsx new file mode 100644 index 00000000..3f3e2b97 --- /dev/null +++ b/site/components/mdx/TocInline.tsx @@ -0,0 +1,9 @@ +import { TocInlineView, useRailItems } from '~/src/layout/Rail' + +/** + * 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()} /> +} diff --git a/site/components/mdx/index.tsx b/site/components/mdx/index.tsx new file mode 100644 index 00000000..20b559da --- /dev/null +++ b/site/components/mdx/index.tsx @@ -0,0 +1,49 @@ +/** + * 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 { 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, 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, + Flow, + FlowNode, + Terms, + Term, + Steps, + Step, + Kbd, + Small, + Eyebrow, + TocInline, + ...(widgets as MDXComponents), +} diff --git a/site/components/roadmap/RoadmapPage.tsx b/site/components/roadmap/RoadmapPage.tsx new file mode 100644 index 00000000..3ec85327 --- /dev/null +++ b/site/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/site/components/roadmap/SectionViews.tsx b/site/components/roadmap/SectionViews.tsx new file mode 100644 index 00000000..ff2b48c2 --- /dev/null +++ b/site/components/roadmap/SectionViews.tsx @@ -0,0 +1,775 @@ +/** + * 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). roadmap.css styles them as before. + * + * 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 CSSProperties, type ReactNode } from 'react' +import { Icon } from '~/components/ui/Icon' +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' + +// ------------------------------------------------------------------------------ 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') + +/** The hover permalink inside a heading (same markup as the docs' headings). */ +function Anchor({ id, label }: { id: string; label: string }) { + return ( + <a className="heading-anchor" href={`#${id}`} aria-label={`Link to ${label}`}> + <Icon name="link" /> + </a> + ) +} + +/** A plain sub-heading (h2) with its permalink; text only. */ +function SubHeading({ id, children }: { id: string; children: string }) { + return ( + <h2 id={id} aria-label={children}> + {children} + <Anchor id={id} label={children} /> + </h2> + ) +} + +/** A to-do's heading: (number,) title. The unchecked box is CSS (decorative). */ +function TodoHeading({ id, title, n }: { id: string; title: string; n?: number }) { + const label = (n ? `${pad(n)} ` : '') + plain(title) + return ( + <h2 id={id} aria-label={label}> + {n ? ( + <> + <span className="gx-n">{pad(n)}</span>{' '} + </> + ) : null} + <Html html={title} /> + <Anchor id={id} label={label} /> + </h2> + ) +} +/** The rail entry for a to-do heading (its markup minus the permalink). */ +const todoRail = (id: string, title: string, n?: number): RailItem => ({ + id, + html: (n ? `<span class="gx-n">${pad(n)}</span> ` : '') + `<span>${title}</span>`, + depth: 2, +}) + +/** + * One to-do: the screen-reader "To do" (the box is drawn by CSS), its heading, then its parts. With + * `note`, a note with nothing to do: no box, "Note" to screen readers. + */ +function Todo({ note, heading, children }: { note?: boolean; heading: ReactNode; children: ReactNode }) { + return ( + <li className={note ? 'rm-note' : undefined}> + <span className="rm-sr" data-pagefind-ignore=""> + {note ? 'Note' : 'To do'} + </span> + {heading} + {children} + </li> + ) +} + +/** + * Today / Plan (and link rows) as a description list. The Today row sits on the plain surface and + * the rows after it on a fill; link-run rows (any label but Today and Plan) mark their dd. + */ +function Detail({ rows }: { rows: [label: string, body: string | ReactNode][] }) { + return ( + <dl className="gx-wf"> + {rows.map(([lab, body], i) => { + const run = lab !== 'Today' && lab !== 'Plan' + // Search leaves out the labels and the link runs after the Plan (as the old index did). + const skip = i > 0 && rows[i - 1][0] !== 'Today' + return ( + <div key={lab} className={lab === 'Today' ? 'rm-t' : undefined} data-pagefind-ignore={skip ? '' : undefined}> + <dt data-pagefind-ignore="">{lab}</dt> + {typeof body === 'string' ? <Html as="dd" className={run ? 'rm-links' : undefined} html={body} /> : <dd className={run ? 'rm-links' : undefined}>{body}</dd>} + </div> + ) + })} + </dl> + ) +} + +/** 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" data-pagefind-ignore=""> + {ids.map((id) => { + const f = rm.FEATS[id] + return ( + <a key={id} href={rm.hrefOf(`roadmap-f-${id}`)} data-v={VC[f.status]}> + <span className="sr-only">{rm.VLABEL(f.status)}: </span> + {f.name} + </a> + ) + })} + </div> + ) +} + +function SiteTags({ rm, repos }: { rm: Roadmap; repos: string[] }) { + return repos.length ? <span className="gx-st">{repos.map((r) => rm.SHORT[r]).join(' · ')}</span> : null +} + +/** A link to a to-do, named by its title. */ +const ItemLink = ({ rm, id }: { rm: Roadmap; id: string }) => <Html as="a" href={rm.hrefOf(id)} 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 className="rm-sref"> + <ItemLink rm={rm} id={id} />{' '} + <span className="rm-ref"> + ({rm.SHORT[s.site]} step {s.n}) + </span> + </span> + ) +} + +/** 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="gx-lg" data-v={VC[v]}> + <b>{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. */ +function BarRow({ rm, label, labelText, ids, descHtml }: { rm: Roadmap; label: ReactNode; labelText: string; ids: string[]; descHtml?: string }) { + const cs = rm.counts(ids).filter(([, n]) => n) + const aria = cs.map(([v, n]) => `${n} ${rm.vword(v, n)}`).join(', ') + return ( + <div className="gx-barrow"> + <p className="gx-bar-h" data-pagefind-ignore=""> + {label} + <span>{ids.length} features</span> + </p> + <div className="gx-bar" role="img" aria-label={`${labelText}: ${aria}`} data-pagefind-ignore=""> + {cs.map(([v, n]) => ( + <i key={v} data-v={VC[v]} style={{ '--n': n } as CSSProperties} /> + ))} + </div> + <p className="gx-bar-l" data-pagefind-ignore=""> + <Legend rm={rm} ids={ids} /> + <span className="gx-ow" 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="gx-bar-d" html={descHtml} /> : null} + </div> + ) +} + +/** A status pill. */ +const Pill = ({ rm, v, own = true }: { rm: Roadmap; v: string; own?: boolean }) => ( + <b className="gx-vp" data-v={own ? VC[v] : undefined}> + {rm.VLABEL(v)} + </b> +) + +/** 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="gx-key"> + {rm.VERDICTS.filter((v) => rm.TOTALS[v]).map((v) => ( + <span key={v} className="gx-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. */ +function Article({ rm, id, cls, count, lede, children }: { rm: Roadmap; id: SectionId; cls?: string; count?: string; lede: ReactNode; children: ReactNode }) { + const s = rm.SEC[id] + return ( + <article id={id} className={`doc-section doc-page gx-sec${cls ? ` ${cls}` : ''}`} aria-labelledby={`${id}-title`} data-pagefind-body=""> + <p className="eyebrow"> + {s.eyebrow} + {count ? ( + <span className="rm-count" 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 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="gx-now rm-do"> + <Html as="p" html={`<span class="rm-sr" data-pagefind-ignore="">To do: </span>${rm.OV.fix_now}`} /> + </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> + <ol className="gx-toplist"> + {rm.TOP.map((g) => ( + <li key={g.id}> + <span className="rm-sr" data-pagefind-ignore=""> + To do:{' '} + </span> + <ItemLink rm={rm} id={g.id} /> + <span className="gx-tl-s" 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} /> + <ul className="gx-tiles" aria-label="Features by status"> + {rm.VERDICTS.map((v) => ( + <li key={v} data-v={VC[v]}> + <span className="gx-tile-n">{T[v]}</span> + <Pill rm={rm} v={v} own={false} /> + <span className="gx-tile-d">{rm.status(v).tile}</span> + </li> + ))} + </ul> + <div className="gx-bars"> + <BarRow rm={rm} 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="gx-note"> + 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="callout warning rm-do"> + <Html as="p" html={`<span class="rm-sr" data-pagefind-ignore="">To do: </span>${rm.OV.fix_docs}`} /> + </div> + <SubHeading id="roadmap--using-this-page">Using this page</SubHeading> + <ul className="gx-map"> + <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')}>Studio 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>: {rm.CHANGES.length} changes, deduplicated from the {n} per-feature proposals, in four groups: the API, the CLI, Studio and the Deco + API, and these docs. + </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 Studio 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> + <p className="small muted"> + 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 Studio'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. + </p> + </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" + cls="gx-items" + 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="rm-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="gx-meta" data-pagefind-ignore=""> + <SiteTags rm={rm} repos={rm.sitesOf(g.features)} /> + </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="rm-dnote" 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 Studio. 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 Studio 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} cls="gx-items" count={`${items.length} to do`} lede={<p>{STUDIO_LEDE[id]}</p>}> + <ul className="rm-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 bindings 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 Studio 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. + </> + ), + }[key] + const article = ( + <Article + rm={rm} + id={id} + cls="gx-items" + count={`${items.length} to do`} + lede={ + <p> + {lede} A chip's dot shows that feature's status: <Key rm={rm} />. + </p> + } + > + <ul className="rm-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="gx-meta" data-pagefind-ignore=""> + <span className="gx-meta-n">{nf}</span> + <SiteTags rm={rm} repos={rm.sitesOf(ids)} /> + </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} cls="gx-items gx-plan" count={count} lede={<Html as="p" html={rm.HEADLINE[r]} />}> + <Html as="p" className="gx-sitedesc" html={`<span class="gx-meta-k" data-pagefind-ignore="">The site</span>${rm.REPO_DESC[r]}`} /> + <div className="gx-bars"> + <BarRow rm={rm} 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="rm-list"> + {steps.map((s, i) => ( + <Todo key={s.id} note={s.no_action} heading={<TodoHeading id={s.id} title={s.title} n={i + 1} />}> + <p className="gx-meta" data-pagefind-ignore=""> + <span className="gx-kind" data-k={s.kind}> + {KIND[s.kind]} + </span> + </p> + <Html as="p" 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 })), + ] +} + +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="gx-tg">Medium confidence</i> : null} + {f.first_rated ? ( + <i className="gx-tg" 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="gx-tg" title="The status a later review changed"> + Rated {rm.vq(f.rated_before_review)} before review + </i> + ) : null} + {f.unconfirmed_sub_claim ? ( + <i className="gx-tg" title="Contains a sub-claim the verifier couldn't confirm"> + Unconfirmed sub-claim + </i> + ) : null} + </> + ) +} + +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" + 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>{n}</span> + </> + )} + </button> + ) + } + const done = T.done + const gone = T['goes-away'] + const article = ( + <Article + rm={rm} + id="roadmap-features" + cls="gx-index" + 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> + } + > + <dl className="rm-legend" aria-label="Statuses"> + {rm.VERDICTS.map((v) => ( + <div key={v}> + <dt> + <b className="gx-lg" data-v={VC[v]}> + {rm.VLABEL(v)} + </b> + </dt> + <Html as="dd" html={rm.status(v).legend} /> + </div> + ))} + </dl> + {/* Without JavaScript the filter does nothing, so roadmap.css hides it unless <html> has the + `js` class (set by the inline head script before first paint, so it never shifts the list). */} + <div className="gx-filter" data-pagefind-ignore=""> + <div className="gx-fg" role="group" aria-label="Filter by status"> + <span className="gx-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]} /> + {rm.VLABEL(v)} + </>, + ), + )} + </div> + <div className="gx-fg" role="group" aria-label="Filter by site"> + <span className="gx-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="gx-fcount" 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}> + <h2 id={hid} aria-label={cat.title} hidden={off}> + {cat.title} + <Anchor id={hid} label={cat.title} /> + </h2> + <p className="gx-catd" hidden={off}> + {ids.length} feature{ids.length !== 1 ? 's' : ''} · {cat.description} + <span className="gx-catc" data-pagefind-ignore=""> + <Legend rm={rm} ids={ids} /> + </span> + </p> + <ul className="gx-fx" 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)}> + <h3>{f.name}</h3> + <p className="gx-fx-m" data-pagefind-ignore=""> + <Pill rm={rm} v={f.status} own={false} /> + <span>Effort {f.effort}</span> + <SiteTags rm={rm} repos={f.sites} /> + <RowTags rm={rm} fid={fid} /> + </p> + <div> + <Html as="p" html={f.summaryHtml} /> + {f.docLinks.length ? ( + <p className="gx-fx-d" data-pagefind-ignore=""> + {join(f.docLinks.map((l) => <a href={l.href}>{l.label}</a>))} + </p> + ) : null} + {back ? ( + <p className="gx-fx-c" data-pagefind-ignore=""> + {back.map((b) => ( + <Fragment key={b.id}> + {b.n ? ( + <a href={rm.hrefOf(b.id)} title={plain(b.title)}> + Blocker {pad(b.n)} + </a> + ) : ( + <Html as="a" href={rm.hrefOf(b.id)} 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-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/site/components/roadmap/index.tsx b/site/components/roadmap/index.tsx new file mode 100644 index 00000000..cdec428b --- /dev/null +++ b/site/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/site/components/roadmap/instance.ts b/site/components/roadmap/instance.ts new file mode 100644 index 00000000..60a0c66a --- /dev/null +++ b/site/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/site/components/roadmap/model.ts b/site/components/roadmap/model.ts new file mode 100644 index 00000000..a563c5d5 --- /dev/null +++ b/site/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` -> /next/studio, `#releases--publishing` -> + * /next/releases#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' + 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. */ +export const GROUP_SEC = { api: 'roadmap-api', cli: 'roadmap-cli', studio: 'roadmap-platform', docs: 'roadmap-docs' } as const +export type WorkGroup = keyof typeof GROUP_SEC +/** Status id -> the one-letter hook the styles colour by (data-v="…" in roadmap.css). */ +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 in legacy.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` -> 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'] 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/site/src/roadmap.css b/site/components/roadmap/roadmap.css similarity index 90% rename from site/src/roadmap.css rename to site/components/roadmap/roadmap.css index 96378ee8..abfb1198 100644 --- a/site/src/roadmap.css +++ b/site/components/roadmap/roadmap.css @@ -1,11 +1,19 @@ /* ========================================================================== - Roadmap page (the next major's to-do list, rendered from data/roadmap.json by gen_roadmap.py). + Roadmap pages (components/roadmap/, data from data/roadmap.json). Ported from the former + Python build's roadmap.css (removed); the selectors follow the new heading levels: the page title is the h1, + sub-headings and to-dos are h2 (were h3), feature rows are h3 (were h4). + + Everything is in the `legacy` cascade layer, like the rest of the ported CSS (src/styles/app.css), + so it competes with legacy.css and prose.css by specificity and order exactly as the old + roadmap.css competed with style.css. + Classes are gx-* (and rm-* for the to-do parts); statuses are data-v codes: g to build · p to finish · a site code · c done · n goes away. Done is the only status in lime/forest, and nothing else on this page uses those colours: plan rows, the filter's pressed state and the target flash are neutral. Every to-do is open, so every box is drawn unchecked. ========================================================================== */ +@layer legacy { :root { --gx-g: #D9432F; --gx-g-bg: #FDECE8; --gx-g-fg: #9E2B18; --gx-p: #E0A106; --gx-p-bg: #FDF3D6; --gx-p-fg: #7A5200; @@ -81,8 +89,8 @@ /* Numbered headings: the number is a quiet tabular index, in the article and in the rail. In the article the number and the title sit side by side, so a wrapped title hangs under itself. */ .gx-n { display: inline-block; min-width: 1.6em; margin-right: .35em; font-size: .56em; line-height: 1; vertical-align: .22em; font-variant-numeric: tabular-nums; letter-spacing: 0; color: var(--eyebrow); } -.gx-sec h3:has(> .gx-n) { display: flex; align-items: baseline; } -.gx-sec h3 > .gx-n { flex: none; margin-right: .6em; position: relative; top: -.39em; } +.gx-sec h2:has(> .gx-n) { display: flex; align-items: baseline; } +.gx-sec h2 > .gx-n { flex: none; margin-right: .6em; position: relative; top: -.39em; } #rail-list .gx-n, .toc-inline .gx-n { min-width: 22px; margin: 0; font-size: 11.5px; vertical-align: 0; text-indent: 0; color: var(--quiet); } #rail-list a:has(> .gx-n) { padding-left: 38px; text-indent: -22px; } .doc-section .toc-inline li a:has(> .gx-n) { padding-left: 22px; text-indent: -22px; } @@ -91,7 +99,7 @@ "To do" to screen readers. The same box marks the overview's blocker list, its two to-do callouts, and (small) the count in each section's eyebrow. */ .rm-sr { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } -.gx-sec .rm-list > li > h3::before, .gx-sec ol.gx-toplist > li::after, .gx-sec .rm-do::before, .rm-count::before { +.gx-sec .rm-list > li > h2::before, .gx-sec ol.gx-toplist > li::after, .gx-sec .rm-do::before, .rm-count::before { content: ""; box-sizing: border-box; width: 18px; height: 18px; flex: none; border: 1.5px solid var(--quiet); border-radius: 5px; background: var(--surface); -webkit-mask: none; mask: none; @@ -102,10 +110,10 @@ .gx-sec .rm-list > li { position: relative; margin: 48px 0 0; padding: 30px 0 0 34px; border-top: 1px solid var(--hairline); } .gx-sec .rm-list > li::before { content: none; } .gx-sec ul.rm-list > li::marker { content: none; } -.gx-sec .rm-list > li > h3 { display: flex; align-items: baseline; margin: 0 0 12px -34px; font-size: 23px; line-height: 1.28; } -.gx-sec .rm-list > li > h3 > span:not(.gx-n) { min-width: 0; } -.gx-sec .rm-list > li > h3::before { align-self: flex-start; margin: calc(.5lh - 9px) 16px 0 0; } -.gx-sec .rm-list > li.rm-note > h3::before { border: 0; border-radius: 0; background: linear-gradient(var(--quiet), var(--quiet)) center / 10px 1.5px no-repeat; } +.gx-sec .rm-list > li > h2 { display: flex; align-items: baseline; margin: 0 0 12px -34px; font-size: 23px; line-height: 1.28; } +.gx-sec .rm-list > li > h2 > span:not(.gx-n) { min-width: 0; } +.gx-sec .rm-list > li > h2::before { align-self: flex-start; margin: calc(.5lh - 9px) 16px 0 0; } +.gx-sec .rm-list > li.rm-note > h2::before { border: 0; border-radius: 0; background: linear-gradient(var(--quiet), var(--quiet)) center / 10px 1.5px no-repeat; } /* Each section's count sits beside its eyebrow. */ .gx-sec > .eyebrow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; } .rm-count { @@ -230,6 +238,7 @@ .rm-dnote { display: block; margin-top: 4px; font-size: 14px; color: var(--muted-fg); } /* Feature index: filter */ +html:not(.js) .gx-filter { display: none; } .gx-filter { display: flex; flex-direction: column; gap: 10px; margin: 28px 0 8px; padding: 14px; border: 1px solid var(--border); border-radius: 16px; background: var(--bg-subtle); } .gx-fg { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; } .gx-fg + .gx-fg { padding-top: 10px; border-top: 1px solid var(--hairline); } @@ -249,8 +258,8 @@ .gx-sec .gx-fcount { margin: 0; font-size: 13px; line-height: 20px; color: var(--muted-fg); } /* Feature index: categories and rows */ -.gx-index h3 { font-size: 25px; margin-top: 56px; } -.gx-sec .gx-catd { margin: 0 0 16px; font-size: 14px; line-height: 22px; color: var(--muted-fg); } +.doc-section.doc-page.gx-index h2 { font-size: 25px; margin-top: 56px; } +.gx-sec .gx-catd, .doc-section.gx-sec > h2 + p.gx-catd { margin: 0 0 16px; font-size: 14px; line-height: 22px; color: var(--muted-fg); } .gx-catc { display: flex; flex-wrap: wrap; gap: 2px 14px; margin-top: 6px; font-size: 12.5px; } /* ul. outranks the list reset above (margin-left: 0), so rows bleed evenly and their text lines up with the column. */ .gx-sec ul.gx-fx { margin: 0 -12px; border-top: 1px solid var(--hairline); } @@ -259,7 +268,7 @@ border-bottom: 1px solid var(--hairline); color: var(--fg-body); transition: background-color .3s ease; } .gx-sec .gx-fx > li + li { margin-top: 0; } -.gx-sec .gx-fx h4 { margin: 0; font-size: 16px; line-height: 23px; font-weight: 500; letter-spacing: -.01em; color: var(--fg); } +.gx-sec .gx-fx > li > h3 { margin: 0; text-wrap: wrap; font-size: 16px; line-height: 23px; font-weight: 500; letter-spacing: -.01em; color: var(--fg); } .gx-sec .gx-fx p { margin: 0; max-width: none; } .gx-sec .gx-fx .gx-fx-m { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 12.5px; line-height: 20px; color: var(--muted-fg); } .gx-tg { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--border-strong); font-size: 11.5px; font-style: normal; line-height: 16px; color: var(--muted-fg); white-space: nowrap; } @@ -267,27 +276,27 @@ .gx-sec .gx-fx > li > div > p:first-child code { font-size: 12.5px; } /* Where these docs cover the feature, and what addresses it; the labels are CSS so each row stays small. */ .gx-sec .gx-fx :is(.gx-fx-d, .gx-fx-c) { margin-top: 8px; font-size: 13px; line-height: 21px; color: var(--muted-fg); } -.gx-sec .gx-fx :is(.gx-fx-d, .gx-fx-c) a:not(.heading-anchor) { font-size: 13px; } +.gx-sec .gx-fx :is(.gx-fx-d, .gx-fx-c) a:not(.heading-anchor) { font-size: 13px; padding-block: 4px; } /* inline padding: a 24px target (WCAG 2.5.8) without moving the lines */ .gx-fx-d::before, .gx-fx-c::before { content: "In these docs"; margin-right: 10px; font-size: 11.5px; letter-spacing: .04em; text-transform: uppercase; color: var(--eyebrow); } .gx-fx-c::before { content: "Addressed by"; } .gx-sec .gx-fx .gx-fx-c { margin-top: 2px; } @container (min-width: 640px) { .gx-sec .gx-fx > li { grid-template-columns: minmax(0, 14.5rem) minmax(0, 1fr); grid-template-rows: auto 1fr; grid-template-areas: "n s" "m s"; column-gap: 28px; } - .gx-sec .gx-fx h4 { grid-area: n; } + .gx-sec .gx-fx > li > h3 { grid-area: n; } .gx-sec .gx-fx .gx-fx-m { grid-area: m; align-self: start; } .gx-sec .gx-fx > li > div { grid-area: s; align-self: start; } } @media (max-width: 767px) { .gx-sec .rm-list > li { margin-top: 40px; padding: 26px 0 0 30px; } - .gx-sec .rm-list > li > h3 { margin-left: -30px; font-size: 21px; } - .gx-sec .rm-list > li > h3::before { margin-right: 12px; } + .gx-sec .rm-list > li > h2 { margin-left: -30px; font-size: 21px; } + .gx-sec .rm-list > li > h2::before { margin-right: 12px; } } @media (max-width: 559px) { /* The callouts' phone padding (style.css), for both to-do callouts and their boxes. */ .gx-sec .gx-now { padding: 14px 16px 14px 44px; } .gx-sec .rm-do::before { left: 15px; top: 17px; } - .gx-index h3 { font-size: 22px; margin-top: 44px; } + .doc-section.doc-page.gx-index h2 { font-size: 22px; margin-top: 44px; } .gx-sec ul.gx-fx { margin: 0 -8px; } .gx-sec .gx-fx > li { padding: 16px 8px 18px; } .gx-filter { padding: 12px; } @@ -306,6 +315,14 @@ } .gx-filter { display: none !important; } .gx-fx > li, .gx-wf, .gx-tiles, .gx-barrow, .gx-now { break-inside: avoid; } - .gx-sec .rm-list > li > h3 { break-after: avoid; } + .gx-sec .rm-list > li > h2 { break-after: avoid; } .gx-vp, .gx-kind, .gx-bar i, .gx-wf, .gx-wf > div { -webkit-print-color-adjust: exact; print-color-adjust: exact; } } + +/* Whatever the feature filter hides stays hidden, whatever display a row's own styles set, and a + feature row reached by a deep link flashes once so the eye finds it in the list. The flash is + neutral, not the lime tint: on this page lime/forest means "Done". */ +.gx-sec [hidden] { display: none !important; } +:where(main [id^="roadmap-f-"]):target { animation: roadmap-target 2.4s var(--ease); } +@keyframes roadmap-target { from, 35% { background-color: var(--hover); } } +} diff --git a/site/components/roadmap/sections.ts b/site/components/roadmap/sections.ts new file mode 100644 index 00000000..eae58f76 --- /dev/null +++ b/site/components/roadmap/sections.ts @@ -0,0 +1,134 @@ +/** + * 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-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: 'Studio support', ids: ['roadmap-studio-new', 'roadmap-studio-legacy'] }, + { title: 'Work items', ids: ['roadmap-api', 'roadmap-cli', 'roadmap-platform', 'roadmap-docs'] }, + { 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': 'Studio and Deco API', + 'roadmap-docs': 'Docs fixes', + 'roadmap-storefront': 'TanStack storefront', + 'roadmap-blog': 'TanStack blog', + 'roadmap-faststore': 'Next.js storefront', + 'roadmap-features': 'Every feature', +} + +/** "Release blockers — Deco Blocks"; the overview is "Roadmap — Deco Blocks". */ +export function roadmapDocumentTitle(id: SectionId): string { + const nav = SECTION_NAV[id] + return `${nav === 'Overview' ? 'Roadmap' : nav} — Deco Blocks` +} + +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" -> "/next/studio", + * "releases--publishing" -> "/next/releases#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/site/components/search/index.tsx b/site/components/search/index.tsx new file mode 100644 index 00000000..4d37f065 --- /dev/null +++ b/site/components/search/index.tsx @@ -0,0 +1,373 @@ +/** + * ⌘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). + * + * Markup and classes are the old site's (#search-dialog, .search-head, .search-results, .sr-*). + */ +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 +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 +const SUGGESTIONS = ['quickstart', 'model', 'routing', 'preview', 'standalone', 'nextjs', '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}>{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} + 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="search-head"> + <Icon name="search" /> + <input + ref={inputRef} + id="search-input" + 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="search-close" id="search-close" type="button" aria-label="Esc: close search" onClick={close}> + Esc + </button> + </div> + <div className="search-results" id="search-results" role="listbox" aria-label="Search results" ref={resultsRef}> + {!words.length && rows.length > 0 && ( + <p className="search-label" aria-hidden="true"> + Suggested + </p> + )} + {words.length > 0 && status.kind === 'results' && ( + <p className="search-label" aria-hidden="true"> + {status.label} + </p> + )} + {words.length > 0 && status.kind === 'loading' && ( + <p className="search-empty" aria-hidden="true"> + Loading the search index… + </p> + )} + {words.length > 0 && status.kind === 'empty' && ( + <p className="search-empty" aria-hidden="true"> + No matching sections. Try “schema”, “rollback”, or “TanStack”. + </p> + )} + {words.length > 0 && status.kind === 'unavailable' && ( + <p className="search-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={on ? 'is-active' : undefined} + 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="sr-icon"> + <Icon name={row.kind === 'heading' ? 'hash' : 'file'} /> + </span> + <span className="sr-body"> + <span className="sr-path">{row.path}</span> + <span className="sr-title"> + <Highlight text={row.title} words={words} /> + </span> + {words.length > 0 && row.excerpt && ( + <span className="sr-snip"> + {excerptParts(row.excerpt).map((p, j) => (p.mark ? <mark key={j}>{p.text}</mark> : p.text))} + </span> + )} + </span> + <span className="sr-enter"> + <Icon name="corner" /> + </span> + </a> + ) + })} + </div> + <p className="sr-only" id="search-status" role="status"> + {statusText} + </p> + <div className="search-hint"> + <span> + <kbd>↑</kbd> + <kbd>↓</kbd> to move + </span> + <span> + <kbd>↵</kbd> to open + </span> + <span className="sh-note">Searches all documentation, including code examples.</span> + </div> + </dialog> + </> + ) +} diff --git a/site/components/search/pagefind.ts b/site/components/search/pagefind.ts new file mode 100644 index 00000000..97669f21 --- /dev/null +++ b/site/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/site/components/ui/Brand.tsx b/site/components/ui/Brand.tsx new file mode 100644 index 00000000..739cf3d7 --- /dev/null +++ b/site/components/ui/Brand.tsx @@ -0,0 +1,36 @@ +/** + * 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 (CSS shows one per theme) + * <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 + */ +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 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'), +} + +// `display: contents` keeps the wrapper out of layout, so the SVGs behave as direct children. +export const Wordmark = () => <span style={{ display: 'contents' }} dangerouslySetInnerHTML={{ __html: html.wordmark }} /> +export const WordmarkLime = () => <span style={{ display: 'contents' }} dangerouslySetInnerHTML={{ __html: html.wordmarkLime }} /> +export const BrandSymbol = () => <span style={{ display: 'contents' }} dangerouslySetInnerHTML={{ __html: html.symbol }} /> + +/** The raw strings, for code that needs markup rather than elements. */ +export const brandHtml = html diff --git a/site/components/ui/Icon.tsx b/site/components/ui/Icon.tsx new file mode 100644 index 00000000..a952fdec --- /dev/null +++ b/site/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/site/components/widgets/Walkthrough.tsx b/site/components/widgets/Walkthrough.tsx new file mode 100644 index 00000000..a545315a --- /dev/null +++ b/site/components/widgets/Walkthrough.tsx @@ -0,0 +1,154 @@ +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; markup and classes (.explorer, .trace-*) unchanged. + */ + +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); `.explorer pre::after` 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}> + {children} + </pre> + ) +} + +function TraceCodeEl(props: ComponentProps<'code'>) { + return <code {...props} id="trace-code" /> +} + +const codeComponents = { TraceCode, pre: TracePre, code: TraceCodeEl } + +function Toggle({ pressed, onClick, children }: { pressed: boolean; onClick: () => void; children: ReactNode }) { + return ( + <button type="button" aria-pressed={pressed} onClick={onClick}> + {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="explorer"> + <div className="explorer-top"> + <strong>SummerCard · one saved entry</strong> + <div className="segmented" 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="explorer-top"> + <span className="small muted">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="explorer-body"> + <div className="trace-steps" 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)}> + {label} + </button> + ))} + </div> + <ActiveState.Provider value={codeStateFor(step, mode)}> + <TraceCodeBlocks components={codeComponents} /> + </ActiveState.Provider> + <p className="trace-caption" id="trace-caption" aria-live="polite"> + {CAPTIONS[operation][step]} + </p> + <div className="explorer-footer"> + <span className="trace-stat" 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="primary-button" + id="trace-next" + aria-disabled={step === last} + onClick={() => step < last && setStep(step + 1)} + > + Next step → + </button> + </div> + </div> + </div> + ) +} diff --git a/site/components/widgets/index.tsx b/site/components/widgets/index.tsx new file mode 100644 index 00000000..77903ba8 --- /dev/null +++ b/site/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/site/components/widgets/walkthrough-code.mdx b/site/components/widgets/walkthrough-code.mdx new file mode 100644 index 00000000..89c0b8f1 --- /dev/null +++ b/site/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/site/content/next/adoption.mdx b/site/content/next/adoption.mdx new file mode 100644 index 00000000..1144182e --- /dev/null +++ b/site/content/next/adoption.mdx @@ -0,0 +1,54 @@ +--- +title: Renaming and migrating +nav: Renames & migrations +group: Production +order: 18 +--- + +# Renaming and migrating + +Saved content refers to your code by name: every block stores a type name in `__resolveType`, references store entry names, and inputs are stored by field name. Treat these names like database columns: once content uses them, changing one breaks that content. Renaming a type or removing an input affects every block that uses it, and renaming a saved entry breaks every reference to it (`NOT_FOUND`). + +This page covers renaming safely, and bringing in content from a site built on Deco's earlier framework, where type names were file paths such as `site/sections/Product.tsx`. Moving source files is free; only names matter. + +## Rename a type with an alias + +An alias is another key pointing at the same thing: the same function in the block map, or the same type in [`Content`](/next/schema). Content that stores the old name keeps working, and routing is unaffected because `matchRoute` looks at each entry's `path` field (see [Routes and redirects](/next/content-types)), not its type name. Here, [`Page`](/next/content-types) is the built-in page type from `@decocms/blocks`; you declare only the legacy name, since `page` itself is built in: + +```ts title="blocks.ts" +import type { Blocks, Page } from "@decocms/blocks"; +import productCard from "./product-card"; + +export default { + "product-card": productCard, + "site/sections/Product.tsx": productCard, // the name legacy content stores +} satisfies Blocks; + +export type Content = { + "website/pages/Page.tsx": Page; // the built-in page type, legacy name; entries still route +}; +``` + +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 schema` rejects an alias that collides with an entry name. + +## 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. +2. **Check inputs.** Regenerate the schema and validate saved content. Review new required fields, narrowed unions, and changed defaults. +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 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/site/content/next/architecture.mdx b/site/content/next/architecture.mdx new file mode 100644 index 00000000..03e2ea27 --- /dev/null +++ b/site/content/next/architecture.mdx @@ -0,0 +1,74 @@ +--- +title: How it works +group: Getting started +order: 1 +--- + +# How it works + +<Callout>**Status.** Deco Blocks 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> + +Deco Blocks lets the people who run a site change what your code does without changing the code. You write ordinary TypeScript functions. The values they're called with, such as a headline, a banner image or the share of visitors who see a new checkout, live as JSON files in your repository. Editors change those files in a visual editor, AI agents edit them directly, and your app reads them at runtime and calls your functions with them. + +Deco Blocks has two pieces you install, and one you don't: + +| Piece | What it does | Where it runs | +|---|---|---| +| **SDK** `@decocms/blocks` | Reads your content (a module the CLI generates from files in your repo, the hosted Deco CMS, or your own store) and calls your functions with it. | Your app, in any JavaScript runtime: Node, Cloudflare Workers, Deno, Bun, React Native or the browser | +| **CLI** `@decocms/blocks-cli` | Provides the `deco` command. `deco schema` generates a JSON Schema from your types (and, in CI, checks a change against the previous one), and `deco content` turns your content files into a module your app imports. | Your machine or CI | +| **Studio** | Builds a visual editor from that schema and saves edits back to your files. | Part of the hosted Deco CMS (optional) | + +The SDK and CLI are open source and run without a Deco account. The **hosted Deco CMS** is Deco's optional service. It adds **Deco Studio**, the visual editor, and the **Deco API**, the HTTP service that delivers published content and draft previews to your app without a redeploy, using your site's token. + +## Key terms + +These terms come up on every page. Each is explained again where it's first used. + +<Terms> + <Term name="Block function">One of your functions with a typed first parameter, registered in a block map. It can return anything: data, a boolean, a descriptor or JSX. The framework treats them all the same. See [Quickstart](/next/quickstart).</Term> + <Term name="Block map">A plain object of your block functions, such as `{ experiments }`. See [Quickstart](/next/quickstart).</Term> + <Term name="Block type">A key in the block map: the name content uses to call that function.</Term> + <Term name="Block">Either a function (code) or saved content (JSON), named by `__resolveType`. A function is a key in your block map and ships with each deploy. Saved content is a saved entry: it's versioned, can have drafts, and publishes without a deploy. The JSON that calls a block, `{ "__resolveType": …, …inputs }`, is called a block too. See [Blocks](/next/model).</Term> + <Term name="Saved entry">Saved content: a block saved under a name, one JSON file in `.deco/blocks`, whose file name is the entry name. See [Content and loaders](/next/content).</Term> + <Term name="Content module">`.deco/blocks.gen.ts`, the file `deco content` generates from `.deco/blocks`. It imports each JSON file and exports a snapshot, `{ revision, blocks }`: one fixed copy of all your saved entries. See [Content and loaders](/next/content).</Term> + <Term name="Content map">All saved entries, keyed by name.</Term> + <Term name="Page">A built-in content type: a saved entry with a path, SEO and an ordered list of blocks (its `sections` field) that editors add, remove and reorder. See [Routes and redirects](/next/content-types).</Term> + <Term name="Loader">Any object whose `load()` returns `{ revision, blocks }`. Pass one as `createCMS`'s `content` to read content from a key-value store, a database or your API. See [Content and loaders](/next/content).</Term> + <Term name="Revision">The ID of one exact content map, released or not, usually a hash of its contents. Change any entry and you get a new revision.</Term> + <Term name="Release">A published content map. With the hosted Deco CMS, each commit to the production branch becomes one. A release never changes, and its revision identifies it. See [Releases and deployment](/next/releases).</Term> + <Term name="Draft">Content changed in Studio but not yet published, kept on a branch. See [Preview](/next/preview).</Term> + <Term name="Client">The object you read content with, one per request: `cms.forRelease()` for visitors, `cms.forDraft(pointer)` for a preview. It has two methods, `list` and `resolve`. See [Quickstart](/next/quickstart).</Term> + <Term name="Descriptor">A plain, serializable object such as `{ component, props }` that names a view and its props for your front end to render. See [Rendering](/next/rendering).</Term> +</Terms> + +## Authoring: from types to an editor + +<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.json`</FlowNode> + <FlowNode title="Studio">Forms and previews for editors, built from the schema</FlowNode> +</Flow> + +You write functions with typed inputs, for example a hero block that takes a title and an image. The CLI turns those types into a schema, and Studio turns the schema into forms. Editors, and AI agents, save content as JSON files in `.deco/blocks`, in your repository. + +## Delivery: from files to your app + +<Flow label="Delivery flow"> + <FlowNode title="Content">The content module built from `.deco/blocks`, or a release (a published version of your content) from the Deco API</FlowNode> + <FlowNode title="SDK">`client.list` and `client.resolve` read and resolve content</FlowNode> + <FlowNode title="Your app">Next.js, TanStack Start, or any server</FlowNode> +</Flow> + +At request time, your app asks the SDK for a piece of saved content by name. The SDK reads it from the content you gave `createCMS`, the one setting that knows where content lives (usually the content module generated from `.deco/blocks`; see [Content and loaders](/next/content)). If that content refers to other saved entries, the SDK expands them. Then it calls the matching function from your [block map](/next/quickstart) with the saved inputs and returns the result. How you render it is up to your framework. When you need more from the block map, such as lazy loading, see [Block maps](/next/manifest). + +If you use the hosted Deco CMS, you can publish content without redeploying. In production your app reads the current release from the Deco API instead of from the content module. A request never waits on that: content stays in memory and refreshes in the background (each server checks for a new release about once a minute, when it's idle), and the content your build shipped with is always there as the fallback. See [Releases and deployment](/next/releases), or [how the loader stays current](/next/loader-internals) for the details. + +## Small on purpose + +Deco Blocks stays out of your way, and every piece is replaceable: + +- **Content is plain JSON.** Open it in any editor, grep it, diff it, or generate it with a script. There's no database or content API for you to host. The hosted Deco CMS is optional, and Deco runs it. +- **Block functions are plain functions.** No framework plugin, no base classes, no magic directories. The block map is a plain object, and you can call any block function directly in a test. +- **Loaders are one method.** Reading from a key-value store such as Cloudflare Workers KV, or from your own database, is one `load()` method that returns `{ revision, blocks }`: an ID for this version of the content, and the saved entries by name. Files in your repository need none, because the CLI turns them into a module. Swap the source and nothing else changes (see [Content and loaders](/next/content)). +- **The schema is a file.** Commit it, inspect it, or generate it another way. Studio builds its forms from that JSON alone. +- **Nothing is hidden.** If the SDK doesn't do what you need, read the content yourself: the block format is documented in [Blocks](/next/model), and the files and the content map in [Content and loaders](/next/content). diff --git a/site/content/next/content-types.mdx b/site/content/next/content-types.mdx new file mode 100644 index 00000000..ca419a37 --- /dev/null +++ b/site/content/next/content-types.mdx @@ -0,0 +1,108 @@ +--- +title: Routes, redirects, and content types +nav: Routes & redirects +group: Websites +order: 9 +--- + +# Routes, redirects, and content types + +A **content type** is a kind of saved entry that holds data and runs no function, such as a page, a blog post or a redirect. Its name is the value of `__resolveType`. You declare your own in `Content`; `page` and `redirect` are built in, and `deco schema` always includes them (see [Content that is only data](/next/schema#content-that-is-only-data)). The framework also ships the `Route` interface, which any content type can extend to get a URL. + +```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" +} + +/** A saved entry of the built-in `redirect` content type. */ +interface Redirect { + from: string; // literal or template + to: string; + permanent: boolean; // 301 or 302 +} +``` + +A page is a route with SEO and a list of [blocks](/next/model#resolving-a-page-step-by-step) (its `sections` field). It's not special to the router; it's just the first type that extends `Route`. It's shaped like the `SummerSale` entry in [Blocks](/next/model), with three additions: `"__resolveType": "page"`, so the CMS can list it by type, and a `name` and `path`, so the router can find it. It's built in, so you don't declare it. To add fields, override it in `Content` (`interface StorePage extends Page { … }`; see [Schema generation](/next/schema#content-that-is-only-data)). It's exported from `@decocms/blocks`: + +```ts +/** The built-in `page` content type. */ +interface Page extends Route { + seo: Block; // an SEO block, or a reference to a saved one + sections: VariantOf<Array<VariantOf<Block>>>; // a list of blocks; the whole list, or any one block, can be a variant +} +``` + +`Block` is a block as stored: JSON whose `__resolveType` names a function or a saved entry. `VariantOf<T>` accepts a `T` or a [multivariate](/next/variants) block that picks one. Both fields are deliberately loose, because the package can't know your block types. To narrow them, override `page`: for example `interface StorePage extends Page { seo: MySeoBlock; sections: Block[] }`, then `export type Content = { page: StorePage }`. + +```json title="Saved as SummerPage" +{ + "__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 its fields mean. page isn't a function, so the object stays data and you resolve each field separately. +{ + __resolveType: "page", + 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="Saved as LegacySummer" +{ + "__resolveType": "redirect", + "from": "/campaigns/summer", + "to": "/summer", + "permanent": true +} +``` + +Two SDK functions turn these entries into URLs. `client.list("page")` returns every saved entry whose `__resolveType` is `page`. `matchRoute(url, { routes, redirects })` picks the entry that matches a URL. [Routing](/next/routing) puts them together. + +**The router's rule is one line: an entry with a string `path` matches at that path.** `Route` is what makes that intentional. The CLI checks that any type declaring `path` extends `Route`, so a stray `path` field on an unrelated block is a schema error rather than a surprise URL. Editors see `name` and `path` as ordinary fields on every routable thing, in Studio, which is how a marketing team gets `/summer-sale` live without a developer. Keep `path` and `from` plain strings, not blocks, so the router can match without running code. + +## Your own routable types + +A blog doesn't want a `page` entry per post. Make `Post` extend `Route`, declare it in `Content` as `post: Post`, and pass the saved posts to `matchRoute` along with your pages (see [Routing](/next/routing)). Each post is then served at its own `path`: + +```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; +} +``` + +```json title="Saved as HelloWorld" +{ "__resolveType": "post", "name": "Hello, world", "path": "/blog/hello-world", "date": "2026-09-01", "body": "…" } +``` + +Two entries with the same path, or two templates that can match the same URL, are rejected by the CLI and by `matchRoute` (see [Path templates and match order](/next/routing#path-templates-and-match-order)). + +## Pages are data; resolve their fields + +A page runs no function, so you never resolve the page itself. Read it, then resolve the blocks inside it: `client.resolve(page.seo)`, and one `client.resolve(block)` per block in `page.sections`. Separate calls let each block render as soon as its own data is ready (see [Rendering](/next/rendering)). A redirect is never resolved; `matchRoute` only reads it. + +`client.resolve("SummerPage")` and `client.list("page", { run: true })` fail with `UNKNOWN_BLOCK`, because `page` names no function, and the CMS never sees `Content` (see [the rule](/next/model#the-rule)). To read a page, list it, or pass `{ run: false }`. + +<Callout>**Running code for a whole page.** If you want code to run for a whole page, such as validation or a layout step, add a `page` function to the block map and opt the built-in type out with `page: never` in `Content`, since a key can't be both. Then `client.resolve(page)` runs it after every block in the page has resolved.</Callout> + +## Content types without a URL + +Navigation, email templates, and campaign settings work the same way, minus `Route`: declare the type in `Content` if it's data, or register a function if something should run, and save entries of that type. diff --git a/site/content/next/content.mdx b/site/content/next/content.mdx new file mode 100644 index 00000000..fac37ff2 --- /dev/null +++ b/site/content/next/content.mdx @@ -0,0 +1,133 @@ +--- +title: Content and loaders +nav: Content & loaders +group: Core concepts +order: 4 +--- + +# Content and loaders + +Resolving takes two maps. The [block map](/next/manifest) holds your functions, and it ships with your code. The content map holds your saved entries: each name points at a piece of JSON. You hand `createCMS` your content through its `content` option, the only setting that knows where content lives: either the content module the CLI generates from your files, or a **loader**, any object with a `load()` method that returns the content map. Your functions, the resolver, and your pages never do. + +<Callout>**Other meanings of "loader".** In these docs, a loader on its own always means a content loader: the object that supplies the content map. Two other things share the name. In Studio, and in sites built on Deco's earlier framework, "loader" also means a block function that fetches data, like `catalogProduct` in [Blocks](/next/model); those are ordinary functions in your block map. And frameworks such as TanStack Router and Remix call the function that fetches a route's data a route loader. Nothing on this page applies to either.</Callout> + +Content always arrives as the whole map at once, with a **revision** that identifies it. This pair, `{ revision, blocks }`, is called a **snapshot**: one fixed copy of all your saved entries. + +```json +{ + "revision": "b1946ac92492d2347c6235b4d2611184", + "blocks": { + "SummerSEO": { "__resolveType": "seo", "title": "Sunny!", "description": "Light layers for long days." }, + "SummerSale": { + "seo": { "__resolveType": "SummerSEO", "title": "Summer sale" }, + "sections": [{ "__resolveType": "carousel", "banners": [] }] + } + } +} +``` + +Each key under `blocks` is an entry name. It's the name a `__resolveType` refers to, and the name you pass to `client.resolve("SummerSEO")`. The field is called `blocks` for compatibility with Studio's file format, but it holds saved entries: any JSON, never functions. Functions only ever come from the block map you give `createCMS`. + +The revision names this exact set of entries. The CMS and your app use it like a version number: to tell when content changed, to keep cached data from different versions apart, and to tell one release from the next. The one hard rule: any change to any entry must produce a new revision, and a revision must never be reused for different content. `deco content`, `remoteLoader` and the Deco API all use the same content hash of the whole map, so two copies of the same content get the same revision wherever they're computed. A loader you write can use any string that follows the rule; [Releases and deployment](/next/releases) shows what matching the hash buys you. + +To read an entry exactly as stored, with references in place, index the map: `content.blocks["SummerSEO"]`, with `content` imported from `./.deco/blocks.gen` (the content module, below). + +The map itself isn't a call: it's what the names in your content refer to. What each entry means is in [Blocks](/next/model#name-a-composition-reuse-it). + +## Where content comes from + +When you edit with Studio or an agent, content is authored as files: they write to `.deco/blocks` in your repository, and every change is a commit. The `content` option only decides where your app reads that content from at runtime. A **release** is one published version of the whole content map. A **draft** is content an editor has changed in Studio but not published yet (see [Preview](/next/preview)). With the hosted Deco CMS, each commit to the production branch becomes a release that reaches your app without a deploy, and the content the build shipped with is the fallback (see [Releases and deployment](/next/releases)). If you load from your own store, how content gets there is up to you. + +<Callout>**There is no publish step.** Content is published by changing it: commit a file in `.deco/blocks` and push, or save in Studio, which commits for you. With the hosted Deco CMS, that commit becomes the current release without a deploy; without it, the content goes live with your next deploy. See [Publishing](/next/releases#publishing). (Running `deco publish` only prints this explanation, so an agent that tries it learns to commit instead.)</Callout> + +The content map can come from anywhere that can return JSON. These are the usual sources: + +| Content in | `createCMS` options | Use it for | +|---|---|---| +| Files in your repository | `content`: the content module, `.deco/blocks.gen.ts`, which `deco content` generates from one JSON file per entry in `.deco/blocks`; the file name without `.json` is the entry name (`.deco/blocks/SummerSEO.json` → `"SummerSEO"`) | Development, tests, every runtime, and the fallback under `token` in production | +| The hosted Deco CMS | Add `token`, which wraps your content as `remoteLoader(content, { token })` | Production: reads the current release from the Deco API without a deploy, and falls back to your content module when it can't. Also serves drafts for preview | +| KV, a database, or your own API | `content`: a `Loader` you write | Anything else | + +## The content module + +The SDK never touches the filesystem, so the same setup runs on Node, Cloudflare Workers, Deno, Bun, React Native and in browsers. Files reach your app through the **content module** instead: `.deco/blocks.gen.ts`, which the CLI command `deco content` generates from `.deco/blocks`. It imports each JSON file separately, using JSON import attributes (the `with { type: "json" }` clause), and its default export is the snapshot `{ revision, blocks }`, with the revision computed by the CLI: + +```ts title=".deco/blocks.gen.ts (excerpt)" +// Generated by deco content; don't edit. +import Experiments from "./blocks/Experiments.json" with { type: "json" }; +import SummerSEO from "./blocks/SummerSEO.json" with { type: "json" }; +// … one import per file + +export default { + revision: "b1946ac92492d2347c6235b4d2611184", + blocks: { Experiments, SummerSEO /* , … */ }, +}; +``` + +The file is generated, so add it to `.gitignore`, like TanStack Router's `routeTree.gen.ts`, and run `deco content` before your dev server and your build, from `package.json`: + +```json title="package.json" +{ + "scripts": { + "predev": "deco schema && deco content", + "prebuild": "deco schema && deco content" + } +} +``` + +Both commands are described in [The two CLI commands](/next/schema#the-two-cli-commands). Because each file is an ordinary import, editing one reloads through your framework's own hot module replacement (HMR, which swaps changed modules into the running app without a restart), and the new content is handed to the running CMS. Only adding or removing a file needs `deco content` again, or `deco content --watch` left running beside your dev server. + +To keep content in another folder, point the CLI at it and import from there: `deco content --root ./content/.deco`, then `import content from "./content/.deco/blocks.gen"`. + +## Configuring content + +`createCMS` takes your content in its `content` option: usually the content module, or a `Loader` for your own store. Add `token` to connect the hosted Deco CMS; it's shorthand for `remoteLoader(content, { token, interval })`, with your content as the fallback. + +```ts title="cms.ts" +import content from "./.deco/blocks.gen"; + +// Your files, through the content module +createCMS({ blocks, content }); + +// Releases from the hosted Deco CMS, with the content module as the fallback +createCMS({ blocks, content, token: process.env.DECO_SITE_TOKEN }); + +// Check for new releases every 2 minutes instead of every minute +createCMS({ blocks, content, token: process.env.DECO_SITE_TOKEN, interval: 120_000 }); + +// Content from your own store (see Write your own loader) +createCMS({ blocks, content: kvContent }); +``` + +`token` is your site's API key from the hosted Deco CMS, used to call the Deco API. (Issuing tokens for this API isn't available yet; see the [Roadmap](/roadmap#roadmap-platform--build-the-deco-api-release-service).) Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads `content` only, so the same line works everywhere. + +`interval` is how often each server checks for a new release, in milliseconds. It defaults to the `DECO_CONTENT_INTERVAL` environment variable, or 60 000 (one minute) when that's unset. The minimum is 60 000: 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. How and when checks run is in [Loader internals](/next/loader-internals). + +The content module you pass with `token` stays in memory as the fallback, alongside the live release. On Cloudflare Workers with a large content set, see “Large content on Workers” under [Create the CMS](/next/tanstack-data#2-create-the-cms). + +## Write your own loader + +The contract is one required method, `load(pointer?)`, which returns `{ revision, blocks }`. Here is one backed by Cloudflare Workers KV, Workers' key-value store, with the revision and the entries stored together under one key: + +```ts title="cms.ts" +import { createCMS, type Loader, type Snapshot } from "@decocms/blocks"; +import { env } from "cloudflare:workers"; +import blocks from "./blocks"; + +const kvContent: Loader = { + async load() { + // { revision, blocks }, stored together under one key + const snapshot = await env.CONTENT.get<Snapshot>("content", "json"); + if (!snapshot) throw new Error("no content in KV"); + return snapshot; + }, +}; + +export const cms = createCMS({ blocks, content: kvContent }); +``` + +On Workers, importing `env` from `cloudflare:workers` gives you your bindings (here the KV namespace `CONTENT`) at module scope, so the CMS can still be created once, outside any request. `Snapshot` is the `{ revision, blocks }` type. If `load()` throws, the read fails with `LOADER_FAILED` (see [Errors](/next/standalone#errors)). A first-party `kvLoader(env.CONTENT, { key })` helper is planned (see the [Roadmap](/roadmap#roadmap-api--document-and-support-kv-only-content-for-large-workers-sites)). + +Pass it as `content`; add `token` to use it as the fallback under hosted releases. Store the revision with the content, and change it whenever any entry changes (see [the rule above](/next/content)). + +Two optional extras. If your store has drafts, use the `pointer` argument, the [draft pointer](/next/preview) that names which draft to read. If content can change while the process runs, add `update()`, which checks for newer content; the CMS calls it on the same idle schedule and `interval` as `remoteLoader` (see [Loader internals](/next/loader-internals)). Either way, the client only ever sees `{ revision, blocks }`. diff --git a/site/content/next/decisions.mdx b/site/content/next/decisions.mdx new file mode 100644 index 00000000..10ec4b47 --- /dev/null +++ b/site/content/next/decisions.mdx @@ -0,0 +1,29 @@ +--- +title: Design decisions +group: Under the hood +kind: internals +order: 6 +--- + +# Design decisions + +Each of these was argued over. The one-line version, and why it won. + +| Decision | Why | +|---|---| +| **The CMS is 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. | +| **For content edited in Studio, Git is the source of truth.** Publishing is committing; the Deco API serves commits as releases. (Any other store is [a loader you write](/next/content).) | 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. | +| **A block is either a function (code) or saved content (JSON).** One namespace: an entry is merged in and checked again; a function gets its inputs resolved, then runs, and its result is returned as is. | Saved entries are just named compositions, 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 entries, and a cycle check stops it. | +| **The CMS at module scope, a client per request, pinned to one revision.** | 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 every cache lives in the loader. | +| **`content` is a snapshot or one `Loader` interface**: `load(pointer?)`, plus an optional `update()`. | Production, drafts, and staying current are uses of the same interface, so the content module, `remoteLoader` and a custom loader can replace each other in the `content` option and [a custom loader](/next/content#write-your-own-loader) is a few lines. | +| **A production request never waits for the network.** | Serve from memory, check for a hash about once a minute, at idle moments, fall back to the content the build shipped with. Eventual consistency for visitors, strict consistency for drafts. | +| **The CMS doesn't route.** `matchRoute` 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. | +| **Data-only types live in `Content`; `page` and `redirect` are built in**, and you can override them or opt out. | A block map entry means "run this". Pages, posts, and redirects have nothing to run; they need a schema, which is the CLI's job. Almost every website needs pages and redirects, and Studio's page list and redirects screen look for them. Overriding with `extends Page` keeps them yours, and an app that isn't a website opts out with `never`. Posts and the rest stay your own types. | +| **Lazy loading belongs to your web framework, not the CMS.** | 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. | +| **Three matchers ship; the rest is app code.** | `always`, `never`, and `date` depend on nothing but the clock. A device or cookie matcher is an opinion about the request, and the app owns those. | +| **Short type names; aliases for the old file-path names.** | A type name is a column name, not a file path. Content saved under the previous framework's names, like `website/pages/Page.tsx`, keeps working through aliases, while new code uses names that survive a refactor. | +| **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. | +| **Two CLI commands, no dev wrapper.** | `deco schema` and `deco content` each do one job and run from `predev` and `prebuild`. Both take `--root` and `--watch`; `deco schema` also takes `--entry`, for a block map outside the default locations, and `--check`, for CI. | +| **Compatibility is a CI check, not a runtime gate.** | Publishing without a deploy exists so code that's already live can render new content; holding content back until a deploy would undo that. `deco schema --check` (planned) flags a code change that would break saved content before it merges, and Studio offers what the schema you point it at has, usually main's. | +| **Poll a hash once a minute, at idle moments; no push.** | A few bytes per server per minute, no infrastructure to reach every server, and never in front of rendering. | diff --git a/site/content/next/internals.mdx b/site/content/next/internals.mdx new file mode 100644 index 00000000..ba61baa4 --- /dev/null +++ b/site/content/next/internals.mdx @@ -0,0 +1,46 @@ +--- +title: How Deco Blocks works inside +nav: Overview +group: Under the hood +kind: internals +order: 1 +--- + +# How Deco Blocks works inside + +The [developer docs](/next/architecture) describe how to use Deco Blocks. 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/walkthrough): one saved entry, expanded and run step by step. +- [How `remoteLoader` stays current](/next/loader-internals): new releases arrive without a request ever waiting on the network. +- [How `matchRoute` matches](/next/router-internals): the data structure that maps a URL to an entry. +- [Studio compatibility](/next/studio): what Deco Studio reads from your site, and how the CLI keeps today's Studio working. +- [Design decisions](/next/decisions): the choices behind all of the above, and why they won. + +## Contributing + +The framework lives in one repository, `decocms/blocks`, as a Bun workspace (Bun is the package manager and test runner). Five core packages make up the framework; the other folders under `packages/`, such as `apps-*`, are integrations with commerce and other services built on top of them. The core packages depend on each other in one direction only: + +```text +packages/ +├── blocks/ @decocms/blocks the SDK: loaders, client, resolution, router. +│ Depends on no other @decocms package. +├── blocks-admin/ @decocms/blocks-admin the HTTP endpoints Studio calls on your site. +│ depends on: @decocms/blocks +├── blocks-cli/ @decocms/blocks-cli deco schema and deco content, plus scripts that +│ move sites off the previous framework. +│ depends on: @decocms/blocks +├── tanstack/ @decocms/tanstack the adapter for TanStack Start on Cloudflare Workers. +│ depends on: @decocms/blocks, @decocms/blocks-admin, @decocms/blocks-cli +└── nextjs/ @decocms/nextjs the adapter for the Next.js App Router. + depends on: @decocms/blocks, @decocms/blocks-admin +``` + +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` and `remoteLoader` keep their instances on `globalThis`, so a second copy reuses the first (see [One instance per process](/next/standalone#one-instance-per-process)). `blocks` never imports from the others, and the two adapters never import from each other; if a feature needs both sides, split it into two functions rather than import in reverse. Apps following these docs need only `@decocms/blocks` at runtime, plus `@decocms/blocks-cli` as a dev dependency. The two adapters serve sites on Studio's current protocol; apps written against the API in these docs don't need them. + +```bash +bun install +bun run check # typecheck + lint +bun run test # vitest, per package +``` + +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 one request's layout data from leaking into another's; if one fails, fix the code, not the assertion. diff --git a/site/content/next/loader-internals.mdx b/site/content/next/loader-internals.mdx new file mode 100644 index 00000000..b9cc3719 --- /dev/null +++ b/site/content/next/loader-internals.mdx @@ -0,0 +1,49 @@ +--- +title: How remoteLoader stays current +nav: Loader internals +group: Under the hood +kind: internals +order: 3 +--- + +# How `remoteLoader` stays current + +The rule that matters: **a production request never waits for the network.** `load()` 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'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 only the current revision hash from the 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 never competes with rendering, and a request never waits. There's no push signal: servers ask, the API never calls them. +3. **Compare.** If the hash equals what's in memory, there's nothing to do. 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'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 revision, checks that the content hashes to it, and swaps it in whole. Whether that content fits this build was settled before it merged, not here (see [Keep the schema compatible](/next/schema#keep-the-schema-compatible)). Requests in flight keep the revision they started with. +5. **Fail quietly.** Any network or validation error 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, within about a minute (or at once 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 are the opposite: a preview must show exactly the version Studio asked for, so `load(pointer)` fetches that version and waits for it, cached by version afterwards. If the draft can't be fetched, the client serves what a normal request would get: the newest release in memory, or the build's content if none has been fetched yet. It never mixes draft and published entries. + +## 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 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. `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 configure `remoteLoader`, see [Configuring content](/next/content#configuring-content); for what a release is, see [Releases and deployment](/next/releases). diff --git a/site/content/next/manifest.mdx b/site/content/next/manifest.mdx new file mode 100644 index 00000000..146419b8 --- /dev/null +++ b/site/content/next/manifest.mdx @@ -0,0 +1,69 @@ +--- +title: Block maps +group: Advanced +order: 7 +--- + +# Block maps + +A block map is the code half of the [registry](/next/model): a plain object from block type to function. The other half is the content map of saved entries, which you pass to `createCMS` as [`content`](/next/content). The quickstart's `blocks.ts` is one. This section covers what else can go in it. + +```ts title="blocks.ts" +import type { Blocks } from "@decocms/blocks"; +import seo from "./seo"; +import productCard from "./product-card"; +import hero from "./hero"; + +export default { + seo, + "product-card": productCard, + hero, +} satisfies Blocks; +``` + +Each key is a block type. Each value is a function `(inputs) => value`, where `inputs` is the block without its `__resolveType`: saved entries already expanded and merged, and nested calls already run, so the function receives values, never a pending call. Use `satisfies Blocks` rather than a type annotation: it checks the shape while keeping the concrete keys and function types, which is what the schema is generated from. + +Keep the map a plain object at module level. The [CLI](/next/schema) reads the file without running it, so build it once and give it to `createCMS`. Only functions go in the map. Types that are pure data, such as pages, posts, and redirects, have nothing to run; they're declared for the CLI instead, see [content that is only data](/next/schema). + +<Callout>**The CMS is pure.** It passes your function the block and nothing else: no context object, no request, no hidden state. When a block needs the current request, a cookie, or a route parameter, read it the way the rest of your app does, usually through request-scoped storage your framework already provides (Node's `AsyncLocalStorage`, which `next/headers` and TanStack Start's `getRequest()` use under the hood). That keeps block functions ordinary functions you can call and test directly.</Callout> + +## Lazy loading + +The map above imports every block at the top, so they all load with the map. That's fine for functions like `seo`. It's not what you want for a component that drags in a charting library, or for the two hundred components a large store has when any page uses five. + +What's heavy is the component a block renders, and your framework already knows how to load components lazily. So do it in one place, the map, and let the block files stay plain components: + +```tsx title="blocks.tsx (Next.js)" +import dynamic from 'next/dynamic' +import type { Blocks } from '@decocms/blocks' +import seo from './seo' +import type { ProductProps } from './components/Product' +import type { HeroProps } from './components/Hero' + +// Each component is split into its own chunk and fetched on first render. +const Product = dynamic(() => import('./components/Product')) +const Hero = dynamic(() => import('./components/Hero')) + +export default { + seo, // eager: tiny, used everywhere + product: (input: ProductProps) => <Product {...input} />, + hero: (input: HeroProps) => <Hero {...input} />, +} satisfies Blocks +``` + +With TanStack Start, use React's `lazy` instead of `dynamic`. Where it goes depends on which of the two rendering modes you use (see [Rendering](/next/rendering)), and the [TanStack guides](/next/tanstack-data) show the code. In every case the component files know nothing about the CMS, the map stays a plain object the CLI can read, and the bundler splits on the `import()`. + +For a block that's heavy for a non-UI reason, say a data function that pulls in a large SDK, a dynamic import inside the function body is enough. It's one line and needs no helper: + +```ts +"catalog-product": async (input: ProductQuery) => { + const { fetchProduct } = await import("./commerce") // loaded on first call; the JS module system caches it after that + return fetchProduct(input) +}, +``` + +Keep import paths literal in both cases. A bundler can split `import("./components/Product")` into its own chunk; it can't split `` import(`./components/${name}`) ``. + +## Choose type names that last + +A block type's name is what content stores in `__resolveType` to call it, so pick it like a database column, not like a file path: short (`seo`, `hero`) or namespaced when a site has many (`commerce/product`). Moving a file only means updating an import; the name doesn't change. Renaming a type means migrating every block that uses it, or adding an alias, a second name for the same block type (see [Renaming and migrating](/next/adoption)). Type names share one namespace with saved entry names (see [Blocks](/next/model)), and `deco schema` fails on a clash (at runtime, the function wins). A simple convention avoids clashes: lowercase or kebab-case for block types (`seo`, `product-card`), capitalized names for entries (`SummerSEO`). diff --git a/site/content/next/model.mdx b/site/content/next/model.mdx new file mode 100644 index 00000000..d63f55be --- /dev/null +++ b/site/content/next/model.mdx @@ -0,0 +1,138 @@ +--- +title: Blocks +group: Core concepts +order: 3 +--- + +# Blocks + +Most of what changes on a website isn't the code. It's the values the code is given: a headline, a banner image, the share of visitors who see the new checkout. Deco is built on that idea: developers write the functions, and the people who run the site decide what goes into them. + +Blocks are how they make those decisions. A block is a small piece of JSON that names one of your functions and lists the inputs to call it with. Editors change the inputs in Studio, and agents edit the file directly. When your app resolves the block, Deco calls the function with whatever is saved and gives you back the result. This page shows what a block looks like, how blocks fit inside each other, and exactly how they turn into values. + +## Start from code you already write + +Here's the kind of code you write every day: + +```ts +productCard({ + title: "Summer collection", + product: catalogProduct({ slug: "summer-shirt" }), +}) +``` + +It's function composition. The inner call runs first, and its result becomes an input to the outer one, so `productCard` receives product data, never a pending call. The functions are the developer's job. The composition is a decision: which product, which title, which function fetches the data. Written in code, changing that decision means editing code and deploying it. + +## A block is that composition, stored as data + +Write the same composition as JSON and nobody has to edit code to change it. `__resolveType` names the function, the other properties are its inputs, and a block inside an input is a call inside a call. Here `productCard` and `catalogProduct` are the functions your block map registers as `"product-card"` and `"catalog-product"`: + +```jsonc +// Stored as JSON +{ + "__resolveType": "product-card", + "title": "Summer collection", + "product": { "__resolveType": "catalog-product", "slug": "summer-shirt" } +} + +// Means +productCard({ + title: "Summer collection", + product: catalogProduct({ slug: "summer-shirt" }), +}) +``` + +Now an editor can change the title or the slug in Studio, and an agent can even point `product` at a different function, and the composition changes without a deploy. The functions are whatever your [block map](/next/manifest) registers, and each one's input type is what `deco schema` turns into the editor form. Use the same model for anything your site needs: UI, product data, SEO, navigation, campaign settings. The CMS doesn't reserve any directories or categories. + +## Name a composition, reuse it + +In code, you'd give a composition you use often a name and reuse it. Saved entries do that. Alongside the block map of functions, the CMS reads a second map, the content map, where each name holds a piece of saved JSON. `{ "__resolveType": "SummerSEO" }` means whatever is saved under the name `SummerSEO`, written in place. The map can come from files in your repository, the hosted Deco CMS, or your own store; see [Content and loaders](/next/content). + +Unlike a `const` that holds a result, what's named here is the call itself, inputs and all. That's why you can add inputs to a reference to override the saved entry's inputs for this one use: + +```jsonc +// 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" } + +// Means +seo({ title: "Summer sale", description: "Light layers for long days." }) +``` + +- **Overrides are shallow.** The merge is `{ ...entry, ...inputs }`, so an override replaces the whole top-level property, and the saved entry itself doesn't change. It isn't currying or partial application: the reference is expanded into one complete call. +- **An entry doesn't have to be a block.** It can be any JSON that contains blocks, like a page with a list of blocks: an object literal with calls inside. Resolving it calls each block inside and leaves the rest of the JSON as it is. + +## Resolving a page, step by step + +Resolving a block is evaluating its composition: named entries expand, then calls run from the inside out. Here's a page saved as `SummerSale`. It uses the `SummerSEO` entry with an override, and one `carousel` block in its `sections` list. A page is mostly an ordered list of blocks that editors add, remove and reorder. The block map has two functions. `seo` returns its inputs; that's still worth registering, because it gives editors a form for SEO and lets them save it as a reusable entry. `carousel` returns a **descriptor**, `{ component, props }`: a plain object that says which component to render with which props, for your app to render later. A block function can also return JSX directly; [Rendering](/next/rendering) compares the two. + +```jsonc +// Saved as SummerSale +{ + "seo": { "__resolveType": "SummerSEO", "title": "Summer sale" }, + "sections": [{ "__resolveType": "carousel", "banners": [] }] +} +``` + +This page has no `__resolveType`, so the page object itself isn't a call: resolving it resolves the blocks inside and keeps the rest of the JSON as it is. (Real pages usually carry `"__resolveType": "page"` so they can be listed by type. That name has no function behind it, so for those you resolve the fields instead; see [below](#the-rule-in-detail).) + +Call `client.resolve("SummerSale")` and follow the page as it changes. `resolve` takes either an entry name (a string; a string is always an entry name) or any JSON value, such as one field of a page. + +**Step 1. The reference expands.** `SummerSEO` is a saved entry, so it's data. The saved entry is merged with the reference's own inputs, and the reference's inputs win. The `__resolveType` from the entry, `seo`, names a function. + +```jsonc +{ + "seo": { "__resolveType": "seo", "title": "Summer sale", "description": "Light layers for long days." }, + "sections": [{ "__resolveType": "carousel", "banners": [] }] +} +``` + +**Step 2. The functions are called.** What's left is a composition of plain calls. Both blocks have plain inputs, so both functions run. They're siblings, so neither waits for the other. + +```ts +{ + seo: seo({ title: "Summer sale", description: "Light layers for long days." }), + sections: [carousel({ banners: [] })] +} +``` + +**Step 3. The result.** Each return value takes its block's place. The page itself was never a block, so its shape stays the same. + +```ts +{ + seo: { title: "Summer sale", description: "Light layers for long days." }, + sections: [{ component: "Carousel", props: { banners: [] } }] +} +``` + +To step through a deeper example, see [How resolution works](/next/walkthrough). + +<Callout>**Reading without running.** Pass `{ run: false }` to read content without running any function. `client.resolve("SummerSale", { run: false })` stops after step 1: saved entries like `SummerSEO` still expand, and blocks that name a function, like `seo` and `carousel`, come back as JSON, untouched. Nothing runs. That's useful for previews, editor tooling, and debugging.</Callout> + +## The rule + +Every `__resolveType` names a block, and a block is either a function (code) or saved content (JSON): a function in your block map to call, or a saved entry in your content to expand. Both share one namespace, one registry. They differ in how they change: functions ship with a deploy, while saved content is versioned (each version has a revision), can have drafts, and can be published without a deploy. + +```ts +const registry = { ...savedEntries, ...blocks }; // name → saved JSON | function +``` + +Saved entries are the content map you pass as `createCMS`'s [`content`](/next/content). Functions come from the block map you give `createCMS`. The CMS applies one rule to every object with a `__resolveType`: + +1. **Look up the name.** +2. **Data:** replace the object with `{ ...entry, ...inputs }`, so your inputs override the entry's, and apply the rule to the result again. +3. **Function:** resolve the inputs first, children before parents and siblings concurrently, then call the function. Its return value takes the block's place. +4. **Neither:** fail with `UNKNOWN_BLOCK`. + +## The rule in detail + +- **Failures stop the parent.** If a nested block fails, its parent never runs and `resolve` returns the error, much as an outer call never runs when an inner one throws. A reference to an entry that doesn't exist fails with `NOT_FOUND`. +- **Results are returned as is.** The CMS never walks a function's return value. Return plain data, a descriptor, or JSX. This is also why a function that returns its input, like `seo` above, can't loop: `__resolveType` is stripped before the call, and whatever comes back is never looked up again. +- **Block functions should only read.** They run whenever content is resolved: for a visitor, in a Studio preview, for an agent. Put side effects in your own route handlers (see [Caching and observability](/next/operations)). +- **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`. + +<Callout>**Pages that are only data.** Some saved entries describe a shape but have no function behind them, such as a page (`page` is a built-in content type, saved with `"__resolveType": "page"`). Nothing can be called for them, so resolving the entry fails with `UNKNOWN_BLOCK`. Read the entry instead, then resolve the blocks inside it, for example `client.resolve(page.seo)`. (`SummerSale` above had no `__resolveType` at all, which is why it could be resolved whole.) [Schema generation](/next/schema#content-that-is-only-data) shows how to declare such types, and [Routes and redirects](/next/content-types) how to read them.</Callout> + +<Callout type="warning">**Entry names and block types share one registry.** If a name is both, the function wins, so a saved entry called `seo` and a `seo` function can't coexist. `deco schema` fails when an entry in `.deco/blocks` has the name of a block type, so rename the entry. By convention, functions are lowercase or kebab-case and saved content is Capitalized.</Callout> diff --git a/site/content/next/nextjs.mdx b/site/content/next/nextjs.mdx new file mode 100644 index 00000000..85d48007 --- /dev/null +++ b/site/content/next/nextjs.mdx @@ -0,0 +1,269 @@ +--- +title: Next.js App Router +group: Framework guides +order: 13 +--- + +# Next.js App Router + +In this guide, you'll render CMS pages with Server Components, stream each of a page's blocks under its own Suspense boundary, and generate metadata from the page's SEO block. + +Each of a page's [blocks](/next/model#resolving-a-page-step-by-step) resolves on its own, so a slow block never holds up the rest. Editors also choose page URLs in Studio, so your app can't know its routes ahead of time. Instead, one catch-all route receives every URL, asks `matchRoute` which page or redirect owns it, and renders that page's blocks. + +**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` and the CLI from [Quickstart](/next/quickstart); and the [example project](/next/routing#the-example-used-here-and-in-the-guides): `src/model.ts` plus the entries in `.deco/blocks` at the project root. + +## 1. Add the components + +`Product` is the component the `product` block renders. `CartButton` is a Client Component with state, there to show that interactive components keep working inside blocks the CMS renders on the server. Neither 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/Product.tsx" +import CartButton from './CartButton' +import type { ProductProps } from './model' + +const money = (props: ProductProps) => + new Intl.NumberFormat('en-US', { style: 'currency', currency: props.currency }).format(props.price) + +export default function Product(props: ProductProps) { + return ( + <section id={props.id}> + <h2>{props.name}</h2> + <p>{money(props)}</p> + <CartButton /> + </section> + ) +} +``` + +## 2. Create the CMS + +The `product` block returns JSX. Next renders it as a Server Component, and `CartButton` stays a Client Component. + +```tsx title="src/blocks.tsx" +import type { Blocks } from '@decocms/blocks' +import Product from './Product' +import type { ProductProps, SEO, StoredPage } from './model' + +export type Content = { page: StoredPage } + +export default { + seo: (input: SEO) => input, + product: (input: ProductProps) => <Product {...input} />, +} satisfies Blocks +``` + +`seo` just returns its input. Registering it lets editors save SEO as a reusable entry with its own form. `page` and `redirect` are built-in content types (see [Content that is only data](/next/schema#content-that-is-only-data)). The `Content` export overrides `page` with the narrower `StoredPage` from [the example](/next/routing#the-example-used-here-and-in-the-guides), so Studio only saves pages this guide's code can read. + +`cms.ts` imports the [content module](/next/content#the-content-module), `.deco/blocks.gen.ts`: the file the CLI command `deco content` generates from your JSON files, exporting a snapshot `{ revision, blocks }` (one fixed copy of all your saved entries). Step 3 sets up that command; until it has run once, TypeScript can't find the import. + +```ts title="src/cms.ts" +import { createCMS } from '@decocms/blocks' +import content from '../.deco/blocks.gen' +import blocks from './blocks' + +// Development (no token) reads the content module. Production reads releases from the Deco API +// and falls back to it. +export const cms = createCMS({ blocks, content, token: process.env.DECO_SITE_TOKEN }) +``` + +The content options and their fallbacks are in [Configuring content](/next/content#configuring-content). + +```ts title="src/client.server.ts" +import 'server-only' +import { cookies } from 'next/headers' +import { DRAFT_COOKIE } from '@decocms/blocks' +import { cms } from './cms' + +// The client for this request: a draft when the cookie holds a valid pointer, production otherwise. +// In development (no token), drafts and releases read the same content. +export async function client() { + const pointer = (await cookies()).get(DRAFT_COOKIE)?.value + return pointer ? cms.forDraft(pointer) : cms.forRelease() +} +``` + +`import 'server-only'` makes the build fail if a Client Component imports this file, which keeps the token and the CMS out of the browser. + +## 3. Generate the schema and content + +Two CLI commands generate the files the app and Studio need (see [The two CLI commands](/next/schema#the-two-cli-commands)): `deco schema` writes `.deco/schema.json`, a form for each block type in Studio, from the block map it finds in `src/blocks.tsx`, and `deco content` writes the content module `cms.ts` imports. Run both before the dev server and the build: + +```json title="package.json" +{ + "scripts": { + "predev": "deco schema && deco content", + "prebuild": "deco schema && deco content" + } +} +``` + +Commit `.deco/schema.json` and gitignore `.deco/blocks.gen.ts`. What reloads on its own is in [The content module](/next/content#the-content-module); while you work, you can leave `deco content --watch` running beside `next dev`. + +## 4. Keep preview working across pages + +Studio opens your site with the [draft pointer](/next/preview) in the URL. `proxy.ts` (called `middleware.ts` before Next.js 16) runs before every request and turns that pointer into a cookie, so the editor stays in preview while clicking around. `client()` reads the cookie. + +```ts title="src/proxy.ts" +import { NextResponse, type NextRequest } from 'next/server' +import { draftCookie } from '@decocms/blocks' + +export function proxy(request: NextRequest) { + const response = NextResponse.next() + const cookie = draftCookie(request) // only set on the request Studio opens; expiring when leaving preview + if (cookie) response.headers.append('Set-Cookie', cookie) + return response +} +``` + +## 5. Match the URL to a page + +`openPage` lists pages and redirects, matches the pathname, and starts resolving every block without waiting for any of them. Listing is cheap: the CMS holds the content in memory. Block keys include the pathname, so React mounts fresh Suspense boundaries when you navigate. + +`generateMetadata` and the page component both need the page. Wrap the lookup in React's `cache` so they share one client, one match, and the same block promises. + +```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 { SEO, 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) permanentRedirect(match.location) + redirect(match.location) + } + + const page = match.entry + return { + blocks: page.sections.map((block, index) => ({ + key: `${pathname}:${index}`, + result: c.resolve<ReactNode>(block), + })), + seo: c.resolve<SEO>(page.seo), + } +}) + +export const pathnameFor = (segments: string[] = []) => + '/' + segments.map(encodeURIComponent).join('/') +``` + +## 6. Render the catch-all route + +Each block is a promise of a `Result`, `[value, error]`. `BlockSlot` awaits it inside its own Suspense boundary and shows a fallback if that one block failed. + +```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 } +} + +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 +} + +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> + ) +} +``` + +## 7. List another content type + +Add `src/post.ts` from [Your own routable types](/next/content-types#your-own-routable-types), change the `Content` export in `src/blocks.tsx` to `{ page: StoredPage; post: Post }`, and save an entry such as `.deco/blocks/HelloWorld.json` (a new file, so run `deco 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, since a post is data with nothing to resolve: + +```ts title="src/open-page.server.ts (changes)" +import type { Result } from '@decocms/blocks' +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 (match.entry.__resolveType === 'post') { + const post = match.entry as Post + const seo: Result<SEO> = [{ title: post.name, description: '' }, null] + return { post, blocks: [], seo: Promise.resolve(seo) } + } + const page = match.entry as StoredPage + return { post: null, blocks: …, seo: … } // blocks and seo exactly as before +``` + +In `Page`, render the post when `page.post` is set (for example `<article><h1>{page.post.name}</h1>…</article>`), and the page's blocks otherwise. `generateMetadata` keeps working unchanged. + +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> + ) +} +``` + +## How it works + +- **Preview is a different client.** `client()` is `pointer ? cms.forDraft(pointer) : cms.forRelease()`, and the pointer only exists while the cookie does. Nothing else in the guide knows about preview. +- **`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. +- **Metadata waits only for SEO.** Next can stream metadata separately. A metadata error after streaming starts can't change headers that were already sent. +- **Redirect status codes.** Next's navigation helpers send 308 and 307. For an exact 301 or 302, match redirects in `proxy.ts` with `matchRoute` and return `NextResponse.redirect(url, match.status)`. Proxy makes its own client, so if a new release lands between the two, the redirect check and the page can briefly see different versions of the content. + +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). diff --git a/site/content/next/operations.mdx b/site/content/next/operations.mdx new file mode 100644 index 00000000..8f930b54 --- /dev/null +++ b/site/content/next/operations.mdx @@ -0,0 +1,26 @@ +--- +title: Caching and observability +nav: Caching & observability +group: Production +order: 17 +--- + +# Caching and observability + +A request passes through several caches. Deco Blocks manages the first two, the JavaScript runtime the third, and the last two are yours, and getting their keys wrong is how a site shows one customer's cart to another or serves last week's prices. + +| Layer | Lifetime | Your responsibility | +|---|---|---| +| Content map | Cached in the CMS: releases by revision, indefinitely; drafts by version. One cache per process, even if the package is loaded twice (see [One instance per process](/next/standalone#one-instance-per-process)). See [Content and loaders](/next/content) and [how remoteLoader stays current](/next/loader-internals). | Nothing, unless you write a loader: then key by revision. | +| Resolved blocks | For one client. A client runs each block once and reuses the result for the rest of the request. | Make a client per request and resolve everything the response renders through it. | +| Dynamically imported modules | Block functions you load lazily with `import()` (see [Block maps](/next/manifest)) 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. | +| 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. | +| Upstream data | Responses from the APIs your blocks call (a commerce platform, a search service). Follows the upstream API. | Include tenant, locale, currency, or customer in the key. Never cache private data as public. | + +A block type alone is never a cache key. Include every input that can change the result. A cache hit doesn't bypass authorization. + +Block functions run whenever content is resolved: for a visitor, in a Studio preview, for an agent, possibly more than once. So they should only read. Put anything that changes state, like adding to a cart, in your own route handlers. + +## Tracing + +If you use [OpenTelemetry](https://opentelemetry.io/), make each request span the parent of block resolution spans and outbound HTTP spans. One way to get block spans is to wrap each function in your block map in a span named after its block type. Record the entry name (when the block came from a saved entry), the block type, duration, errors, and cache outcome, and keep credentials and customer data out of attributes. Recording the served revision needs the client to expose it, which is on the [Roadmap](/roadmap#roadmap-api--add-a-revision-handle). Export telemetry asynchronously, so an unavailable collector never blocks rendering. diff --git a/site/content/next/preview.mdx b/site/content/next/preview.mdx new file mode 100644 index 00000000..c312fdf0 --- /dev/null +++ b/site/content/next/preview.mdx @@ -0,0 +1,84 @@ +--- +title: Preview +group: Core concepts +order: 6 +--- + +# Preview + +A **draft** is content an editor has changed in Studio but not published yet, kept on a branch until someone publishes it. Editors want to see a draft before it's published. Because block *functions* ship with your app and only *content* changes, previewing a draft needs no deploy: the app renders the same code against different content. + +Here's the whole mechanism. Studio opens your site with a **draft pointer** in the URL: + +```bash +https://store.example.com/summer?__draft=studio.decocms.com/api/acme/decofile/store/feat-summer%3Ftoken%3D…@9f3c1a +``` + +The part after `__draft=` is URL-encoded, which is why `?` and `=` show as `%3F` and `%3D`. Decoded, `studio.decocms.com/api/…/feat-summer?token=…` is where the draft lives: the Deco API's address for this site's `feat-summer` branch, plus a token signed by Studio. Treat the path as opaque. `9f3c1a`, after the `@`, is the draft's version. + +The pointer is a string, `<host><path>@<version>`: where the draft content lives, and which version. `draftPointer(request)` reads it, from `?__draft=` or from the cookie described below, and the CMS gives you a client over that draft. Everything after that line is the same code that serves visitors: + +```ts title="A plain request handler" +import { draftPointer } from '@decocms/blocks' +import { cms } from './cms' + +export async function handle(request: Request) { + const pointer = draftPointer(request) // null on an ordinary request + const client = pointer ? cms.forDraft(pointer) : cms.forRelease() + + return render(client, request) // your own function: list, matchRoute and resolve as usual +} +``` + +`forDraft` hands the pointer to whatever supplies your content. `remoteLoader` fetches that exact version from the Deco API, waits for it, and caches it by version, so the second request for the same draft is a memory read. A draft replaces the whole revision, deletions included, so a page in preview looks exactly as it will after publishing. The content module has no drafts, so it ignores the pointer: in development you're already looking at the files. A loader you write honors it if your store has drafts (see [Write your own loader](/next/content#write-your-own-loader)). That's why the code above never checks where its content comes from. + +## Keep the draft across navigation + +The pointer is only in the URL of the first page Studio opens. `draftCookie(request)` gives you the `Set-Cookie` value that response needs, and `null` on every other request. Append it, and `draftPointer` reads the cookie from then on, so every link the editor clicks stays in preview: + +```ts title="Query parameter first, cookie afterwards" +import { draftPointer, draftCookie } from '@decocms/blocks' +import { cms } from './cms' + +export async function handle(request: Request) { + const pointer = draftPointer(request) + const client = pointer ? cms.forDraft(pointer) : cms.forRelease() + const response = await render(client, request) + + const cookie = draftCookie(request) + if (cookie) response.headers.append('Set-Cookie', cookie) + return response +} +``` + +Studio ends a preview by opening the site with an empty `?__draft=`; `draftCookie` returns an expiring cookie for that, so the same two lines cover leaving preview. The parameter and cookie names never appear in your code. In Next.js the two steps split across `proxy.ts` (Next.js 16's name for `middleware.ts`), which appends the cookie, and a server helper that reads it with `cookies()`. The [Next.js guide](/next/nextjs) has both files. + +## 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 Studio 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 [reference](/next/standalone); `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 configuration in your app. First, the pointer must name your site's own Deco API host, which `remoteLoader` looks up with your token. Any other host is ignored, so a crafted pointer can't make your server fetch from somewhere else. Second, the pointer carries a token signed by Studio, which the Deco API checks before serving the draft. + +If a website wants to show drafts on its preview domain only, it checks the request's host before calling `forDraft`: + +```ts +const host = new URL(request.url).host +const pointer = host === 'preview.store.example.com' ? draftPointer(request) : null +const client = pointer ? cms.forDraft(pointer) : cms.forRelease() +``` + +<Callout type="warning">**The token is the only secret.** The site holds no other credential. Invalid or unexpected pointers are ignored and the request falls back to published content, never to an error page.</Callout> + +## Connect Studio to your site + +Studio opens previews on your site's URL, so the site has to be set up in the hosted Deco CMS with your token (creating a site and a token for this API is on the [Roadmap](/roadmap#roadmap-platform--build-the-deco-api-release-service)). Support for sites on this API, including previews inside Studio's frame, is still being built; see the [Roadmap](/roadmap#roadmap-blockers--make-draft-preview-work-inside-studio-s-iframe). To try the mechanism locally: with only local files, `forDraft` returns the release, because the content module has no drafts. Testing a real draft needs a token. diff --git a/site/content/next/quickstart.mdx b/site/content/next/quickstart.mdx new file mode 100644 index 00000000..5ff39da4 --- /dev/null +++ b/site/content/next/quickstart.mdx @@ -0,0 +1,180 @@ +--- +title: Quickstart +group: Getting started +order: 2 +--- + +# Quickstart + +<Callout>**Status.** Deco Blocks 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. Ramping the new checkout from 10% to 25% means a pull request and a deploy. In this guide you'll move those numbers into content: a JSON file that you, an AI agent, or an editor in Deco Studio can change, while the function stays exactly as it is. It 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, and [Blocks](/next/model) shows how. + +**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`. +- Optional: a site on the hosted Deco CMS connected to this repository, and its token, if you want to try Studio or publish without a deploy. Creating a site and issuing a token for this API isn't available yet (see the [Roadmap](/roadmap#roadmap-platform--build-the-deco-api-release-service)). Everything on this page works without it. + +```bash +npm install @decocms/blocks +npm install -D @decocms/blocks-cli +``` + +The second package provides the `deco` command. + +## 1. Make the function configurable + +Any function with a typed first parameter can become a block function. Register it in a 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`. The CLI reads this file, and your app hands it to `createCMS`. + +```ts title="blocks.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. [Schema generation](/next/schema) lists every tag. + +```bash +npx deco schema +``` + +It finds the block map in `blocks.ts` or `blocks.tsx`, in the project root or in `src/` (`--entry` points it at any other file; see [Schema generation](/next/schema)), and writes `.deco/schema.json`. Commit the file. If the repository is connected to the hosted Deco CMS, then once that code is deployed, Studio shows each experiment as a number field clamped to 0–100. Without it, the schema still describes the content you or an agent write by hand. + +## 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 Studio (hosted Deco CMS).** Pick the `experiments` block type, fill in the form, and save. Studio commits the same file. + +Both routes produce the same file in `.deco/blocks`, which is where the CLI looks for content (its `--root` flag points it at another `.deco` folder): + +```json title=".deco/blocks/Experiments.json" +{ + "__resolveType": "experiments", + "newCheckout": 10, + "stickyHeader": 50, + "freeShippingBanner": 0 +} +``` + +```ts +// Means +experiments({ newCheckout: 10, stickyHeader: 50, freeShippingBanner: 0 }) +``` + +The file is a saved entry: one named piece of content that code, or other content, can refer to. Its file name without `.json` is its name (see [Content and loaders](/next/content)). `__resolveType` is a reserved key (the underscores keep it from clashing with your fields) that names the function to call by its block type. The other keys are that function's inputs. + +Two names are in play, on purpose: `experiments` is the block type and names a function, and `Experiments` is the entry name and names this one piece of saved content. You could save several entries that call `experiments`, such as `HolidayExperiments`. Names are case-sensitive, so they don't clash. + +## 4. Generate the content module + +The SDK never reads files itself, so it runs the same way in any JavaScript runtime. Instead, the CLI turns your content files into a module your app imports, the **content module**: + +```bash +npx deco content +``` + +It writes `.deco/blocks.gen.ts`, which imports each JSON file in `.deco/blocks` separately and exports a **snapshot**, `{ revision, blocks }`: one fixed copy of all your saved entries, plus its revision, an ID the CLI computes from their contents. The file is generated, so add it to `.gitignore`, as you would TanStack Router's `routeTree.gen.ts`. For this guide, running `npx deco content` once is enough. Rerun it when you add or remove a file in `.deco/blocks`; editing an existing file needs no rerun. In an app with dev and build scripts, add the two commands to `package.json` so they run first (see [The content module](/next/content#the-content-module)): + +```json +"predev": "deco schema && deco content", +"prebuild": "deco schema && deco content" +``` + +## 5. Read the content from your code + +Create the CMS once, at module scope, with your block map and the content module (to connect the hosted Deco CMS or another store, see [Configuring content](/next/content#configuring-content)). + +```ts title="cms.ts" +import { createCMS } from "@decocms/blocks"; +import content from "./.deco/blocks.gen"; +import blocks from "./blocks"; + +export const cms = createCMS({ blocks, content }); +``` + +Then ask it for a client, the object you read content with. `forRelease()` returns a client over the published content, the release; with plain files, the release is simply what's in the content module. Its sibling `forDraft()` reads unpublished drafts for previews (see [Preview](/next/preview)). Make one client per request: a client reads one consistent version of the content for its whole life. + +Fetch the entry by name. The client runs `experiments` on the saved inputs, so you get the same value as calling `experiments()` yourself. An entry name is just 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 entry 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 (no need to rerun `deco content`: only adding or removing a file needs that): `newCheckout` is `true` every time. You changed what the code does without touching the code. + +That's it. The function didn't change, but its inputs now live in a file that editors change through a form and agents change directly, and every change is a commit. 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 [Block maps](/next/manifest)). + +## Next steps + +- [Blocks](/next/model): how blocks nest and refer to each other, which is how a page is built from a list of blocks. +- [Content and loaders](/next/content): where saved entries come from, and how to read them from somewhere other than `.deco/blocks`. +- [Schema generation](/next/schema): how your types become editor forms, and the JSDoc tags that shape them. +- [Preview](/next/preview): render drafts in your real pages, no deploy. +- [Block maps](/next/manifest), when you need more: lazy loading and naming types. +- [Matchers and variants](/next/variants): let content pick different values per visitor, date or experiment. +- [Routes and redirects](/next/content-types): the content types that give pages and redirects their URLs. +- [Rendering](/next/rendering): the two ways a block becomes UI. +- [Routing](/next/routing): match URLs to pages and redirects. +- Framework guides for [Next.js](/next/nextjs) and [TanStack Start](/next/tanstack-data). diff --git a/site/content/next/releases.mdx b/site/content/next/releases.mdx new file mode 100644 index 00000000..35cce654 --- /dev/null +++ b/site/content/next/releases.mdx @@ -0,0 +1,44 @@ +--- +title: Releases and deployment +nav: Releases & deployment +group: Production +order: 16 +--- + +# Releases and deployment + +Your code ships when you deploy. Your content can ship the same way, or on its own. This page covers how a content change reaches visitors, how each response stays consistent, and what happens when the network fails. It rests on one idea from [Content and loaders](/next/content): a revision identifies one exact content map. `deco content`, `remoteLoader` and the Deco API all compute it as the same hash of the whole map, so two copies of the content are the same exactly when their revisions match, and `remoteLoader` recognizes the build's content module as the current release and skips a download. A loader you write only has to change its revision whenever content changes (see [Write your own loader](/next/content#write-your-own-loader)). + +| Content from | How a content change reaches visitors | +|---|---| +| The content module | The content ships with the app: `.deco/blocks.gen.ts`, which `deco content` generates from your files, is bundled like any other import. On its own, changing content means a deploy (in development, editing a file hot-reloads). With a token, it's the fallback: what a server serves until it has fetched its first release. | +| `content` plus `token` (`remoteLoader`) | The Deco API serves each commit of the production branch as a release that never changes. The loader keeps the current one in memory, with the content the build shipped with as the fallback. A content change reaches visitors without a deploy. | +| A `Loader` you write | Whatever your store does. Content reaches visitors when the loader returns a new revision; if it can go stale, implement `update()`, which the CMS calls once a minute at an idle moment (see [Write your own loader](/next/content#write-your-own-loader)). | + +A revision never changes once it exists: change any entry and you get a new revision. That makes a revision a safe cache key everywhere. + +## Publishing + +With the hosted Deco CMS, publishing is committing. A developer or an agent edits the entry files in your content directory (`.deco/blocks`, or the folder you give the CLI's `--root`) and pushes; an editor saves in Studio, which commits to the same repository. (If you load from your own store, see [Content and loaders](/next/content).) Either way, the production branch (the branch the hosted Deco CMS publishes from, usually `main`) moves. + +Publishing takes seconds: the Deco API serves that commit as the current release as soon as it lands. Each server checks for a new release about once a minute, at an idle moment, so a publish is live everywhere within about a minute. Calling `cms.update()`, for example from a webhook, 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 (see [Loaders](/next/standalone#loaders)). Requests never wait on this: `load()` always returns from memory, and the check runs in the background. Step by step: [under the hood](/next/loader-internals). + +Because content and code share one history, a deploy and a content change are the same kind of event: a commit. Rolling content back is reverting one. + +Publishing without a deploy means the code that's already running renders the new content. Nothing at runtime checks that the two fit: the Deco API serves each commit as it lands, and `remoteLoader` doesn't compare schemas. They're checked before anything merges instead; the rules and a CI workflow are in [Keep the schema compatible](/next/schema#keep-the-schema-compatible). + +## Fallback + +Until a server has fetched its first release (a new server, or one that has never reached the Deco API), `remoteLoader` serves its fallback's content. If the Deco API becomes unreachable later, the server keeps serving the newest release it already has. With the content module, that's the content the build shipped with, the content this deploy was tested against, so it's always there and always compatible with the code. If your `content` is a loader you wrote (see [Write your own loader](/next/content#write-your-own-loader)), keeping those guarantees is up to you. `remoteLoader` always switches between whole revisions, never mixing entries from two of them, because a mix can break references or bring back deleted entries. The loader reports the revision it actually served. + +## One revision per response + +Because new content can arrive in the background at any moment, two reads a few milliseconds apart could see different revisions; a header from one and a footer from the other make an inconsistent page. A client 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. (A way to read and pin the served revision is on the [Roadmap](/roadmap#roadmap-api--add-a-revision-handle).) + +A revision only selects content; it isn't a secret and unlocks nothing. Unpublished drafts are protected by the signed token in the draft pointer (see [Preview](/next/preview)). + +## Deploying on Workers + +Workers need nothing special, except on sites with several megabytes of content (see “Large content on Workers” under [Create the CMS](/next/tanstack-data#2-create-the-cms)). The content module is bundled like any other import, and because Workers run no timers between requests, the SDK runs a due release 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 `cms.update()`. The [deploying checklist](/next/tanstack-data#deploying-checklist) in the TanStack Start guide lists every step. diff --git a/site/content/next/rendering.mdx b/site/content/next/rendering.mdx new file mode 100644 index 00000000..57f3f00b --- /dev/null +++ b/site/content/next/rendering.mdx @@ -0,0 +1,38 @@ +--- +title: Rendering +group: Websites +order: 10 +--- + +# Rendering + +A block function can return anything. For a website it usually returns something that becomes UI, and there are two ways to get that to the browser. + +- **Descriptors (data mode):** the block returns plain JSON such as `{ component: "product", props: {…} }`, and a view registry in your app (an object mapping 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. + +The CMS doesn't care which you pick; it returns whatever your block function returns. Each integration uses one of the two: + +| Integration | Block returns | Streaming | Ships to the browser | +|---|---|---|---| +| [Next.js App Router](/next/nextjs) | JSX | Server Component Suspense boundaries | Client Components | +| [TanStack Start, data](/next/tanstack-data) | Serializable descriptors | Deferred promises with `Await` | The view registry and its components | +| [TanStack Start, RSC](/next/tanstack-rsc) | JSX | RSC stream (Flight) | Client references and the RSC runtime | + +## 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 integrations, 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 directly. On the server, importing it gives you a placeholder React can render but your code can't call, and the CMS calls every block function. Wrap it instead: `cart: (input: CartProps) => <CartButton {...input} />`. + +## 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)` returns `[value, error]`; render a fallback when `error` is set. Errors thrown later while React renders need an error boundary. +- **Cancellation is your app's job.** The 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` result) before you stream. diff --git a/site/content/next/router-internals.mdx b/site/content/next/router-internals.mdx new file mode 100644 index 00000000..b714eb85 --- /dev/null +++ b/site/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` 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. + +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. 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. **Reject 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), throw with both names in the message. The CLI runs the same build at schema time, so `deco schema` in CI reports it: a pull request check catches it before merge, and a check on pushes to the production branch reports a Studio commit that went straight there, after it's live (see [Keep the schema compatible](/next/schema#keep-the-schema-compatible)). +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. If a branch dead-ends, go back up and try the parameter child you skipped. Each node has at most one literal match and one parameter, 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 the literal child before the parameter child at every node is exactly "exact paths win over templates", per segment rather than per path: `/blog/archive` beats `/blog/:slug` for `/blog/archive`, and `/blog/:slug` still serves `/blog/hello-world`. No sorting, no regex. +5. **Redirects are a second trie**, checked first, with the same rules. A hit fills the parameters 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. 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/site/content/next/routing.mdx b/site/content/next/routing.mdx new file mode 100644 index 00000000..10421797 --- /dev/null +++ b/site/content/next/routing.mdx @@ -0,0 +1,141 @@ +--- +title: Routing +group: Websites +order: 11 +--- + +# Routing + +The CMS doesn't route. It lists and resolves. Routing is three steps in your request handler: list the types you want at URLs, hand them to `matchRoute`, resolve the winner. Do all three with one client, so they see the same content even if editors publish mid-request (see [One revision per response](/next/releases#one-revision-per-response)). + +## The example used here and in the guides + +This page and the framework guides share these files: the model in `src/model.ts`, the post type in `src/post.ts` (from [Your own routable types](/next/content-types)), and two content entries, stored as files in `.deco/blocks`, which `deco content` turns into the content module (see [Content and loaders](/next/content)). Each guide's block map (`src/blocks.tsx` or `src/blocks.ts`) declares `seo` and its own `product`. Pages and redirects are built-in content types (see [Content that is only data](/next/schema#content-that-is-only-data)); the guides override `page` with `StoredPage`, below, to narrow its `sections` field, so Studio's form matches what the guide code reads. + +In `src/model.ts`, `ProductDescriptor` is the descriptor a `product` block returns in data mode (see [Rendering](/next/rendering)), and `StoredPage` is a page exactly as saved, before any of its blocks run. + +```ts title="src/model.ts" +import type { Block, Page } from '@decocms/blocks' + +export interface SEO { title: string; description: string } +export interface ProductProps { id: string; name: string; price: number; currency: string } +export interface ProductDescriptor { component: 'product'; props: ProductProps } + +// A saved page, as client.list returns it by default (nothing run). +// Overrides the built-in Page to keep sections a plain array: no variant around the whole list. +export interface StoredPage extends Page { + sections: Block[] +} +``` + +`StoredPage` narrows the built-in `Page` type from [Routes and redirects](/next/content-types): it doesn't allow a [variant](/next/variants) around the whole `sections` list. Each guide's block map exports `export type Content = { page: StoredPage }`, so `deco schema` gives Studio the narrower form. If you keep the built-in `Page`, which does allow it, resolve the list first, `const [list] = await client.resolve(page.sections)`, which picks the variant and also runs every block in it, or store the variant's values as entry names as in [Every variant resolves](/next/variants#every-variant-resolves). + +```json title=".deco/blocks/ProductPage.json" +{ + "__resolveType": "page", + "name": "Product page", + "path": "/:slug/p", + "seo": { + "__resolveType": "seo", + "title": "Summer shirt | Deco example", + "description": "A small example of independently resolved fields." + }, + "sections": [ + { "__resolveType": "product", "id": "details", "name": "Summer shirt", "price": 49, "currency": "USD" }, + { "__resolveType": "product", "id": "related", "name": "Related shirt", "price": 39, "currency": "USD" } + ] +} +``` + +```ts +// Means (page isn't a function, so the object stays data; the blocks inside it are calls) +{ + __resolveType: "page", + name: "Product page", + path: "/:slug/p", + seo: seo({ title: "Summer shirt | Deco example", description: "A small example of independently resolved fields." }), + sections: [ + product({ id: "details", name: "Summer shirt", price: 49, currency: "USD" }), + product({ id: "related", name: "Related shirt", price: 39, currency: "USD" }), + ], +} +// product is whatever each guide's block map registers: JSX in Next.js and TanStack RSC, a descriptor in data mode +``` + +```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 page uses literal product values to keep the example small. A real page would use a block that fetches the product for the slug, reading `match.params` from your app's request state (see [Block maps](/next/manifest)). + +## Route a request + +With those files in place, this is the whole request handler: + +```ts +import { matchRoute, type Redirect } from "@decocms/blocks"; +// 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, and catch matchRoute's throw (see below) + +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": + switch (match.entry.__resolveType) { + case "page": { + const blocks = match.entry.sections.map((b) => client.resolve(b)); // start them all + const [seo] = await client.resolve(match.entry.seo); // then await + return render(seo, blocks); + } + case "post": + return renderPost(match.entry); // data: nothing to resolve + } +} +``` + +Mount this handler as your app's catch-all route, the one that runs when no other route matches. + +| `kind` | Fields | +|---|---| +| `"match"` | `entry` (the stored route you passed in) and `params` from the path template. | +| `"redirect"` | `location` with parameters filled in, and `status` (`301` or `302`). | +| `"not-found"` | None | + +<Callout>**`matchRoute` is a helper, not a requirement.** The CMS has no idea what a URL is. If your framework already routes, or you'd rather read a page by name (`client.resolve("SummerPage", { run: false })`, then resolve its fields) or find it by a field of your own, skip it. It exists because "an editor typed a path into Studio 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`. `match.params` holds the URL parameters; hand them to your code however your app shares request state. The CMS never injects them into blocks. + +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`. The [Block maps](/next/manifest) page covers request-scoped state in more detail. + +Matching is a lookup, not a scan: entries are compiled into a segment trie (a tree keyed by path segment), so a lookup costs the URL's depth, not the number of routes. The trie is cached per `routes` array object, so passing the same array again skips the build. The handler above builds a new array per request, which rebuilds the trie each time; that's one pass over the paths, fine for most sites. Reusing one array per revision needs the client to expose its revision, which is on the [Roadmap](/roadmap#roadmap-api--add-a-revision-handle). The algorithm is described [under the hood](/next/router-internals). + +## Path templates and match order + +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"`. Redirects are checked before routes, so a redirect overrides a route at the same path. Within each group, exact paths win over templates. Two entries with the same path, or two templates that can match the same URL, make `matchRoute` throw. `deco schema` reports the same conflict, so run it in CI on every commit, including the content commits Studio makes. Content published without that check can still make `matchRoute` throw at request time, so catch the error and return a 500 rather than crash (removing the throw is on the [Roadmap](/roadmap#roadmap-api--add-a-splat-to-matchroute-and-stop-request-time-throws)). Serve framework endpoints, API routes and static assets from their own routes, so they never reach this catch-all. + +## Linking and listing + +Because the URL is a field, linking to an entry is reading it, and listing a routable type gives you an index for free: + +```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> + +const match = matchRoute("/blog/hello-world", { routes: posts }); +// { kind: "match", entry: { __resolveType: "post", name: "Hello, world", path: "/blog/hello-world", … }, params: {} } +``` diff --git a/site/content/next/schema.mdx b/site/content/next/schema.mdx new file mode 100644 index 00000000..6d3ce060 --- /dev/null +++ b/site/content/next/schema.mdx @@ -0,0 +1,183 @@ +--- +title: Schema generation +group: Core concepts +order: 5 +--- + +# Schema generation + +Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. It reads the file that exports your block map, the `blocks.ts` from the [quickstart](/next/quickstart), and writes `.deco/schema.json`. It looks for that file as `blocks.ts` or `blocks.tsx`, in the project root or in `src/`. If your block map lives somewhere else, pass its path with `--entry`, for example `deco schema --entry app/cms/blocks.ts`: + +```bash +npx deco schema +``` + +For each block type in the map, the CLI reads the type of the function's first parameter and turns it into a form definition. Commit `.deco/schema.json` with your code, and run the command in CI so the schema never drifts from your types (see [Keep the schema compatible](#keep-the-schema-compatible)). + +The CLI also checks your content model: it fails if a saved entry in `.deco/blocks` has the name of a block type, or if an alias (a second name for a block type, used when you rename one; see [Renaming and migrating](/next/adoption)) collides with either. + +## The two CLI commands + +The CLI has two commands, and each does one job: + +- **`deco schema`** turns the types in your block map into `.deco/schema.json`. Commit this file. +- **`deco content`** turns the files in `.deco/blocks` into the [content module](/next/content#the-content-module), `.deco/blocks.gen.ts`. It's generated, so gitignore it. + +```bash +deco schema [--entry <path>] [--root <dir>] [--watch] [--check <base>] +deco content [--root <dir>] [--watch] +``` + +- `--entry <path>` (`deco schema` only): the file that exports your block map. By default the CLI looks for `blocks.ts` or `blocks.tsx` in the project root or in `src/`. +- `--root <dir>`: the `.deco` folder to use (by default `./.deco`). +- `--watch`: regenerate as files change. +- `--check <base>` (`deco schema` only): compare the schema your code generates now with an earlier one, and fail on breaking changes. It writes nothing, and it isn't built yet (see the [Roadmap](/roadmap#roadmap-cli--add-deco-schema-check-a-compatibility-check)). See [Keep the schema compatible](#keep-the-schema-compatible). + +There's no separate dev command: run both before your dev server and your build. Why the content module is gitignored, and what reloads without a rerun, is in [The content module](/next/content#the-content-module). + +```json title="package.json" +{ + "scripts": { + "predev": "deco schema && deco content", + "prebuild": "deco schema && deco content" + } +} +``` + +## Keep the schema compatible + +With the hosted Deco CMS, content goes live without a deploy, so the code that's already running renders it. That's the point of publishing without a deploy. Nothing at runtime checks that the two fit: the Deco API serves each commit as it lands, and `remoteLoader` doesn't compare schemas. They're kept compatible before anything merges, in two directions. (`deco schema --check`, which the first one relies on, is planned; see the [Roadmap](/roadmap#roadmap-cli--add-deco-schema-check-a-compatibility-check).) + +- **Backward compatible: new code still renders all existing content.** A code change must not break content that's already saved. `deco schema --check <base>` enforces this. It generates the schema from your code and compares it with `<base>`, the path to an earlier `.deco/schema.json`, such as the one on `main`. It lists every breaking change and exits with a non-zero code if it finds one. It writes nothing. +- **Forward: already-deployed code renders new content.** This holds as long as content only uses block types and fields that the deployed code has. Studio builds its forms from whichever schema you point it at, usually the one on your main branch (see [What Studio reads](/next/studio#what-studio-reads)). Keep that schema in step with what's deployed, and deploy the code first, then publish content that uses it. An entry written by hand or by an agent that uses a type the deployed code lacks fails that block with `UNKNOWN_BLOCK` (see [Errors](/next/standalone#errors)) until the code ships. Rolling code back is the exception: content that already uses the newer types or fields fails on the older code until you redeploy or revert that content, so check what's published before you roll back. + +What `--check` treats as breaking: + +| Change | Breaking? | +|---|---| +| Removing a block type (a function in the block map) or a `Content` type | Yes. `--check` compares schemas only, so any removal counts, whether or not saved content uses the type. Remove a type in a later change, once no content uses it. To rename a block type, keep the old name as an alias (see [Rename a type with an alias](/next/adoption#rename-a-type-with-an-alias)); the old name stays in the schema, so the check passes. Don't rename a `Content` type this way yet: the check would pass, but Content aliases don't work at runtime (see the [Roadmap](/roadmap#roadmap-cli--emit-a-runtime-alias-table)). | +| Removing or renaming a field | Yes. Fields have no aliases, so rename in steps: add the new name as an optional field, move the content over, then remove the old field once no content uses it. | +| Adding a required field, or making an optional field required | Yes: existing entries don't have it | +| Narrowing a field's type: a `string` becoming a union of allowed values, a union losing a member, a tighter limit (a lower `@maximum` or `@maxLength`, or a higher `@minimum` or `@minLength`) | Yes: saved values may fall outside it | +| Adding a block type, a `Content` type or a built-in type | No | +| Adding an optional field | No | +| Widening a field's type: a union gaining a member, a required field becoming optional | No | + +Run it in CI on every pull request. This GitHub Actions workflow first checks that the committed `.deco/schema.json` is up to date: it regenerates the schema and fails if the file changed or was never committed. It then compares the schema with the one on the pull request's target branch. On the first pull request that adds a schema there's nothing to compare with yet, so it skips that step. Any other problem, such as a missing branch, fails the job instead of skipping it: + +```yaml title=".github/workflows/schema.yml" +name: Schema +on: pull_request + +jobs: + schema: + runs-on: ubuntu-latest + env: + BASE_REF: ${{ github.base_ref }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + - run: npm ci + - name: The committed schema matches the code + run: | + npx --no-install deco schema + if [ -n "$(git status --porcelain -- .deco/schema.json)" ]; then + git diff -- .deco/schema.json + echo ".deco/schema.json is missing or out of date: run deco schema and commit it." + exit 1 + fi + - name: Fetch the base schema + run: | + git rev-parse --verify --quiet "origin/$BASE_REF" > /dev/null \ + || { echo "origin/$BASE_REF not found"; exit 1; } + if git cat-file -e "origin/$BASE_REF:.deco/schema.json" 2> /dev/null; then + git show "origin/$BASE_REF:.deco/schema.json" > base-schema.json + fi + - name: No breaking changes + run: | + if [ -f base-schema.json ]; then + npx --no-install deco schema --check base-schema.json + else + echo "No .deco/schema.json on $BASE_REF yet; skipping the compatibility check." + fi +``` + +`--no-install` makes `npx` fail if `@decocms/blocks-cli` isn't installed, instead of downloading some other package named `deco`. If you use `--root`, pass it to both `deco schema` calls and replace `.deco/schema.json` in the workflow with `<dir>/schema.json`. Make the job a required check in your branch protection rules, so a failing pull request can't merge. + +Studio's saves are commits to the same repository. They only change content, never the schema, so `--check` always passes them, and `deco schema` still checks the content model (entry names, aliases and route collisions). When Studio saves through pull requests, this workflow checks them before merge. When it commits straight to your production branch, they're live as soon as they land, so a check can only report a problem afterwards: add `push: { branches: [main] }` under `on` and guard the two comparison steps with `if: github.event_name == 'pull_request'`, since a push has no target branch. + +## Annotate fields with JSDoc + +The TypeScript type decides what kind of field each input gets: a `string` is a text box, a `number` a number field, a union of string literals a select, an array a list editors can add to and reorder. JSDoc tags on a field refine the form: + +| 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` | `@format rich-text` | +| `@ignore` | Leaves the field out of the form | `@ignore` | + +Put each tag on its own line. This type: + +```ts +export interface HeroProps { + /** + * @title Headline + * @maxLength 60 + */ + title: string; + /** + * @title Body + * @format rich-text + */ + body?: string; + /** + * @title Ends on + * @format date + */ + endsOn?: string; +} +``` + +becomes a form with a *Headline* text box limited to 60 characters, an optional *Body* rich-text editor, and an optional *Ends on* date picker. + +## Content that is only data + +A page, a blog post, a redirect: these have a shape but no function. They're never resolved themselves: your app reads them, by type with `client.list("post")` or by URL with `matchRoute` (see [Routing](/next/routing)), and resolves the blocks *inside* them. They still need a schema so Studio can create them, so declare them for the CLI in a type-level map next to the block map. `deco schema` always includes two built-in content types, `page: Page` and `redirect: Redirect`, so you declare only your own, like `post: Post`. `Page`, `Redirect` and `Route` are exported from `@decocms/blocks`. + +```ts title="blocks.ts" +import type { Blocks } from "@decocms/blocks"; +import type { Post } from "./post"; +import seo from "./seo"; +import hero from "./hero"; + +export default { seo, hero } satisfies Blocks; // code: a schema per function parameter + +export type Content = { // data: a schema per type + post: Post; // page and redirect are built in +}; +``` + +The CLI reads both exports. Keys of the default export get a schema from each function's parameter type; keys of `Content` get one from the type itself. The two share one namespace, and a key in both is an error. The CMS never sees `Content`: `list("page")` works because the entries exist, and resolving a page means resolving its fields, not the page. + +**Override or opt out.** Declaring a built-in key in `Content` replaces it. Extend the exported type to add fields, or to narrow `sections`, a page's ordered list of blocks: + +```ts +import type { Page } from "@decocms/blocks"; + +interface StorePage extends Page { theme: "light" | "dark" } + +export type Content = { page: StorePage; post: Post }; +``` + +An app that isn't a website opts out with `page: never` or `redirect: never`. Either way, only the schema changes: the CMS never reads `Content`. + +Studio learns the shape of your content only from the schema this command writes. What it looks for, which endpoints it calls on your site, and how the CLI bridges today's Studio conventions are covered [under the hood](/next/studio). diff --git a/site/content/next/standalone.mdx b/site/content/next/standalone.mdx new file mode 100644 index 00000000..a71bb2ae --- /dev/null +++ b/site/content/next/standalone.mdx @@ -0,0 +1,222 @@ +--- +title: API reference +group: Reference +order: 12 +--- + +# API reference + +Everything on this page is exported from `@decocms/blocks`. The built-in matchers (`always`, `never`, `date`) are in `@decocms/blocks/matchers`. + +## `createCMS(config)` + +`createCMS` returns a **CMS**: your block map plus your content. Create it once, at module scope; it returns the same instance for the same configuration (see [One instance per process](#one-instance-per-process)). For each request, ask it for a **client**: `forRelease()` for visitors, `forDraft(pointer)` for a preview. A client loads one revision on first use and reads only that revision afterwards, so everything in one response agrees. Every later call, `list` or `resolve`, reuses that content, so a page's blocks can stream as separate calls; the next request's client sees the next revision, so don't share a client across requests. A client also remembers what it has already resolved: an entry referenced three times runs its function once per client. That memory is tied to the client and to the block map object (results are kept per function of the map you passed), so a map rebuilt inside your handler starts with an empty cache; build the map once at module scope. Clients are cheap to create, because the CMS caches the content. + +```ts +function createCMS(config: { + blocks: Blocks; + content: Snapshot | Loader; // the content module (.deco/blocks.gen.ts), or any Loader + token?: string; // hosted releases over content: remoteLoader(content, { token, interval }) + interval?: number; // ms between release checks; default DECO_CONTENT_INTERVAL or 60_000; minimum 60_000 +}): CMS; + +interface CMS { + forRelease(): Client; // a client reading the current release + forDraft(pointer: string): Client; // a client reading the draft a pointer names; an invalid pointer behaves like the release + update(): Promise<{ updated: boolean }>; // check for a new release now (manual refresh); checks also run on their own +} + +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[]>>; +} +``` + +`content` takes the content module or a `Loader`; `token` and `interval` add hosted releases. See [Configuring content](/next/content#configuring-content). The [rule from Blocks](/next/model#the-rule) is how a client resolves. + +## Loaders + +One interface. `load()` with no argument is production; `load(pointer)` is a draft. `update()` checks for newer content. Which one to use, and how to write your own, is in [Content and loaders](/next/content). + +```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()`. | Development, tests, and the fallback in production | +| `remoteLoader(fallback: Snapshot \| Loader, { token, interval? })` | The current release from the Deco API over the fallback, or the draft a pointer names. The token identifies the site; everything else comes from the Deco API. | Production | +| A `Loader` you write | Whatever your store returns (see [Write your own loader](/next/content#write-your-own-loader)). | KV, a database, or your own API | + +`remoteLoader(content, { token })`: `load()` never waits on the network. It returns what's in memory, the newest release fetched or else the fallback's content, and `update()` checks for newer content in the background. See [how it stays current](/next/loader-internals). `load(pointer)` fetches the draft a pointer names and caches it by version. If the draft can't be fetched, the client serves what a normal request would get, never a mix of draft and published entries. See [Preview](/next/preview). + +`remoteLoader` checks for a new release on its own: + +- every `interval`, which defaults to `DECO_CONTENT_INTERVAL` or 60 000 ms and is never below 60 000; +- each check one interval, plus or minus up to 10 s, after the previous one (60 s ± 10 s by default), fetching only the revision hash; +- when a check is due, at the next idle moment (see [Loader internals](/next/loader-internals)). On Cloudflare Workers, which run no timers between requests, it runs after the response inside `ctx.waitUntil` (`ctx` is the Worker's execution context; `waitUntil` lets work finish after the response is sent), so you never call `update()` yourself. + +`cms.update()` stays for a manual refresh, such as a webhook or an admin "refresh now" button; it refreshes only the server that runs it. It never throws. A loader you write that has `update()` gets the same idle schedule and `interval`. A snapshot, like the content module, never changes, and a loader without `update()` is always current by construction. + +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` and `remoteLoader` instances are process-wide singletons. Each is stored on `globalThis` under a `Symbol.for("decocms.blocks…")` key derived from its configuration (the site 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 a package loaded twice, by two bundles or by a dev reload (hot module replacement, HMR), still shares one cache and one poller. 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. Tests can start clean: + +```ts +function resetForTests(): void; // clears every stored instance +``` + +## Draft pointers + +A pointer is a string, `<host[:port]><path[?query]>@<version>`, that names where a draft lives and which version it is. Studio makes them; `forDraft` reads them. Two helpers cover everything in between: + +```ts +interface DraftPointer { + host: string; // host[:port] of the Deco API, e.g. "studio.decocms.com" + path: string; // starts with "/"; its query holds a token signed by Studio + version: string; // opaque, immutable: the branch head or ETag +} + +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. + +For websites, two more helpers do the handshake, so the parameter and cookie names never appear in app code: + +```ts +function draftPointer(request: Request | { url: string; headers: Headers }): string | null; +// ?__draft= from the URL first, then the deco-draft cookie. Null when neither is present. + +function draftCookie(request: Request | { url: string; headers: Headers }): string | null; +// When the URL carries ?__draft=, the Set-Cookie value that stores it (HttpOnly, SameSite=Lax, Path=/), +// or one that expires the cookie when the value is empty. Null on every other request. + +const DRAFT_COOKIE = "deco-draft"; // for frameworks whose cookie API has no Request, e.g. Next.js cookies() +``` + +Both take anything with a `url` and `headers`, so they work with a fetch `Request`, a Next.js `NextRequest`, or a framework's request wrapper. `DRAFT_COOKIE` is for the one place that has no request at all: reading the cookie in a Next.js Server Component. + +```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 +formatDraftPointer({ host: "studio.decocms.com", path: "/api/acme/decofile/store/main?token=…", version: "9f3c1a" }); +// "studio.decocms.com/api/acme/decofile/store/main?token=…@9f3c1a" +``` + +## `client.resolve(target, options?)` + +Loads the content once and applies the [registry rule](/next/model#the-rule) to the target: saved entries expand, and each function the target names in your block map runs with its resolved inputs. With `{ run: false }` it reads without running: entries expand, but no function runs. + +| 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 entry, with references expanded and nothing run | +| `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 | +| `resolve({ title: "Store" })` | The same object: it contains no blocks | + +A string target is always an entry 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 an entry name can't carry a static type; it isn't validated at runtime. + +## `client.list(type, options?)` + +Every saved entry whose `__resolveType` is `type` or an [alias](/next/adoption) 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 must name a function in your block map, or it fails with `UNKNOWN_BLOCK`. + +```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 [Content and loaders](/next/content)). 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 [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 } + | { kind: "not-found" }; +``` + +Redirects win over routes, exact paths win over templates, and two entries that can match the same URL throw. 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). + +## Types + +```ts +type Block = { __resolveType: string; [input: string]: unknown }; +type VariantOf<T> = T | Block; // T, or a block (usually a multivariate) that resolves to one + +// A block map: what blocks.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 + +interface Loader { + load(pointer?: string | null): Promise<Snapshot>; + update?(): Promise<{ updated: boolean }>; +} + +interface DraftPointer { host: string; path: string; version: string } + +interface Route { name: string; path: string } // extend it to give a type a URL + +// The built-in content types; override them in Content with an interface that extends them. +interface Page extends Route { seo: Block; sections: VariantOf<Array<VariantOf<Block>>> } +interface Redirect { from: string; to: string; permanent: boolean } + +type Match<T> = + | { kind: "match"; entry: T; params: Record<string, string> } + | { kind: "redirect"; location: string; status: 301 | 302 } + | { 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 + +Client calls never throw: `resolve` and `list` return `[value, null]` or `[null, error]`. The one exception in the SDK is `matchRoute`, which throws when two entries can match the same URL. That's a content bug `deco schema` also reports, so run it in CI on every commit, including Studio's (see [Keep the schema compatible](/next/schema#keep-the-schema-compatible)), and catch the throw in your handler (see [Path templates and match order](/next/routing#path-templates-and-match-order)). Check the error, not the value, because a block function can legitimately return `null`. + +| Code | When | +|---|---| +| `NOT_FOUND` | A string target or a reference names an entry that doesn't exist. | +| `UNKNOWN_BLOCK` | A name is neither a saved entry nor a type in `blocks`. | +| `CYCLE` | References lead back to an entry that's already being expanded. | +| `BLOCK_FAILED` | A block function threw. The original error is in `cause`. | +| `LOADER_FAILED` | The loader couldn't load the content. The original error 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/site/content/next/studio.mdx b/site/content/next/studio.mdx new file mode 100644 index 00000000..fb25fb29 --- /dev/null +++ b/site/content/next/studio.mdx @@ -0,0 +1,60 @@ +--- +title: Deco Studio compatibility +nav: Studio compatibility +group: Under the hood +kind: internals +order: 5 +--- + +# Deco Studio compatibility + +## What Studio reads + +Studio learns about your site from your site. Your app serves a few HTTP endpoints for it. The main one, `/live/_meta`, returns the schema the CLI wrote; Studio also calls your app to render previews (`/live/previews`), to run functions for pickers and the Run button (`/deco/invoke`), and to encrypt secret fields (`/live/invoke`). In today's packages, these handlers come from `@decocms/blocks-admin`; how the new API mounts them is on the [Roadmap](/roadmap#roadmap-studio-new). Studio also reads your content files in `.deco/blocks`. Which schema Studio uses is up to you: usually the one on your main branch, but you can point it at another. Everything Studio knows about the shape of your content comes from the schema, so it's all inferred from your types; the other endpoints run your code, they don't describe it. + +The schema file has two parts. `definitions` holds one JSON Schema per type: the form Studio shows. `manifest` lists every key of your block map and your `Content` type, grouped by kind, so Studio knows which keys render UI, which are rules, and so on. The group names are Studio's, inherited from Deco's previous framework. Each manifest entry points at its definition with a JSON Schema `$ref`. The CLI picks the group from the type: + +| Manifest group | What lands there | Studio uses it for | +|---|---|---| +| `sections` (Studio's legacy name) | Block functions that return JSX or a render descriptor | Studio's "add" catalog: the blocks an editor can add to a page | +| `matchers` | Block functions that return `boolean` | The rule picker on a variant | +| `loaders` | Every other block function. Studio calls these loaders, after the old framework's name for data functions; they're not the content loaders from [Content and loaders](/next/content). | Data fields that can point at a function | +| `pages` | `Content` types that extend `Route` | The page list, "new page", the URL field | +| `redirects` | `Redirect` | The redirects screen | +| `content` | Every other `Content` type | Plain entries: navigation, settings, posts | + +```json title=".deco/schema.json, abridged" +{ + "manifest": { + "blocks": { + "sections": { "hero": { "$ref": "#/definitions/hero" } }, + "matchers": { "always": { "$ref": "…" }, "never": { "$ref": "…" }, "date": { "$ref": "…" } }, + "loaders": { "seo": { "$ref": "…" }, "multivariate": { "$ref": "…" } }, + "pages": { "page": { "$ref": "…" } }, + "redirects": { "redirect": { "$ref": "…" } } + } + }, + "definitions": { "hero": { "type": "object", "properties": { "title": { "type": "string", "title": "Heading" } } } } +} +``` + +Three conventions follow from this, and they're the ones the rest of the doc already uses. A key is a **block type if it's in the manifest and a saved entry if it isn't**, so short names like `hero` are fine, as long as no saved entry uses the same name (see [Blocks](/next/model)). A variant is `{ "variants": [{ "rule": …, "value": … }, …] }`. 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. + +## Studio compatibility today + +Deco's previous framework named 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`. Today's Studio was built for that framework and still expects those names. It decides whether a key is a block type or a saved entry by checking for a file extension, and it finds pages, the `always` rule (the default variant), the `never` rule (how it hides a block) and `multivariate` (`website/flags/multivariate.ts`) by their old paths. + +Until Studio reads the manifest instead, the CLI bridges the gap: for the types Studio looks up by name, it also writes the old path into the manifest as an alias (a second key for the same type). Content Studio saves under an old name resolves through the [same alias mechanism](/next/adoption) your own renames use. Your code uses `page` and `always`; the manifest carries both spellings; nothing in the SDK changes. + +Two gaps remain. Redirects created in Studio are saved in the old nested shape, not the flat one from [Routes and redirects](/next/content-types): + +```json +// Saved by today's Studio +{ "__resolveType": "website/loaders/redirect.ts", + "redirect": { "from": "/campaigns/summer", "to": "/summer", "type": "permanent" } } + +// The shape these docs use +{ "__resolveType": "redirect", "from": "/campaigns/summer", "to": "/summer", "permanent": true } +``` + +`matchRoute` accepts both. And Studio's file-extension check must become a manifest lookup before a site can drop the aliases. Both are Studio work, tracked in the [Roadmap](/roadmap#roadmap-studio-legacy), not something sites do. diff --git a/site/content/next/tanstack-data.mdx b/site/content/next/tanstack-data.mdx new file mode 100644 index 00000000..69cb17f0 --- /dev/null +++ b/site/content/next/tanstack-data.mdx @@ -0,0 +1,234 @@ +--- +title: TanStack Start with data descriptors +nav: TanStack Start · descriptors +group: Framework guides +order: 14 +--- + +# TanStack Start with data descriptors + +There are two ways to render with TanStack Start: this guide, which works with any React setup, and the experimental [RSC guide](/next/tanstack-rsc); [Which mode to use](/next/rendering#which-mode-to-use) compares them. Here, block functions don't return JSX. They return a descriptor, a plain object such as `{ component: 'product', props }` (see [Rendering](/next/rendering)). Start sends a route's loader data to the browser as serialized data, and JSX can't make that trip, but a descriptor can. (Here "loader" is TanStack Router's route loader, the function that fetches a route's data. It isn't a content loader from [Content and loaders](/next/content).) In the browser, a view registry (`const views = { product: Product }`) maps each name to a component. Start streams each block's descriptor as it resolves, renders the views on the server, and hydrates them in the browser. It deploys to Cloudflare Workers. + +Editors choose page URLs in Studio, so your app can't know its routes ahead of time. One catch-all route receives every URL, asks `matchRoute` which page or redirect owns it, and renders that page's [blocks](/next/model#resolving-a-page-step-by-step). + +**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`, plus the CLI from [Quickstart](/next/quickstart); the [example project](/next/routing#the-example-used-here-and-in-the-guides); and `Product` 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. + +## 1. Return descriptors from blocks + +```ts title="src/blocks.ts" +import type { Blocks } from '@decocms/blocks' +import type { ProductDescriptor, ProductProps, SEO, StoredPage } from './model' + +export type Content = { page: StoredPage } + +export default { + seo: (input: SEO) => input, + product: (input: ProductProps): ProductDescriptor => ({ component: 'product', props: input }), +} satisfies Blocks +``` + +As in the Next.js guide, `Content` overrides the built-in `page` with the narrower `StoredPage`. Generate the schema from the map file (`deco schema` finds `src/blocks.ts`), and the content module (`.deco/blocks.gen.ts`, which `cms.ts` imports) from `.deco/blocks`, before the dev server and the build (see [The two CLI commands](/next/schema#the-two-cli-commands)): + +```json title="package.json" +{ + "scripts": { + "predev": "deco schema && deco content", + "prebuild": "deco schema && deco content" + } +} +``` + +Commit `.deco/schema.json` and gitignore `.deco/blocks.gen.ts` (see [The content module](/next/content#the-content-module)). + +## 2. Create the CMS + +`DECO_SITE_TOKEN` is your site's API key for the Deco API. On Workers, environment variables come from `env` in `cloudflare:workers`; locally, `wrangler dev` reads them from a `.dev.vars` file you keep out of Git. In production, set it with `npx wrangler secret put DECO_SITE_TOKEN`. + +```ts title="src/cms.ts" +import { createCMS, draftPointer } from '@decocms/blocks' +import { env } from 'cloudflare:workers' +import content from '../.deco/blocks.gen' +import blocks from './blocks' + +// Production reads releases from the Deco API and falls back to the content module. +export const cms = createCMS({ blocks, content, token: env.DECO_SITE_TOKEN }) + +// The client for this request: the draft it points at, or the current release. +export const client = (request: Request) => { + const pointer = draftPointer(request) + return pointer ? cms.forDraft(pointer) : cms.forRelease() +} +``` + +The content module is ordinary imports, so Vite bundles it into the Worker like any other module; nothing here is specific to Workers. Its revision is the same content hash the Deco API computes, so a new Worker instance whose build already holds the current release skips downloading it (see [Loader internals](/next/loader-internals)). Workers run no timers between requests, so when a release check is due, the SDK runs it 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 `cms.update()`. + +**Large content on Workers.** When you pass both `content` and `token`, the content module stays in memory as the fallback, alongside the release the loader fetched. Once you publish, the live release and the fallback are different revisions, so your content is in memory more than once. A Worker isolate has a 128 MB memory limit, and the bundled content also counts toward the Worker's script size limit. For a few hundred kilobytes of JSON this doesn't matter; for several megabytes, it can. On large sites, pass a [loader that reads from KV](/next/content#write-your-own-loader) as `content` instead of the content module. No copy is bundled into the Worker, and none is held in memory just because the module was imported: the fallback is read from KV only when it's needed. Once read, it's held in memory like any other content, so this removes the bundled copy, not every duplicate: + +```ts title="src/cms.ts (large sites)" +import { env } from "cloudflare:workers" +// kvContent: the KV loader from "Write your own loader" + +// The fallback comes from KV, not from the bundle +export const cms = createCMS({ blocks, content: kvContent, token: env.DECO_SITE_TOKEN }) +``` + +The trade-off: the fallback is now only as good as the KV key. Your deploy pipeline must write the current content to it before the new Worker serves traffic. Until a new isolate's first release check (about a minute), and whenever the Deco API can't be reached, reads come from the KV key. So if the key is missing, every new isolate fails its first reads with `LOADER_FAILED` until it has fetched a release, and keeps failing if it can't reach the API. A first-party `kvLoader` helper is planned (see the [Roadmap](/roadmap#roadmap-api--document-and-support-kv-only-content-for-large-workers-sites)). + +## 3. Match the URL to a page + +This helper is shared by both TanStack guides. `T` is what your `product` block returns: `ProductDescriptor` here, `ReactNode` in the [RSC guide](/next/tanstack-rsc), which reuses this file unchanged. The server function hands the incoming request to `client()`, and `draftPointer` finds the pointer in the URL or in the cookie from step 6. Each block promise resolves to the value, or to `null` if the block failed, so no internal error details reach the browser. + +```ts title="src/open-page.server.ts" +import { notFound, redirect } from '@tanstack/react-router' +import { matchRoute, type Redirect } from '@decocms/blocks' +import { client } from './cms' +import type { SEO, StoredPage } from './model' + +export async function openPage<T>(href: string, request: Request) { + const c = 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 + const blocks = page.sections.map((block, index) => ({ + key: `${href}:${index}`, + value: c.resolve<T>(block).then(([value, blockError]) => { + if (blockError) console.error(blockError) + return value + }), + })) + + // Every block has started before SEO is awaited. + const [seo, seoError] = await c.resolve<SEO>(page.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, your token 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 { ProductDescriptor } from './model' + +export const loadPage = createServerFn({ method: 'GET' }) + .inputValidator(z.object({ href: z.string().startsWith('/') })) + .handler(({ data }) => openPage<ProductDescriptor>(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 Product from '../Product' +import { loadPage } from '../page.functions' + +const views = { product: Product } + +export const Route = createFileRoute('/$')({ + loader: ({ location }) => loadPage({ data: { href: location.pathname + location.searchStr } }), + head: ({ loaderData }) => ({ + meta: loaderData + ? [{ 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}> + {(data) => { + if (!data) return <p role="status">This content is temporarily unavailable.</p> + const View = views[data.component] + return <View {...data.props} /> + }} + </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> + +## 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 token 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. +- **Drafts need no authorization code.** The pointer carries a token signed by Studio, and the Deco API checks it (see [Who may preview](/next/preview#who-may-preview)). + +## 6. Keep preview across navigation + +Studio opens your site with the [draft pointer](/next/preview) in the URL, but only on the first page. Append the draft cookie to the document response so every link the editor clicks stays in preview. Start's request middleware runs around every request (see [Keep the draft across navigation](/next/preview#keep-the-draft-across-navigation)): + +```ts title="src/start.ts" +import { createMiddleware, createStart } from '@tanstack/react-start' +import { draftCookie } from '@decocms/blocks' + +const draftCookieMiddleware = createMiddleware().server(async ({ request, next }) => { + const result = await next() + const cookie = draftCookie(request) // only set on the request Studio opens; expiring when leaving preview + if (cookie) result.response.headers.append('Set-Cookie', cookie) + return result +}) + +export const startInstance = createStart(() => ({ requestMiddleware: [draftCookieMiddleware] })) +``` + +## 7. 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 content fetch is shared by every request. See Cloudflare's [compatibility flags](https://developers.cloudflare.com/workers/configuration/compatibility-flags/). + +## Deploying checklist + +- Run `deco schema && deco content` before each build (the `prebuild` script). +- Pass `token` to `createCMS` so releases arrive without a deploy. +- Set the token with `npx wrangler secret put DECO_SITE_TOKEN`. +- Expect each new Worker instance (Cloudflare calls it an isolate) to serve the build's content module until its first check, or the content in your KV key if you pass a KV loader as `content` (write the key before the deploy). Checks run after responses, about once a minute. +- If your content is several megabytes, use a KV loader instead of the bundled content module, and write the KV key before the new Worker takes traffic (see [Create the CMS](#2-create-the-cms)). +- Keep both compatibility flags. +- The [Next.js guide](/next/nextjs) targets Node, not Workers. + +<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/site/content/next/tanstack-rsc.mdx b/site/content/next/tanstack-rsc.mdx new file mode 100644 index 00000000..96a559f1 --- /dev/null +++ b/site/content/next/tanstack-rsc.mdx @@ -0,0 +1,96 @@ +--- +title: TanStack Start with React Server Components +nav: TanStack Start · RSC +group: Framework guides +order: 15 +--- + +# 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> + +This guide reuses the [data guide](/next/tanstack-data)'s content, CMS and `openPage`, but the `product` block returns JSX instead of a descriptor. The server renders it as React Server Components and streams the result in React's wire format (Flight). Because the browser receives rendered output, it needs no view registry, and view code used only on the server never ships. + +## 1. Swap the product block + +Replace `src/blocks.ts` with the [Next.js guide](/next/nextjs)'s `src/blocks.tsx`. Keep `src/cms.ts`, `src/open-page.server.ts` and `src/start.ts` (the preview cookie) from the data guide as they are. `deco schema` finds `src/blocks.tsx` on its own, 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. + +```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<ReactNode | null> }) { + return (await value) ?? <p role="status">This content is temporarily unavailable.</p> +} + +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 + ? [{ 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 data 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' } }, + }, +}) +``` + +<Small>Also requires `@vitejs/plugin-rsc` 0.5.30 and `react-server-dom-webpack` 19.2.7. If one repository builds both the data and the RSC version, give each its own Vite config: the RSC plugins change how every component is bundled.</Small> diff --git a/site/content/next/troubleshooting.mdx b/site/content/next/troubleshooting.mdx new file mode 100644 index 00000000..7a031ced --- /dev/null +++ b/site/content/next/troubleshooting.mdx @@ -0,0 +1,27 @@ +--- +title: Troubleshooting +group: Production +order: 19 +--- + +# Troubleshooting + +Most problems come down to three facts: which revision this server is serving, which entry was asked for, and which block type it names. Until the client exposes its revision (see the [Roadmap](/roadmap#roadmap-api--add-a-revision-handle)), build the loader yourself (`const loader = remoteLoader(content, { token })`), pass it as `createCMS`'s `content`, and log `(await loader.load()).revision`; that's the revision a new client would read right now. To see an entry with references expanded but no function run, call `client.resolve(target, { run: false })`. To see it exactly as stored, open `.deco/blocks/<name>.json`. + +| Symptom | What to check | +|---|---| +| `UNKNOWN_BLOCK` | Compare `__resolveType` with your block map's keys, including case. Add an [alias](/next/adoption) for renamed types. If the function does a dynamic `import()`, confirm the imported module has a default export. | +| `NOT_FOUND` | A string target is always an entry name, never a block type. Confirm the entry exists in the served revision, that `createCMS`'s `content` is the content you expect, and that `deco content` ran after the file was added (see [Configuring content](/next/content#configuring-content)). 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. | +| My saved entry is ignored | A block type probably has the same name, and the function wins. Run `deco schema`, which reports the collision, and rename the entry. | +| Blocks come back as JSON instead of running | You called `client.list` without `{ run: true }` (it returns saved entries by default), or you passed `{ run: false }` to `resolve`. To run listed entries, pass `run: true`, or resolve the fields you need with `client.resolve`. | +| 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. | +| Wrong fields in the editor | Regenerate the schema from the right `blocks.ts`. Check the first parameter's type and its property JSDoc (see [Schema generation](/next/schema)). | +| Serialization error when sending a block's result to the browser | JSON can't carry functions or React elements. Return plain data (descriptors, see [Rendering](/next/rendering)), or use an integration that renders on the server, like [TanStack RSC](/next/tanstack-rsc) or [Next.js](/next/nextjs). | +| 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. | +| Content changes mid-response | Use one client for the whole response; a client never changes revision. If you wrote the loader, check that it never returns different content under an existing revision. | +| Stale or fallback content | Compare the revision your server serves (see above) 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 about a minute after publishing. See [Releases and deployment](/next/releases) and [how remoteLoader stays current](/next/loader-internals). | +| `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 the schema compatible](/next/schema#keep-the-schema-compatible). | +| A new content file isn't found in development | Run `deco content`, or keep `deco content --watch` running beside your dev server. Only adding or removing a file needs it; edits to an existing file hot-reload. | +| Warning about an instance with different options | `createCMS` (or `remoteLoader`) ran twice with the same token and content but different options, such as another `interval`. The first instance is kept. Create the CMS once, at module scope (see [One instance per process](/next/standalone#one-instance-per-process)). | +| 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/site/content/next/variants.mdx b/site/content/next/variants.mdx new file mode 100644 index 00000000..2885100b --- /dev/null +++ b/site/content/next/variants.mdx @@ -0,0 +1,133 @@ +--- +title: Matchers and variants +nav: Matchers & variants +group: Advanced +order: 8 +--- + +# Matchers and variants + +Editors often want content that depends on a condition: a banner that only shows during a campaign, a title that changes on a date, an experiment. Two small ideas cover all of it, and neither knows what a website is, so they work the same in a website, a mobile app, or a background job (a script or cron task). + +A **variant** is one of several alternative values for the same input, together with the rule that selects it. A **matcher** is a block function that returns a boolean, used as a variant's rule. A **multivariate** (after multivariate testing) is a block function that takes a list of variants, `{ rule, value }` pairs, and returns the first value whose rule is true. It's three lines, so you own it and can change how it picks. Because the CMS resolves inputs before calling a function, the multivariate never sees a matcher, only booleans. + +## The three matchers that ship + +They depend on nothing but the clock, so they're the only ones the framework provides: + +```ts +import { always, never, date } from "@decocms/blocks/matchers"; + +// always() => true the default rule of a variant +// never() => false Studio hides a block by wrapping it in a variant whose rule is never() +// date({ start?: string; end?: string }) => boolean, true when now is inside [start, end] +``` + +`start` and `end` are ISO 8601 strings. A date without a time, like `"2026-11-30"`, means midnight UTC, so add a time and offset (`"2026-11-30T23:59:59-03:00"`) to end on your own clock. + +Everything else that could gate content, a device, a cookie, a country, a user's plan, depends on where your app runs and how it sees its environment. Those are yours to write, below. + +## The multivariate is three lines you own + +```ts title="multivariate.ts" +export function multivariate<T>({ variants }: { variants: { rule: boolean; value: T }[] }) { + return variants.find((v) => v.rule)?.value; // first match wins, so put the always() variant last, as the fallback +} +``` + +```ts title="blocks.ts" +import type { Blocks } from "@decocms/blocks"; +import { always, never, date } from "@decocms/blocks/matchers"; +import { multivariate } from "./multivariate"; +import hero from "./hero"; + +export default { always, never, date, multivariate, hero } satisfies Blocks; +``` + +Editors compose them in Studio. Each variant is a tab, and its rule is picked from the matchers in your map. Variants are checked in order, so the one with the `always` rule goes last and catches everything else. A hero whose title changes for a campaign is a `hero` whose `title` is a `multivariate`: + +```json title="Saved as HomeHero" +{ + "__resolveType": "hero", + "title": { + "__resolveType": "multivariate", + "variants": [ + { "rule": { "__resolveType": "date", "start": "2026-11-20", "end": "2026-11-30" }, "value": "Black Friday starts now" }, + { "rule": { "__resolveType": "always" }, "value": "New season, new arrivals" } + ] + } +} +``` + +```ts +// Means +hero({ + title: multivariate({ + variants: [ + { rule: date({ start: "2026-11-20", end: "2026-11-30" }), value: "Black Friday starts now" }, + { rule: always(), value: "New season, new arrivals" }, + ], + }), +}) +``` + +Resolution does the rest: each rule runs and yields a boolean, `multivariate` runs with those booleans and yields a string, `hero` runs with a plain `title`. Nothing in `hero` knows a variant was involved. The same shape wraps any block in a page, or the page's whole `sections` list, which is how Studio does page-level variants. + +## Write your own matcher + +A matcher is any block function that returns a boolean. It reads whatever your app knows about the current request or user the way every other block does, through the context your runtime already provides: + +```ts title="matchers.ts (website)" +import { headers } from "next/headers"; + +// A website: the device from the user agent. +export async function device({ device }: { device: "mobile" | "desktop" }) { + const ua = (await headers()).get("user-agent") ?? ""; + return (/Mobi|Android/i.test(ua) ? "mobile" : "desktop") === device; +} +``` + +```ts title="matchers.ts (app)" +// A mobile app: the user's plan from app state. No request, no headers. +export const plan = ({ plan }: { plan: "free" | "pro" }) => session.current().plan === plan; +``` + +Add them to the map and Studio's rule picker shows them next to `date`. The CLI lists every block function that returns a boolean as a matcher, which is how Studio knows to offer it in the rule picker (see [what Studio reads](/next/studio#what-studio-reads)). Combinators are matchers too: `and`, `or`, and `not` are one-liners over booleans, if you want them. + +## Every variant resolves + +Inputs resolve before a function runs, so every variant's `value` resolves before `multivariate` picks one. For a title that's free. For two product queries it isn't, and a failure in a variant you didn't pick still fails the whole thing. + +The lazy form is to make the values *entry names* instead of inline blocks. A string isn't a block, so nothing runs; the multivariate returns a name, and your app resolves it: + +```json +{ + "__resolveType": "multivariate", + "variants": [ + { "rule": { "__resolveType": "date", "start": "2026-11-20", "end": "2026-11-30" }, "value": "BlackFridayHero" }, + { "rule": { "__resolveType": "always" }, "value": "DefaultHero" } + ] +} +``` + +```ts +// Means +multivariate({ + variants: [ + { rule: date({ start: "2026-11-20", end: "2026-11-30" }), value: "BlackFridayHero" }, + { rule: always(), value: "DefaultHero" }, + ], +}) // the values are entry names, not calls: only the rules and multivariate run + +// block is the JSON above, e.g. from a page's sections list; +// client.resolve accepts an entry name or an inline block +const [name, err] = await client.resolve(block); // "BlackFridayHero" +if (err) throw err; +const [hero] = await client.resolve(name); // only this variant ran +``` + +<Callout type="warning">**A variant is not access control.** It chooses between values that are all fine to compute and show. 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> + +If you cache rendered pages, at a CDN for example, anything a matcher looks at must be part of the cache key. Otherwise the first visitor's variant is cached and served to everyone: a page that varies by device would show the cached desktop hero to mobile visitors. See [Caching and observability](/next/operations). + +Nothing so far has assumed a website. The next part adds URLs. diff --git a/site/content/next/walkthrough.mdx b/site/content/next/walkthrough.mdx new file mode 100644 index 00000000..1b9ac9c6 --- /dev/null +++ b/site/content/next/walkthrough.mdx @@ -0,0 +1,14 @@ +--- +title: How resolution works +group: Under the hood +kind: internals +order: 2 +--- + +# How resolution works + +Here's the setup. Your block map registers two functions, `product-card` and `catalog-product`. Your content map holds two saved entries: `CurrentProduct`, which is `{ "__resolveType": "catalog-product", "slug": "summer-shirt" }`, and `SummerCard`, shown in step 1, which uses it. As code, `SummerCard` means `productCard({ title: "Summer collection", product: catalogProduct({ slug: "summer-shirt" }) })`. + +Step through `client.resolve("SummerCard")` to watch [the rule](/next/model#the-rule) at work, or switch to `{ run: false }` to see what a read returns. `product-card` can return a descriptor 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 /> diff --git a/site/content/v7/index.mdx b/site/content/v7/index.mdx new file mode 100644 index 00000000..18c06ab0 --- /dev/null +++ b/site/content/v7/index.mdx @@ -0,0 +1,20 @@ +--- +title: Deco Blocks v7 +nav: Overview +group: Getting started +order: 1 +description: Documentation for Deco Blocks v7, the current release. Being written; until then, the guides in the repository. +--- + +# Deco Blocks v7 + +These docs are being written. v7 is the current released version of the framework (the `@decocms/blocks`, `@decocms/blocks-admin`, `@decocms/blocks-cli`, `@decocms/tanstack` and `@decocms/nextjs` packages). Until its pages are here, the guides in the repository cover it: + +- [The repository README](https://github.com/decocms/blocks#readme): the packages and how they fit together. +- [Fast deploy](https://github.com/decocms/blocks/blob/main/docs/fast-deploy.md): serve content from Cloudflare KV, so a content change doesn't need a redeploy. +- [Observability](https://github.com/decocms/blocks/blob/main/docs/observability.md): the metrics, traces and logs the framework emits. +- [Troubleshooting](https://github.com/decocms/blocks/blob/main/docs/troubleshooting.md): common errors and their fixes. +- [Migrating a site to TanStack Start](https://github.com/decocms/blocks/blob/main/.agents/skills/deco-to-tanstack-migration/SKILL.md): the playbook for moving a Fresh/Deno site to v7. +- [The docs folder](https://github.com/decocms/blocks/tree/main/docs): every other guide and runbook. + +The [Next major](/next/architecture) docs describe the proposed API for the next major version. diff --git a/site/gen_roadmap.py b/site/gen_roadmap.py deleted file mode 100755 index 5821014a..00000000 --- a/site/gen_roadmap.py +++ /dev/null @@ -1,714 +0,0 @@ -#!/usr/bin/env python3 -"""Render the Roadmap page of the Deco Blocks docs site from data/roadmap.json. - -The Roadmap is the to-do list for the next major. Everything it shows comes from -data/roadmap.json (see README.md for the format); this script holds the page's structure, its -framing sentences and its self-checks, and fails loudly when the data doesn't hold together -(unknown ids, broken links, counts that disagree with the copy). - -Nothing on the roadmap is done yet, so every to-do renders unchecked. The boxes are drawn by CSS -and are not interactive: a checklist kept in one reader's browser would look shared and isn't. - -Output: a run of top-level <section class="doc-section"> blocks plus one small inline <script> -(feature chips, back-links, and the status/site filter of the feature list). build.py imports -this module and calls render(); run alone, it checks the data and prints a summary: - - python3 site/gen_roadmap.py [--out FILE] -""" -import argparse -import html -import json -import pathlib -import re -import sys -import unicodedata -from collections import Counter - -HERE = pathlib.Path(__file__).resolve().parent -DATA = HERE / "data" / "roadmap.json" -SRC = HERE / "src" - -# The page's sections, in order. app.js's sidebar groups list these ids. -SEC_IDS = ["roadmap", "roadmap-blockers", "roadmap-studio-new", "roadmap-studio-legacy", "roadmap-api", "roadmap-cli", - "roadmap-platform", "roadmap-docs", "roadmap-storefront", "roadmap-blog", "roadmap-faststore", "roadmap-features"] -# Work-item groups -> their sections. -GROUP_SEC = {"api": "roadmap-api", "cli": "roadmap-cli", "studio": "roadmap-platform", "docs": "roadmap-docs"} -# Status id -> the one-letter hook the styles colour by (data-v="..." in roadmap.css). -VC = {"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). -OPEN = {"to-build", "to-finish", "site-code"} -# Site-step kinds. The site-migration lede below names these labels. -KIND = {"now": "Fix now", "pre": "Before migrating", "blocker": "Blocker", "work": "Site work", "content": "Content", "fix": "Note"} -EFF_RANK = {"L": 0, "M": 1, "S": 2} -# The page says "the ten release blockers" in several places. -N_BLOCKERS = 10 - - -class DataError(SystemExit): - pass - - -def check(cond, msg): - if not cond: - raise DataError(f"gen_roadmap: {msg}") - - -def esc(s): - return html.escape(str(s), quote=True) - - -def slug(text): - """Same rule as app.js's slugify, applied to plain text.""" - t = unicodedata.normalize("NFKD", re.sub(r"<[^>]+>", "", html.unescape(text)).lower()) - t = "".join(c for c in t if not unicodedata.combining(c)) - return re.sub(r"[^a-z0-9]+", "-", t).strip("-") or "section" - - -def plain(s): - return html.unescape(re.sub(r"<[^>]+>", "", s)) - - -def md(s): - """Escape, then `code` and [label](#id) links (the feature summaries).""" - s = esc(s) - s = re.sub(r"`([^`]+)`", r"<code>\1</code>", s) - return re.sub(r"\[([^\]]+)\]\(#([a-z0-9-]+)\)", r'<a href="#\2">\1</a>', s) - - -def site_ids(): - """Ids and section labels of the rest of the site (Docs, Under the hood, landing, shell): the - Roadmap links into the docs by these ids, and its own ids must not collide with them.""" - content = (SRC / "content.html").read_text(encoding="utf-8") - labels = {m.group(1): html.unescape(m.group(2)) - for m in re.finditer(r'<section id="([^"]+)" data-nav="([^"]+)"', content)} - # Both pages' first sections are "Overview" in the sidebar, so the Under-the-hood one gets its page name. - labels["internals"] = "Under the hood" - ids = set() - for name in ("content.html", "landing.html", "shell.html", "cobogo-defs.svg"): - ids |= set(re.findall(r'\bid="([^"]+)"', (SRC / name).read_text(encoding="utf-8"))) - return labels, ids - - -# The Feature readiness filter, chips and back-links. Kept inline so the fragment is self-contained. -SCRIPT = """<script> -(function () { - var page = document.querySelector('.page[data-page="roadmap"]'); - if (!page) return; - var byId = function (id) { return document.getElementById(id); }; - var sec = byId('roadmap-features'); - // Chips: each id becomes a link to its row, named from the row (with its status for screen - // readers). Rows collect the reverse links: the release blockers and work items whose chips name them. - var back = {}; - [].forEach.call(page.querySelectorAll('.gx-chips[data-f]'), function (box) { - var h = box.previousElementSibling, s = box.closest('section'); - while (h && h.tagName !== 'H3') h = h.previousElementSibling; - var src = h && s && /^roadmap-(blockers|api|cli|platform|docs)$/.test(s.id) ? h : null; - box.getAttribute('data-f').split(' ').forEach(function (id) { - var row = byId('roadmap-f-' + id), a = document.createElement('a'), sr = document.createElement('span'); - if (!row) return; - a.href = '#roadmap-f-' + id; a.setAttribute('data-v', row.getAttribute('data-v')); - sr.className = 'sr-only'; sr.textContent = row.querySelector('.gx-vp').textContent + ': '; - a.append(sr, row.querySelector('h4').textContent); - box.appendChild(a); - if (src) (back[id] = back[id] || []).push(src); - }); - }); - Object.keys(back).forEach(function (id) { - var p = document.createElement('p'); - p.className = 'gx-fx-c'; - back[id].forEach(function (h) { - var a = document.createElement('a'), n = h.querySelector('.gx-n'), t = h.querySelector('span:not(.gx-n)'); - a.href = '#' + h.id; - if (n) { a.textContent = 'Blocker ' + n.textContent; a.title = t.textContent; } else a.innerHTML = t.innerHTML; - p.append(a, ' '); - }); - byId('roadmap-f-' + id).lastElementChild.appendChild(p); - }); - var bar = sec && sec.querySelector('.gx-filter'); - if (!bar) return; - var rows = [].slice.call(sec.querySelectorAll('.gx-fx > li')); - var lists = [].slice.call(sec.querySelectorAll('.gx-fx')); - var btns = [].slice.call(bar.querySelectorAll('button[data-fk]')); - var count = bar.querySelector('.gx-fcount'); - var state = { v: '', s: '' }; - rows.forEach(function (li) { var st = li.querySelector('.gx-st'); li._s = ' ' + (st ? st.textContent.split(' \\u00b7 ').join(' ') : '') + ' '; }); - function hit(li, k, val) { return !val || (k === 'v' ? li.getAttribute('data-v') === val : li._s.indexOf(' ' + val + ' ') > -1); } - function apply() { - var shown = 0; - rows.forEach(function (li) { var on = hit(li, 'v', state.v) && hit(li, 's', state.s); li.hidden = !on; if (on) shown++; }); - // A category with no matching rows hides whole: heading, description and list. - lists.forEach(function (ul) { - var off = !ul.querySelector('li:not([hidden])'), d = ul.previousElementSibling, h = d && d.previousElementSibling; - ul.hidden = off; if (d) d.hidden = off; if (h && h.tagName === 'H3') h.hidden = off; - }); - // Each button counts the rows it would show, given the other filter. - btns.forEach(function (b) { - var k = b.getAttribute('data-fk'), val = b.getAttribute('data-fv'), o = k === 'v' ? 's' : 'v', n = b.querySelector('span'); - b.setAttribute('aria-pressed', String(val === state[k])); - if (n) n.textContent = rows.filter(function (li) { return hit(li, k, val) && hit(li, o, state[o]); }).length; - }); - count.textContent = shown === rows.length ? 'Showing all ' + shown + ' features' : shown ? 'Showing ' + shown + ' of ' + rows.length + ' features' : 'No feature matches both filters'; - document.dispatchEvent(new CustomEvent('roadmap:filter')); - } - bar.addEventListener('click', function (e) { - var b = e.target.closest && e.target.closest('button[data-fk]'); - if (!b) return; - state[b.getAttribute('data-fk')] = b.getAttribute('data-fv'); apply(); - }); - // "See where the N features it uses stand": preset the site filter, then let the link navigate. - page.addEventListener('click', function (e) { - var a = e.target.closest && e.target.closest('a[data-gx-site]'); - if (a) { state.v = ''; state.s = a.getAttribute('data-gx-site'); apply(); } - }); - // A link to something the filter hides clears the filter first, so the router scrolls to it. - // Both listeners run before the router's: the click one (capture) also covers a link to the - // current hash, which fires no hashchange; hashchange covers back and forward. - function unhide(id) { - try { id = decodeURIComponent(id); } catch (err) {} - var el = id && byId(id); - if (el && sec.contains(el) && el.closest('[hidden]')) { state.v = ''; state.s = ''; apply(); } - } - document.addEventListener('click', function (e) { - var a = e.target.closest && e.target.closest('a[href^="#roadmap"]'); - if (a) unhide(a.getAttribute('href').slice(1)); - }, true); - window.addEventListener('hashchange', function () { unhide(location.hash.slice(1)); }); - bar.hidden = false; - apply(); -})(); -</script>""" - - -class Roadmap: - def __init__(self, data): - self.doc_label, self.site_ids = site_ids() - self.load(data) - - # ------------------------------------------------------------------ data - def load(self, d): - # Statuses, in rank order (Feature readiness sorts by this order). - self.VERDICTS = [s["id"] for s in d["statuses"]] - check(self.VERDICTS == list(VC), f"statuses must be {list(VC)} in that order, got {self.VERDICTS}") - st = {s["id"]: s for s in d["statuses"]} - self.VLABEL = {v: st[v]["label"] for v in self.VERDICTS} - self.VWORD = {v: st[v]["word"] for v in self.VERDICTS} - self.VWORD1 = {v: st[v]["word_one"] for v in self.VERDICTS} - self.TILE = {v: st[v]["tile"] for v in self.VERDICTS} - self.LEGEND = {v: st[v]["legend"] for v in self.VERDICTS} - - self.SEC = {s["id"]: s for s in d["sections"]} - check([s["id"] for s in d["sections"]] == SEC_IDS, "sections must list the page's section ids in order") - for s in d["sections"]: - check(s.get("nav") and s.get("eyebrow"), f"section {s['id']} needs nav and eyebrow") - self.check_title(s["title"], f"section {s['id']}") - - self.CATS = d["categories"] - cat_ids = [c["id"] for c in self.CATS] - self.FEATS = d["features"] - self.REPOS = [s["id"] for s in d["sites"]] - for fid, f in self.FEATS.items(): - check(f["category"] in cat_ids, f"feature {fid}: unknown category {f['category']!r}") - check(f["status"] in VC, f"feature {fid}: unknown status {f['status']!r}") - check(f["effort"] in EFF_RANK, f"feature {fid}: effort must be L, M or S") - check(f["sites"] and set(f["sites"]) <= set(self.REPOS), f"feature {fid}: unknown sites {f['sites']}") - for k in ("first_rated", "rated_before_review"): - check(f[k] is None or f[k] in VC, f"feature {fid}: {k} must be a status id or null") - bad = [x for x in f["docs"] if x not in self.doc_label] - check(not bad, f"feature {fid}: docs ids not found in src/content.html: {bad}") - # Every feature names at least one part of these docs. - check(f["docs"], f"feature {fid}: no docs ids") - self.TOTALS = dict(self.counts(list(self.FEATS))) - self.REPO_IDS = {r: [fid for fid in self.FEATS if r in self.FEATS[fid]["sites"]] for r in self.REPOS} - - self.SHORT = {s["id"]: s["short"] for s in d["sites"]} - # The name the page shows for a site (bars, the intro's links, the site filter's tooltip). - self.NAME = {s["id"]: s["name"] for s in d["sites"]} - self.SITE_SEC = {s["id"]: s["section"] for s in d["sites"]} - check(list(self.SITE_SEC.values()) == ["roadmap-storefront", "roadmap-blog", "roadmap-faststore"], - "sites must map, in order, to the roadmap-storefront, roadmap-blog and roadmap-faststore sections") - self.REPO_DESC = {s["id"]: s["description"] for s in d["sites"]} - - # Every to-do has an explicit id (its anchor); titles are looked up by id for links. - self.TITLE = {} - - def reg(item, prefix, what): - check(item["id"].startswith(prefix + "--"), f"{what} {item['id']!r} must start with {prefix}--") - check(item["id"] not in self.TITLE, f"duplicate id {item['id']!r}") - self.check_title(item["title"], f"{what} {item['id']}") - self.check_features(item["features"], f"{what} {item['id']}") - self.TITLE[item["id"]] = item["title"] - - self.TOP = d["blockers"] - for b in self.TOP: - reg(b, "roadmap-blockers", "blocker") - check(len(self.TOP) == N_BLOCKERS, f"the page says 'ten release blockers'; the data has {len(self.TOP)}") - self.STUDIO_NEW, self.STUDIO_LEGACY = d["studio_new"], d["studio_legacy"] - for x in self.STUDIO_NEW: - reg(x, "roadmap-studio-new", "Studio item") - for x in self.STUDIO_LEGACY: - reg(x, "roadmap-studio-legacy", "Studio item") - self.CHANGES = d["work_items"] - for c in self.CHANGES: - check(c["group"] in GROUP_SEC, f"work item {c['id']}: unknown group {c['group']!r}") - reg(c, GROUP_SEC[c["group"]], "work item") - bad = [x for x in c["docs"] if x not in self.doc_label] - check(not bad, f"work item {c['id']}: docs ids not found in src/content.html: {bad}") - self.WORK = {c["id"]: c for c in self.CHANGES} - self.PLAN, self.STEP = {}, {} # step id -> (site, step number, step) - for s in d["sites"]: - self.PLAN[s["id"]] = s["steps"] - for n, step in enumerate(s["steps"], 1): - reg(step, s["section"], "site step") - check(step["kind"] in KIND, f"site step {step['id']}: unknown kind {step['kind']!r}") - # A Note with nothing to do (the migration removes it) is drawn without a box and isn't counted. - check(not step["no_action"] or step["kind"] == "fix", f"site step {step['id']}: only a Note can carry no action") - self.STEP[step["id"]] = (s["id"], n, step) - - # Work items delivering each blocker (each is one part of it, not all of it), and back. - self.CLEARS = {} - for i, b in enumerate(self.TOP, 1): - for k in b["delivered_by"]: - check(k in self.WORK, f"blocker {b['id']}: delivered_by {k!r} is not a work item") - self.CLEARS.setdefault(k, []).append(i) - # Studio items point to work items or site steps; a pointer must share at least one feature with the item. - for x in self.STUDIO_NEW + self.STUDIO_LEGACY: - for ref in x["delivered_by"]: - check(ref in self.WORK or ref in self.STEP, f"Studio item {x['id']}: delivered_by {ref!r} is not a work item or site step") - ids = self.WORK[ref]["features"] if ref in self.WORK else self.STEP[ref][2]["features"] - check(set(ids) & set(x["features"]), f"Studio item {x['id']}: {ref!r} shares no feature with it") - - # {{item:ID}} in any HTML field becomes a link to that to-do, named by its current title. - def expand(s): - def one(m): - check(m.group(1) in self.TITLE, f"{{{{item:{m.group(1)}}}}} names no to-do") - return f'<a href="#{m.group(1)}">{self.TITLE[m.group(1)]}</a>' - return re.sub(r"\{\{item:([a-z0-9-]+)\}\}", one, s) - - for b in self.TOP: - b["today"], b["plan"] = expand(b["today"]), expand(b["plan"]) - for x in self.STUDIO_NEW + self.STUDIO_LEGACY: - x["today"] = expand(x["today"]) - for c in self.CHANGES: - c["plan"] = expand(c["plan"]) - for s in d["sites"]: - for step in s["steps"]: - step["text"] = expand(step["text"]) - self.HEADLINE = {s["id"]: expand(s["headline"]) for s in d["sites"]} - self.OV = {k: expand(v) for k, v in d["overview"].items()} - - def check_title(self, t, where): - # Titles are HTML: plain text, entities and <code> only. - rest = re.sub(r"</?code>", "", t) - check(t.strip() == t and t, f"{where}: empty title or stray whitespace") - check(not re.search(r"[<>]|&(?!(?:amp|lt|gt|quot|#\d+);)", rest), f"{where}: title has markup other than <code>: {t!r}") - - def check_features(self, ids, where): - bad = [i for i in ids if i not in self.FEATS] - check(ids and not bad, f"{where}: unknown or missing feature ids {bad}") - - # ------------------------------------------------------------------ helpers - def counts(self, ids): - c = Counter(self.FEATS[i]["status"] for i in ids) - return [(v, c.get(v, 0)) for v in self.VERDICTS] - - def vword(self, v, n): - return self.VWORD1[v] if n == 1 else self.VWORD[v] - - def vq(self, v): - return f"“{self.VWORD1[v]}”" - - def clink(self, k): - return f'<a href="#{k}">{self.TITLE[k]}</a>' - - def section(self, sid, body, cls="", count=None): - """A page section. Its eyebrow carries the section's honest count ("24 to do").""" - s = self.SEC[sid] - eb = esc(s["eyebrow"]) + (f'<span class="rm-count">{count}</span>' if count else "") - return (f'<section id="{sid}" data-nav="{esc(s["nav"])}" class="doc-section gx-sec{(" " + cls) if cls else ""}" aria-labelledby="{sid}-title">\n' - f'<p class="eyebrow">{eb}</p><h2 id="{sid}-title">{s["title"]}</h2>\n{body}\n</section>') - - def vpill(self, v, own=True): - """Status pill. Inside an element that already carries data-v, own=False.""" - return f'<b class="gx-vp"{f" data-v={chr(34)}{VC[v]}{chr(34)}" if own else ""}>{self.VLABEL[v]}</b>' - - def chips(self, ids): - """Feature chips. To keep the single-file page small, the ids are listed once and the - inline script at the end expands each into <a href="#roadmap-f-ID" data-v=..>Name</a>, reading - the name and status from that feature's row (static HTML). The "Features" label is CSS.""" - return f'<div class="gx-chips" data-f="{" ".join(ids)}"></div>' - - def site_tags(self, repos): - return f'<span class="gx-st">{" · ".join(self.SHORT[r] for r in repos)}</span>' if repos else "" - - def sites_of(self, ids): - return [r for r in self.REPOS if any(r in self.FEATS[x]["sites"] for x in ids)] - - # .rm-sr is visually hidden (roadmap.css) and left out of the search index (app.js). - SR_TODO = '<span class="rm-sr">To do</span>' - - @staticmethod - def todo_h3(hid, title_html, n=None): - """A to-do's heading. The unchecked box is CSS (h3::before, decorative); the item's first - child says "To do" to screen readers. A number and the title sit in two spans side by side, - so a title that wraps hangs under its own first line.""" - num = f'<span class="gx-n">{n:02d}</span> ' if n else "" - return f'<h3 id="{hid}">{num}<span>{title_html}</span></h3>' - - @staticmethod - def detail(rows): - """Today / Plan (and link rows) as a description list. rows: (label, html). The Today row carries - a class: it sits on the plain surface, and the rows after it on a fill (roadmap.css). Link-run rows - (any label but Today and Plan) mark their dd, so roadmap.css can space the links apart.""" - return '<dl class="gx-wf">' + "".join( - f'<div{" class=" + chr(34) + "rm-t" + chr(34) if lab == "Today" else ""}><dt>{lab}</dt><dd{"" if lab in ("Today", "Plan") else " class=" + chr(34) + "rm-links" + chr(34)}>{body}</dd></div>' - for lab, body in rows) + "</dl>" - - def todo_li(self, h3, *parts, note=False): - """A to-do (box drawn by CSS), or with note=True a note with nothing to do (no box, "Note" to screen readers).""" - head = '<li class="rm-note"><span class="rm-sr">Note</span>' if note else f"<li>{self.SR_TODO}" - return head + h3 + "".join(p for p in parts if p) + "</li>" - - def legend(self, ids): - return "".join(f'<span class="gx-lg" data-v="{VC[v]}"><b>{n}</b> {self.vword(v, n)}</span>' for v, n in self.counts(ids) if n) - - def key(self): - # The colour key for chip dots (the to-build marker is a diamond, so the key doesn't rely on colour alone). - return "".join(f'<span class="gx-lg" data-v="{VC[v]}">{self.vword(v, 1)}</span>' for v in self.VERDICTS if self.TOTALS[v]) - - def open_work(self, ids): - eff = Counter(self.FEATS[i]["effort"] for i in ids if self.FEATS[i]["status"] in OPEN) - return f'Open work: <b>{eff.get("L", 0)}</b> L · <b>{eff.get("M", 0)}</b> M · <b>{eff.get("S", 0)}</b> S' - - def bar_row(self, label_html, ids, desc=""): - cs = [(v, n) for v, n in self.counts(ids) if n] - aria = ", ".join(f"{n} {self.vword(v, n)}" for v, n in cs) - segs = "".join(f'<i data-v="{VC[v]}" style="--n:{n}"></i>' for v, n in cs) - return (f'<div class="gx-barrow"><p class="gx-bar-h">{label_html}<span>{len(ids)} features</span></p>' - f'<div class="gx-bar" role="img" aria-label="{esc(plain(label_html))}: {aria}">{segs}</div>' - f'<p class="gx-bar-l">{self.legend(ids)}<span class="gx-ow" title="Effort of the features to build, to finish and left to site code">{self.open_work(ids)}</span></p>' - + (f'<p class="gx-bar-d">{desc}</p>' if desc else "") + "</div>") - - def legend_p(self): - # The status legend: a short definition list, one status per line, each label with its status marker (CSS). - items = "".join(f'<div><dt><b class="gx-lg" data-v="{VC[v]}">{self.VLABEL[v]}</b></dt><dd>{self.LEGEND[v]}</dd></div>' - for v in self.VERDICTS) - return f'<dl class="rm-legend" aria-label="Statuses">{items}</dl>' - - def ref_link(self, ref): - """A link to a work item or, with the site and step number, to a site step.""" - if ref in self.STEP: - r, n, st = self.STEP[ref] - return f'<span class="rm-sref"><a href="#{st["id"]}">{st["title"]}</a> <span class="rm-ref">({self.SHORT[r]} step {n})</span></span>' - return self.clink(ref) - - def blocker_lede(self): - all_sites = all(self.sites_of(g["features"]) == self.REPOS for g in self.TOP) - return ("Ranked by how much each one blocks, in the reviewers' judgment, so the order doesn't follow the " - "feature count shown beside each." + (" Every one hits all three sites." if all_sites else "")) - - # ------------------------------------------------------------------ 1 · overview - def overview(self): - n, T = len(self.FEATS), self.TOTALS - intro, lead = self.OV["intro"].strip(), self.OV["readiness_lead"].strip() - # The intro and the readiness line are copy; fail if the numbers or links move under them. - R = self.REPOS - for s in (*(f'<a href="#{self.SITE_SEC[r]}">{self.NAME[r]}</a>' for r in R), - 'The <a href="#roadmap-blockers">ten release blockers</a> gate the release', - '<a href="#roadmap--release-readiness">Release readiness</a>'): - check(s in intro, f"overview.intro must contain {s!r}") - for s in (f"None of the {n} features", f"{T['to-build']} are still to build", f"{T['to-finish']} are to finish", - f"{T['site-code']} are left to site code", f"{T['goes-away']} go away"): - check(s in lead, f"overview.readiness_lead must contain {s!r} (the counts come from the features)") - tiles = "".join(f'<li data-v="{VC[v]}"><span class="gx-tile-n">{T[v]}</span>{self.vpill(v, False)}<span class="gx-tile-d">{esc(self.TILE[v])}</span></li>' - for v in self.VERDICTS) - bars = self.bar_row("<b>All three sites</b>", list(self.FEATS)) + "".join( - self.bar_row(f'<a href="#{self.SITE_SEC[r]}"><b>{self.NAME[r]}</b></a>', self.REPO_IDS[r], f"<span>{self.REPO_DESC[r]}</span> {self.HEADLINE[r]}") - for r in self.REPOS) - top = "".join( - f'<li><span class="rm-sr">To do: </span><a href="#{g["id"]}">{g["title"]}</a><span class="gx-tl-s">{len(g["features"])} features</span></li>' - for g in self.TOP) - shorts = [f"<b>{self.SHORT[r]}</b>" for r in self.REPOS] - short_names = ", ".join(shorts[:-1]) + " and " + shorts[-1] - n_studio = len(self.STUDIO_NEW) + len(self.STUDIO_LEGACY) - n_steps = sum(len(v) for v in self.PLAN.values()) - # The overview's to-do callouts share one shape: the box, an imperative title that links to the item, then the text. - # fix_now (a live bug to fix whether or not a site migrates) is optional. - fix_now = f'<div class="gx-now rm-do"><p><span class="rm-sr">To do: </span>{self.OV["fix_now"]}</p></div>\n' if self.OV.get("fix_now") else "" - fix_docs = f'<div class="callout warning rm-do"><p><span class="rm-sr">To do: </span>{self.OV["fix_docs"]}</p></div>' - F = self.FEATS.values() - n_first = sum(1 for f in F if f["first_rated"]) - n_review = sum(1 for f in F if f["rated_before_review"]) - n_medium = sum(1 for f in F if f["confidence"] != "high") - n_unv = sum(1 for f in F if f["unconfirmed_sub_claim"]) - body = f"""{intro} -{fix_now}<h3 id="roadmap--the-ten-release-blockers">The ten release blockers</h3> -<p>{self.blocker_lede()} Each links to where it stands today, the plan and the work items that deliver it.</p> -<ol class="gx-toplist">{top}</ol> -<h3 id="roadmap--release-readiness">Release readiness</h3> -<p>{lead}</p> -<ul class="gx-tiles" aria-label="Features by status">{tiles}</ul> -<div class="gx-bars">{bars}</div> -<p class="gx-note">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: {short_names}.</p> -{fix_docs} -<h3 id="roadmap--using-this-page">Using this page</h3> -<ul class="gx-map"> -<li><a href="#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="#roadmap-studio-new">Studio support</a>: what has to work for editors, on a next-major site and with legacy content ({n_studio} items).</li> -<li><a href="#roadmap-api">Work items</a>: {len(self.CHANGES)} changes, deduplicated from the {n} per-feature proposals, in four groups: the API, the CLI, Studio and the Deco API, and these docs.</li> -<li><a href="#roadmap-storefront">Site migrations</a>: each site's plan as a checklist, in execution order ({n_steps} steps).</li> -<li><a href="#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 Studio 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> -<p class="small muted">How this list was made: each site's features were catalogued from its code, with file and line evidence, and grouped into {len(self.CATS)} categories. Each feature was then assessed against these docs, and a second reviewer checked each assessment against Studio's and the framework's code. That check changed {n_first} statuses, all to “to finish”, and a later review changed {n_review} more. {n_medium} of the {n} assessments are medium confidence and the rest are high, and {n_unv} contain a sub-claim the second reviewer couldn't confirm. Feature readiness marks each of these on its row.</p>""" - return self.section("roadmap", body) - - # ------------------------------------------------------------------ 2 · release blockers - def blockers(self): - lede = (f"<p>{self.blocker_lede()} Each says where things stand today, the plan, and the work items that deliver it; the chips link to " - "the affected features.</p>") - all_sites = all(self.sites_of(g["features"]) == self.REPOS for g in self.TOP) - lis = [] - for i, g in enumerate(self.TOP, 1): - by = " ".join(self.clink(k) for k in g["delivered_by"]) + (f' <span class="rm-dnote">{g["delivered_note"]}</span>' if g.get("delivered_note") else "") - rows = [("Today", g["today"]), ("Plan", g["plan"]), ("Delivered by", by)] - meta = "" if all_sites else f'<p class="gx-meta">{self.site_tags(self.sites_of(g["features"]))}</p>' - lis.append(self.todo_li(self.todo_h3(g["id"], g["title"], i), meta, self.detail(rows), self.chips(g["features"]))) - return self.section("roadmap-blockers", f'{lede}\n<ol class="rm-list">{"".join(lis)}</ol>', "gx-items", f"{len(self.TOP)} to do") - - # ------------------------------------------------------------------ 3 · studio support - def studio(self, sid, lede, items): - # Unnumbered: unlike the blockers and the site plans, the order isn't a ranking. - lis = [self.todo_li(self.todo_h3(x["id"], x["title"]), - self.detail([("Today", x["today"])] + ([("Delivered by", " ".join(self.ref_link(r) for r in x["delivered_by"]))] if x["delivered_by"] else [])), - self.chips(x["features"])) for x in items] - return self.section(sid, f'<p>{lede}</p>\n<ul class="rm-list">{"".join(lis)}</ul>', "gx-items", f"{len(items)} to do") - - # ------------------------------------------------------------------ 4 · work items - def changes(self, key): - sid = GROUP_SEC[key] - # Pinned items first, in data order (the docs items that correct statements that are wrong today); - # then by how many features need them, ties in data order. - items = sorted((c for c in self.CHANGES if c["group"] == key), - key=lambda c: (0, 0) if c.get("pinned") else (1, -len(c["features"]))) - n_pinned = sum(1 for c in items if c.get("pinned")) - check(key == "docs" and n_pinned == 2 or key != "docs" and n_pinned == 0, - "exactly two docs work items are pinned (the docs lede says so), and no other") - sort = "They're sorted by how many features need them." - lede = { - "api": f"{len(items)} changes to the SDK, the bindings and the <code>@decocms/apps-*</code> packages. {sort}", - "cli": f"{len(items)} changes to the <code>deco</code> CLI's output, codegen and migration tooling. {sort}", - "studio": f"{len(items)} changes on the Studio side and in the Deco API's release service. {sort}", - "docs": f"{len(items)} 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.", - }[key] - lis = [] - for c in items: - 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. - n_build = sum(1 for x in ids if self.FEATS[x]["status"] == "to-build") - nf = f'{len(ids)} feature{"s" if len(ids) != 1 else ""}' - if n_build: - nf += ", to build" if n_build == len(ids) == 1 else f", {n_build} of them to build" - meta = f'<span class="gx-meta-n">{nf}</span>' + self.site_tags(self.sites_of(ids)) - rows = [("Plan", c["plan"])] - if c["docs"]: - rows.append(("In these docs", " ".join(f'<a href="#{x}">{esc(self.doc_label[x])}</a>' for x in c["docs"]))) - if c["id"] in self.CLEARS: - # One part of the blocker, not all of it: most blockers need two to four work items. - cl = self.CLEARS[c["id"]] - rows.append(("Part of blocker" + ("s" if len(cl) > 1 else ""), - " ".join(f'<a href="#{self.TOP[i - 1]["id"]}">{self.TOP[i - 1]["title"]}</a>' for i in cl))) - lis.append(self.todo_li(self.todo_h3(c["id"], c["title"]), f'<p class="gx-meta">{meta}</p>', self.detail(rows), self.chips(ids))) - body = (f'<p>{lede} A chip\'s dot shows that feature\'s status: <span class="gx-key">{self.key()}</span>.</p>\n' - f'<ul class="rm-list">{"".join(lis)}</ul>') - return self.section(sid, body, "gx-items", f"{len(items)} to do") - - # ------------------------------------------------------------------ 5 · site migrations - def site_plan(self, r): - sid = self.SITE_SEC[r] - ids = self.REPO_IDS[r] - steps = self.PLAN[r] - n_note = sum(1 for s in steps if s["no_action"]) - parts = [f"<p>{self.HEADLINE[r]}</p>", - f'<p class="gx-sitedesc"><span class="gx-meta-k">The site</span>{self.REPO_DESC[r]}</p>', - f'<div class="gx-bars">{self.bar_row(f"<b>{self.NAME[r]}</b>", 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>." - + (" A note with nothing to do has no box." if n_note else "") + " " - f'<a href="#roadmap-features" data-gx-site="{self.SHORT[r]}">See where the {len(ids)} features it uses stand</a>.</p>'] - lis = [self.todo_li(self.todo_h3(s["id"], s["title"], i), f'<p class="gx-meta"><span class="gx-kind" data-k="{s["kind"]}">{KIND[s["kind"]]}</span></p>', - f'<p>{s["text"]}</p>', self.chips(s["features"]), note=s["no_action"]) - for i, s in enumerate(steps, 1)] - parts.append(f'<ol class="rm-list">{"".join(lis)}</ol>') - count = f"{len(steps) - n_note} to do" + (f" · {n_note} note" if n_note else "") - return self.section(sid, "\n".join(parts), "gx-items gx-plan", count) - - # ------------------------------------------------------------------ 6 · feature readiness - def row_tags(self, f): - # 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 ..."). - t = "" - if f["confidence"] != "high": - t += '<i class="gx-tg">Medium confidence</i>' - if f["first_rated"]: - t += f'<i class="gx-tg" title="The assessor\'s first rating; verification against the code changed it">First rated {self.vq(f["first_rated"])}</i>' - if f["rated_before_review"]: - t += f'<i class="gx-tg" title="The status a later review changed">Rated {self.vq(f["rated_before_review"])} before review</i>' - if f["unconfirmed_sub_claim"]: - t += '<i class="gx-tg" title="Contains a sub-claim the verifier couldn\'t confirm">Unconfirmed sub-claim</i>' - return t - - def feature_index(self): - V_RANK = {v: i for i, v in enumerate(self.VERDICTS)} - T = self.TOTALS - vbtn = '<span class="gx-fl">Status</span><button type="button" data-fk="v" data-fv="" aria-pressed="true">All statuses</button>' + "".join( - f'<button type="button" data-fk="v" data-fv="{VC[v]}" aria-pressed="false"><i data-v="{VC[v]}"></i>{self.VLABEL[v]} <span>{T[v]}</span></button>' - for v in self.VERDICTS if T[v]) - sbtn = '<span class="gx-fl">Site</span><button type="button" data-fk="s" data-fv="" aria-pressed="true">All sites</button>' + "".join( - f'<button type="button" data-fk="s" data-fv="{self.SHORT[r]}" aria-pressed="false" title="{esc(self.NAME[r])}">{self.SHORT[r]} <span>{len(self.REPO_IDS[r])}</span></button>' - for r in self.REPOS) - parts = [ - f"<p>All {len(self.FEATS)} features the three sites use, grouped into {len(self.CATS)} 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>", - self.legend_p(), - f'<div class="gx-filter" hidden><div class="gx-fg" role="group" aria-label="Filter by status">{vbtn}</div>' - f'<div class="gx-fg" role="group" aria-label="Filter by site">{sbtn}</div>' - f'<p class="gx-fcount" aria-live="polite">Showing all {len(self.FEATS)} features</p></div>'] - for cat in self.CATS: - ids = [fid for fid, f in self.FEATS.items() if f["category"] == cat["id"]] - ids.sort(key=lambda x: (V_RANK[self.FEATS[x]["status"]], EFF_RANK[self.FEATS[x]["effort"]], self.FEATS[x]["name"])) - title = cat["title"] - rows = [] - for fid in ids: - f = self.FEATS[fid] - docs = " ".join(f'<a href="#{x}">{esc(self.doc_label[x])}</a>' for x in f["docs"]) - rows.append( - f'<li id="roadmap-f-{fid}" data-v="{VC[f["status"]]}">' - f'<h4>{esc(f["name"])}</h4><p class="gx-fx-m">{self.vpill(f["status"], False)}<span>Effort {f["effort"]}</span>{self.site_tags(f["sites"])}{self.row_tags(f)}</p>' - f'<div><p>{md(f["summary"])}</p>' + (f'<p class="gx-fx-d">{docs}</p>' if docs else "") + "</div></li>") - parts.append( - f'<h3 id="roadmap-features--{slug(title)}">{esc(title)}</h3>\n' - f'<p class="gx-catd">{len(ids)} feature{"s" if len(ids) != 1 else ""} · {esc(cat["description"])}<span class="gx-catc">{self.legend(ids)}</span></p>\n' - f'<ul class="gx-fx">{"".join(rows)}</ul>') - # "Goes away" features can never be done, so the count leaves them out and names them. - done, gone = T["done"], T["goes-away"] - return self.section("roadmap-features", "\n".join(parts), "gx-index", f"{done} of {len(self.FEATS) - gone} done · {gone} go away") - - # ------------------------------------------------------------------ assemble - def render(self): - secs = [ - self.overview(), - self.blockers(), - self.studio("roadmap-studio-new", "What has to work when a migrated site runs against today's Studio. 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.", self.STUDIO_NEW), - self.studio("roadmap-studio-legacy", "Content and assumptions Studio 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.", self.STUDIO_LEGACY), - self.changes("api"), self.changes("cli"), self.changes("studio"), self.changes("docs"), - *(self.site_plan(r) for r in self.REPOS), - self.feature_index(), - ] - out = "\n\n".join(secs) + "\n\n" + SCRIPT + "\n" - out = re.sub(r"\n +<", "\n<", out) # no source indentation: every byte counts in the single-file build - out = re.sub(r"\n +", "\n", out) - self.checks(out) - self.out = out - return out - - # ------------------------------------------------------------------ checks - def checks(self, out): - visible = re.sub(r"<script\b.*?</script>", "", out, flags=re.S) - text = html.unescape(re.sub(r"<[^>]+>", " ", visible)) - - def near(pat): - return re.findall(r".{0,40}(?:" + pat + r").{0,40}", text, re.I)[:5] - - # Wording: these docs are the spec here, anchors are links not "#ids", and no undefined abbreviations. - for pat, what in ((r"\bspec\b", "'spec'"), (r"(?<![\w&])#[a-z]", "a bare #anchor"), (r"\b(?:IS|hCMS|ALS)\b|N/A", "an undefined abbreviation")): - check(not re.search(pat, text, re.I if what == "'spec'" else 0), f"{what} in the page text: {near(pat)}") - check("{{" not in out and "}}" not in out, "an unexpanded {{placeholder}}") - # No "gap" wording as a label: headings, eyebrows, nav names, detail labels, pills, buttons, tags. - labels = re.findall(r'<(h[234])\b[^>]*>(.*?)</\1>', out) - labels += re.findall(r'<(dt|button|b class="gx-vp"|span class="gx-kind"|i class="gx-tg"|p class="eyebrow")[^>]*>(.*?)</', out) - labels += [(None, x) for x in re.findall(r'data-nav="([^"]*)"', out)] - bad_lab = [x for _, x in labels if re.search(r"\bgaps?\b", plain(x), re.I)] - check(not bad_lab, f"'gap' in a label: {bad_lab}") - - ids = re.findall(r'\bid="([^"]+)"', out) - dupes = [i for i, n in Counter(ids).items() if n > 1] - check(not dupes, f"duplicate ids: {dupes}") - clash = sorted(set(ids) & self.site_ids) - check(not clash, f"ids that collide with the rest of the site: {clash}") - sec_ids = re.findall(r'<section id="([^"]+)"', out) - check(sec_ids == SEC_IDS, f"sections: {sec_ids}") - # Every to-do on the page. 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. - n_notes = sum(1 for v in self.PLAN.values() for s in v if s["no_action"]) - n_todo = (len(self.TOP) + len(self.STUDIO_NEW) + len(self.STUDIO_LEGACY) + len(self.CHANGES) - + sum(len(v) for v in self.PLAN.values()) - n_notes) - todos = re.findall(r'<li><span class="rm-sr">To do</span><h3 id="([^"]+)"', out) - check(len(todos) == n_todo == out.count('<li><span class="rm-sr">To do</span>'), f"to-dos: {len(todos)} rendered, {n_todo} in the data") - check(out.count('<li class="rm-note"><span class="rm-sr">Note</span><h3') == n_notes, "notes") - # No row reads as work that was done and undone, and no grand total appears. - check(str(n_todo) not in text and str(n_todo + n_notes) not in text, f"the page shows a to-do total ({n_todo})") - check(not re.search(r"\bWas “", text), "a 'Was “...”' tag") - check(out.count('<dl class="gx-wf">') == len(self.TOP) + len(self.STUDIO_NEW) + len(self.STUDIO_LEGACY) + len(self.CHANGES), "detail lists") - rows = re.findall(r'<li id="roadmap-f-([^"]+)"', out) - check(len(rows) == len(self.FEATS) and set(rows) == set(self.FEATS), "feature rows") - row_v = Counter(re.findall(r'<li id="roadmap-f-[^"]+" data-v="([^"]+)"', out)) - check(dict(row_v) == {VC[k]: v for k, v in self.TOTALS.items() if v}, f"row statuses: {row_v}") - # Every internal link resolves statically (h3 ids are all explicit here). - hrefs = re.findall(r'href="#([^"]*)"', out) - bad = sorted({h for h in hrefs if h not in set(ids) | self.site_ids}) - check(not bad, f"links to ids that don't exist: {bad}") - chip_targets = [x for lst in re.findall(r'<div class="gx-chips" data-f="([^"]+)"></div>', out) for x in lst.split()] - check(chip_targets and all(t in self.FEATS for t in chip_targets), "chip targets") - check(out.count('class="gx-chips"') == len(re.findall(r'<div class="gx-chips" data-f="[^"]+"></div>', out)), "empty chip lists") - n_tags = Counter(re.findall(r'<i class="gx-tg"[^>]*>([A-Z][a-z]+)', out)) - F = self.FEATS.values() - check(n_tags["Medium"] == sum(1 for f in F if f["confidence"] != "high") - and n_tags["Rated"] == sum(1 for f in F if f["rated_before_review"]) - and n_tags["Unconfirmed"] == sum(1 for f in F if f["unconfirmed_sub_claim"]) - and n_tags["First"] == sum(1 for f in F if f["first_rated"]), f"row tags: {n_tags}") - counts_by_sec = {s: c for s, c in re.findall(r'<section id="([^"]+)"[^>]*>\n<p class="eyebrow">[^<]*<span class="rm-count">([^<]+)</span>', out)} - check(list(counts_by_sec) == SEC_IDS[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(sum(len(v) for v in self.CLEARS.values()) == sum(len(b["delivered_by"]) for b in self.TOP), "blocker back-links") - self.stats = dict(sec_ids=sec_ids, counts_by_sec=counts_by_sec, todos=len(todos), rows=len(rows), chips=len(chip_targets), - hrefs=hrefs, n_tags=n_tags, doc_links=sorted({h for h in hrefs if h in self.doc_label})) - - -def render(data=None): - """The Roadmap fragment. data: the parsed roadmap.json (read from data/roadmap.json by default).""" - if data is None: - data = json.loads(DATA.read_text(encoding="utf-8")) - return Roadmap(data).render() - - -def main(): - ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) - ap.add_argument("--out", help="also write the rendered fragment to this file") - args = ap.parse_args() - # The summary has a few non-ASCII characters (·, ›); never fail on a terminal that can't show them. - if hasattr(sys.stdout, "reconfigure"): - sys.stdout.reconfigure(errors="replace") - rm = Roadmap(json.loads(DATA.read_text(encoding="utf-8"))) - out = rm.render() - if args.out: - pathlib.Path(args.out).write_text(out, encoding="utf-8") - print(f"wrote {args.out} {len(out.encode()) / 1024:.1f} KB") - s = rm.stats - print("sections:", ", ".join(f"{x} [{s['counts_by_sec'].get(x, '-')}]" for x in s["sec_ids"])) - print(f"to-dos: {s['todos']} (blockers {len(rm.TOP)}, studio {len(rm.STUDIO_NEW)}+{len(rm.STUDIO_LEGACY)}, work items {len(rm.CHANGES)}, " - f"site steps {'+'.join(str(len(rm.PLAN[r])) for r in rm.REPOS)}) rows: {s['rows']} chips: {s['chips']} internal links: {len(s['hrefs'])}") - print("statuses:", {rm.VLABEL[k]: v for k, v in rm.TOTALS.items()}, - "per site:", {rm.SHORT[r]: dict(rm.counts(rm.REPO_IDS[r])) for r in rm.REPOS}) - print("row tags:", dict(s["n_tags"])) - print("links into Docs/Under the hood:", s["doc_links"]) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/site/package.json b/site/package.json new file mode 100644 index 00000000..3b4c175c --- /dev/null +++ b/site/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/site/public/favicon.svg b/site/public/favicon.svg new file mode 100644 index 00000000..99d2d099 --- /dev/null +++ b/site/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/site/scripts/check-content.ts b/site/scripts/check-content.ts new file mode 100644 index 00000000..1a6456b8 --- /dev/null +++ b/site/scripts/check-content.ts @@ -0,0 +1,69 @@ +/** + * 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)` or `href="/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)! + const links = [...src.matchAll(/\]\((\/[^)\s]*)\)/g), ...src.matchAll(/href="(\/[^"]*)"/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/site/scripts/check-roadmap.ts b/site/scripts/check-roadmap.ts new file mode 100644 index 00000000..73c9d89c --- /dev/null +++ b/site/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 +const TODO = '<li><span class="rm-sr" data-pagefind-ignore="">To do</span><h2 id="' +const todos = [...out.matchAll(/<li><span class="rm-sr"[^>]*>To do<\/span><h2 id="([^"]+)"/g)].map((m) => m[1]) +check(todos.length === nTodo && out.split(TODO).length - 1 === nTodo, `to-dos: ${todos.length} rendered, ${nTodo} in the data`) +check(out.split('<li class="rm-note"><span class="rm-sr" data-pagefind-ignore="">Note</span><h2').length - 1 === 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/site/scripts/postbuild.ts b/site/scripts/postbuild.ts new file mode 100644 index 00000000..cc64891d --- /dev/null +++ b/site/scripts/postbuild.ts @@ -0,0 +1,131 @@ +/** + * 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). + * 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. + */ +import { 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 + // 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`) + 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)`) +} + +// ---------------------------------------------------------------- 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). +const EXCLUDE = [ + '.toc-inline', '.code-head', '.copy-button', '.code-lang', '.heading-anchor', + '.gx-meta', '.gx-kind', '.gx-chips', '.gx-fx-m', '.gx-fx-d', '.gx-fx-c', '.gx-catc', '.gx-filter', + '.gx-bar', '.gx-bar-h', '.gx-bar-l', '.gx-tl-s', '.gx-meta-k', '.gx-wf dt', '.rm-sr', '.rm-count', +] +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'))}/`) diff --git a/site/scripts/preview.ts b/site/scripts/preview.ts new file mode 100644 index 00000000..7ec04462 --- /dev/null +++ b/site/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/site/scripts/site-files.ts b/site/scripts/site-files.ts new file mode 100644 index 00000000..bcba7156 --- /dev/null +++ b/site/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/site/src/app.js b/site/src/app.js deleted file mode 100644 index d5fdb214..00000000 --- a/site/src/app.js +++ /dev/null @@ -1,920 +0,0 @@ -(() => { - 'use strict'; - const ICONS = {{icons_json}}; - const $ = (s, r = document) => r.querySelector(s); - const $$ = (s, r = document) => [...r.querySelectorAll(s)]; - const root = document.documentElement; - const body = document.body; - const reduceMotion = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; - const icon = name => ICONS[name] || ''; - - /* ------------------------------------------------------------------ Theme */ - const THEME_KEY = 'deco-blocks-docs-theme'; - const mqDark = window.matchMedia ? matchMedia('(prefers-color-scheme: dark)') : null; - // The header toggle, plus the one in the mobile drawer (the landing moves it there below 900px). - const themeBtns = $$('[data-theme-toggle]'); - const effectiveTheme = () => { - const t = root.dataset.theme; - if (t === 'dark' || t === 'light') return t; - return mqDark && mqDark.matches ? 'dark' : 'light'; - }; - function syncThemeButton() { - const next = effectiveTheme() === 'dark' ? 'light' : 'dark'; - themeBtns.forEach(btn => { - btn.setAttribute('aria-label', 'Switch to ' + next + ' mode'); - btn.title = 'Switch to ' + next + ' mode'; - const label = $('.theme-label', btn); - if (label) label.textContent = next === 'dark' ? 'Dark mode' : 'Light mode'; - }); - } - function setTheme(theme) { - root.dataset.theme = theme; - try { localStorage.setItem(THEME_KEY, theme); } catch (e) {} - syncThemeButton(); - } - themeBtns.forEach(btn => btn.addEventListener('click', () => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark'))); - if (mqDark && mqDark.addEventListener) mqDark.addEventListener('change', syncThemeButton); - syncThemeButton(); - - /* -------------------------------------------------------------- Structure */ - const pages = $$('main .page'); - const sections = $$('main .page > section'); - const pageOf = new Map(); - pages.forEach(page => $$(':scope > section', page).forEach(section => pageOf.set(section.id, page.dataset.page))); - const groupsByPage = { - home: [], - docs: [["Getting started", ["architecture", "quickstart"]], ["Core concepts", ["model", "content", "schema", "preview"]], ["Advanced", ["manifest", "variants"]], ["Websites", ["content-types", "rendering", "routing"]], ["Reference", ["standalone"]], ["Framework guides", ["nextjs", "tanstack-data", "tanstack-rsc"]], ["Production", ["releases", "operations", "adoption", "troubleshooting"]]], - internals: [["Under the hood", ["internals", "walkthrough", "loader-internals", "router-internals", "studio", "decisions"]]], - roadmap: [["Overview", ["roadmap"]], ["Release blockers", ["roadmap-blockers"]], ["Studio support", ["roadmap-studio-new", "roadmap-studio-legacy"]], ["Work items", ["roadmap-api", "roadmap-cli", "roadmap-platform", "roadmap-docs"]], ["Site migrations", ["roadmap-storefront", "roadmap-blog", "roadmap-faststore"]], ["Feature readiness", ["roadmap-features"]]], - }; - const pageLabels = { home: 'Home', docs: 'Docs', internals: 'Under the hood', roadmap: 'Roadmap' }; - const pageHeadings = { docs: 'Deco Blocks documentation', internals: 'Deco Blocks: under the hood', roadmap: 'Deco Blocks: next-major roadmap' }; - const groupIcons = { 'Getting started': 'play', 'Core concepts': 'box', 'Advanced': 'sliders', 'Websites': 'globe', 'Reference': 'book', 'Framework guides': 'layers', 'Production': 'server', 'Under the hood': 'cpu', 'Overview': 'eye', 'Release blockers': 'zap', 'Studio support': 'pencil', 'Work items': 'git-branch', 'Site migrations': 'globe', 'Feature readiness': 'list' }; - const pageEntry = { home: 'home', docs: 'architecture', internals: 'internals', roadmap: 'roadmap' }; - const groupOf = new Map(); - const reading = []; - // One reading order across the doc pages: the pager runs Docs → Under the hood → Roadmap. - ['docs', 'internals', 'roadmap'].forEach(page => groupsByPage[page].forEach(([group, ids]) => ids.forEach(id => { groupOf.set(id, group); reading.push(id); }))); - const byId = id => document.getElementById(id); - const navLabel = section => section.dataset.nav || ($('h2', section) && $('h2', section).textContent.trim()) || 'Overview'; - // A section alone in a sidebar group of the same name (the Roadmap's "Overview") goes by its - // heading in search results and the pager, where "Overview" alone would say little. - const soleInGroup = section => groupOf.get(section.id) === navLabel(section); - const cardTitle = section => (soleInGroup(section) && section.dataset.title) || navLabel(section); - - /* ------------------------------------------------ Heading ids and anchors */ - const slugify = text => text.toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '') - .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'section'; - // A section's title heading: the landing's h1, otherwise the article's h2. - const titleHeading = section => $('h1', section) || $('h2', section); - // The permalink sits inside its heading; the heading keeps its own text as its name, so a - // screen reader's heading list says "API reference", not "API reference Link to API reference". - const addAnchor = (heading, href, text) => { - heading.setAttribute('aria-label', text); - const a = document.createElement('a'); - a.className = 'heading-anchor'; a.href = href; a.innerHTML = icon('link'); - a.setAttribute('aria-label', 'Link to ' + text); - heading.append(a); - }; - sections.forEach(section => { - const h2 = titleHeading(section); - if (h2) { section.dataset.title = h2.textContent.trim().replace(/\s+/g, ' '); h2.tabIndex = -1; } - if (section.id === 'home') return; - if (h2) addAnchor(h2, '#' + section.id, section.dataset.title); - $$('h3', section).forEach(h3 => { - const text = h3.textContent.trim(); - if (!h3.id) { - const base = section.id + '--' + slugify(text); - let id = base, n = 2; - while (byId(id)) id = base + '-' + n++; - h3.id = id; - } - h3.dataset.title = text; - addAnchor(h3, '#' + h3.id, text); - }); - }); - - /* ------------------------------------------------------------------ Toast */ - const live = $('#live'); - function announce(message) { - // Cleared first and set a beat later, so a repeat of the same message is still read out. - live.textContent = ''; - clearTimeout(announce.timer); announce.timer = setTimeout(() => { live.textContent = message; }, 60); - } - function toast(message) { - const el = $('#toast'); - el.textContent = message; el.hidden = false; - announce(message); - clearTimeout(toast.timer); toast.timer = setTimeout(() => { el.hidden = true; }, 2200); - } - async function copyText(text) { - try { await navigator.clipboard.writeText(text); return true; } catch (e) { - const field = document.createElement('textarea'); - field.value = text; field.setAttribute('readonly', ''); field.style.position = 'fixed'; field.style.opacity = '0'; - document.body.append(field); field.select(); - let ok = false; try { ok = document.execCommand('copy'); } catch (err) {} - field.remove(); return ok; - } - } - - /* ------------------------------------------------------------ Code blocks */ - const languageLabels = { typescript: 'TypeScript', tsx: 'TSX', json: 'JSON', jsonc: 'JSON', bash: 'Shell', yaml: 'YAML', text: 'Text' }; - const extensionLanguages = { ts: 'typescript', mts: 'typescript', tsx: 'tsx', jsx: 'tsx', js: 'typescript', json: 'json', jsonc: 'jsonc', sh: 'bash', yml: 'yaml', yaml: 'yaml' }; - // JSX in a TypeScript block: a closing tag, a self-closing capitalized tag, or a fragment. - const looksLikeJsx = text => /<\/[A-Za-z][\w.]*>|<[A-Z][\w.]*(\s[^<>]*)?\/>|<>|<\/>/.test(text); - function fileNameFor(pre) { - const caption = pre && pre.closest('figure.code-example') && pre.closest('figure.code-example').querySelector('figcaption'); - return caption ? caption.textContent.trim() : ''; - } - function highlightCode(code, language) { - const pre = code.closest('pre'); - const fileName = fileNameFor(pre); - const extension = (fileName.match(/\.(\w+)(?:\s|$|\))/) || [])[1]; - language = language || extensionLanguages[extension] || (code.className.match(/language-([\w-]+)/) || [])[1] || 'typescript'; - if (language === 'typescript' && looksLikeJsx(code.textContent)) language = 'tsx'; - code.className = 'language-' + language; - if (pre) { - pre.dataset.lang = languageLabels[language] || language; - pre.dataset.label = fileName || pre.dataset.lang; - pre.classList.toggle('has-file', Boolean(fileName)); - if (fileName) pre.setAttribute('aria-label', fileName); - } - if (window.Prism && Prism.languages[language]) Prism.highlightElement(code); - } - $$('pre > code').forEach(code => { if (!code.closest('.jp, .ci-visual')) highlightCode(code); }); - const langIcon = label => label === 'Shell' ? icon('terminal') : icon('code'); - - function makeCopyButton(getText, withLabel) { - const btn = document.createElement('button'); - btn.type = 'button'; btn.className = 'copy-button' + (withLabel ? ' has-label' : ''); - btn.setAttribute('aria-label', 'Copy code example'); - btn.innerHTML = icon('copy') + icon('check') + (withLabel ? '<span class="copy-label">Copy</span>' : ''); - const label = btn.querySelector('.copy-label'); - btn.addEventListener('click', async () => { - const ok = await copyText(getText()); - if (!ok) { toast('Select the code to copy it.'); return; } - btn.classList.add('copied'); btn.setAttribute('aria-label', 'Copied'); announce('Copied to clipboard'); - if (label) label.textContent = 'Copied'; - setTimeout(() => { btn.classList.remove('copied'); btn.setAttribute('aria-label', 'Copy code example'); if (label) label.textContent = 'Copy'; }, 1800); - }); - return btn; - } - const langBadge = label => { const lang = document.createElement('span'); lang.className = 'code-lang'; lang.textContent = label; lang.setAttribute('aria-hidden', 'true'); return lang; }; - // "cms.ts" is a path; "A plain request handler" describes the block; "blocks.tsx - // (Next.js)" and ".deco/schema.json, abridged" are a path with a note. - const PATH = /^([^\s,]*\/[^\s,]*|[^\s,]+\.[a-z]\w*)(?=$|[\s,])/i; - $$('pre').forEach(pre => { - if (pre.closest('.explorer, .jp, .ci-visual, .sw')) return; - const code = $('code', pre); - const text = () => (code ? code.textContent : pre.textContent); - const file = fileNameFor(pre); - const raw = text().replace(/\s+$/, ''); - const isBash = /language-bash/.test(code ? code.className : ''); - const panel = document.createElement('div'); - panel.className = 'code-panel' + (raw.includes('\u2502') ? ' is-diagram' : ''); - pre.before(panel); - let label = pre.dataset.lang || 'Code'; - if (!file && isBash && /^https?:\/\//.test(raw)) label = 'URL'; - if (!pre.hasAttribute('aria-label')) pre.setAttribute('aria-label', label === 'URL' ? 'URL' : label + ' code'); - if (!file && !raw.includes('\n') && isBash && /^(npm|npx|pnpm|yarn|bun)\b/.test(raw)) { - // A one-line shell command: a compact prompt row with its language badge and copy button. - panel.classList.add('is-oneline', 'is-cmd'); - panel.append(pre, langBadge(label), makeCopyButton(text, false)); - return; - } - const path = file && file.match(PATH); - const kind = !file ? 'is-lang' : path ? '' : 'is-desc'; - const head = document.createElement('div'); - head.className = 'code-head' + (kind ? ' ' + kind : ''); - head.innerHTML = kind ? langIcon(label) : icon('file'); - const name = document.createElement('span'); name.className = 'code-file'; name.setAttribute('aria-hidden', 'true'); - if (path && path[0].length < file.length) { - const note = document.createElement('span'); note.className = 'cf-note'; note.textContent = file.slice(path[0].length); - name.append(path[0], note); - } else name.textContent = file || label; - head.append(name); - if (file) head.append(langBadge(label)); - head.append(makeCopyButton(text, true)); - panel.append(head, pre); - }); - - /* Scroll hints and focus for code that doesn't fit its panel. A block that scrolls is a tab - stop (a named region, so arrow keys can scroll it); one that fits is not. The fade marks the - edge it continues past, and goes once that edge is reached. */ - function fadeCode(pre) { - const x = pre.scrollLeft, maxX = pre.scrollWidth - pre.clientWidth, 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); - } - function fitCode(scope) { - $$('.code-panel > pre', scope).forEach(pre => { - 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'); } - else { pre.removeAttribute('tabindex'); pre.removeAttribute('role'); } - fadeCode(pre); - }); - } - $$('.code-panel > pre').forEach(pre => pre.addEventListener('scroll', () => { - if (pre.fadeTick) return; - pre.fadeTick = requestAnimationFrame(() => { pre.fadeTick = 0; fadeCode(pre); }); - }, { passive: true })); - - // Short inline code ("product-card") stays on one line; long tokens may still wrap anywhere. - $$('.doc-section :not(pre) > code').forEach(c => { if (c.textContent.trim().length <= 24) c.classList.add('is-short'); }); - $$('main table').forEach(table => { - if (!table.parentElement.classList.contains('table-wrap')) { - const wrap = document.createElement('div'); wrap.className = 'table-wrap'; - table.before(wrap); wrap.append(table); - } - // Multi-word code (signatures like `remoteLoader(content, { token })`) may wrap at its - // spaces on phones; single identifiers such as "not-found" stay on one line. - $$('td code, th code', table).forEach(c => { if (/\s/.test(c.textContent.trim())) c.classList.add('can-wrap'); }); - }); - - /* ------------------------------------------------------------ Sidebar toc */ - const toc = $('#toc'); - const navLinks = new Map(); - let tocPage = null; - function buildToc(page) { - const source = page === 'home' ? 'docs' : page; - if (tocPage === source) return; - tocPage = source; - toc.replaceChildren(); navLinks.clear(); - groupsByPage[source].forEach(([title, ids], gi) => { - const group = document.createElement('div'); group.className = 'nav-group'; - const label = document.createElement('p'); label.className = 'nav-label'; - label.id = 'nav-group-' + source + '-' + gi; - label.innerHTML = icon(groupIcons[title] || 'book'); label.append(title); - const list = document.createElement('ul'); list.className = 'nav-list'; - list.setAttribute('aria-labelledby', label.id); - ids.forEach(id => { - const section = byId(id); if (!section) return; - const li = document.createElement('li'); - const a = document.createElement('a'); a.href = '#' + id; a.textContent = navLabel(section); - li.append(a); list.append(li); navLinks.set(id, a); - }); - group.append(label, list); toc.append(group); - }); - } - function fadeToc() { - const max = toc.scrollHeight - toc.clientHeight; - toc.classList.toggle('more-above', toc.scrollTop > 1); - toc.classList.toggle('more-below', max > 1 && toc.scrollTop < max - 1); - } - toc.addEventListener('scroll', fadeToc, { passive: true }); - - /* ----------------------------------------------------------- Article chrome */ - const crumb = $('#breadcrumb'); - function buildBreadcrumb(section) { - const page = pageOf.get(section.id); - crumb.replaceChildren(); - const add = (text, href) => { - const li = document.createElement('li'); - if (href) { const a = document.createElement('a'); a.href = href; a.textContent = text; li.append(a); } - else { const span = document.createElement('span'); span.textContent = text; li.append(span); } - crumb.append(li); return li; - }; - add(pageLabels[page], '#' + pageEntry[page]); - const group = groupOf.get(section.id); - if (group && group !== pageLabels[page] && group !== navLabel(section)) add(group); - const last = add(navLabel(section)); - last.firstChild.setAttribute('aria-current', 'page'); - } - const pager = $('#pager'); - function pagerCard(id, dir) { - const section = byId(id); - const a = document.createElement('a'); - a.href = '#' + id; a.className = dir === 'prev' ? 'pg-prev' : 'pg-next'; - const d = document.createElement('span'); d.className = 'pg-dir'; - d.innerHTML = dir === 'prev' ? icon('arrow-left') + '<span>Previous</span>' : '<span>Next</span>' + icon('arrow-right'); - const t = document.createElement('span'); t.className = 'pg-title'; t.textContent = cardTitle(section); - const s = document.createElement('span'); s.className = 'pg-sub'; - const page = pageOf.get(id); - const group = groupOf.get(id); - s.textContent = group && group !== pageLabels[page] && group !== navLabel(section) ? pageLabels[page] + ' › ' + group : pageLabels[page]; - a.append(d, t, s); - return a; - } - function buildPager(section) { - pager.replaceChildren(); - const i = reading.indexOf(section.id); - if (i < 0) return; - if (i > 0) pager.append(pagerCard(reading[i - 1], 'prev')); - if (i < reading.length - 1) pager.append(pagerCard(reading[i + 1], 'next')); - } - - /* -------------------------------------------------- Right rail + scroll-spy */ - const railList = $('#rail-list'); - const indicator = $('#rail-indicator'); - const inlineList = $('#toc-inline-list'); - const tocInline = $('#toc-inline'); - let spy = []; - function railLabel(el) { - const clone = el.cloneNode(true); - $$('.heading-anchor', clone).forEach(n => n.remove()); - return clone.innerHTML.trim(); - } - const railNav = $('#rail-nav'); - inlineList.addEventListener('click', event => { if (event.target.closest('a')) tocInline.open = false; }); - function placeInlineToc(section) { - // Below 1200px the page outline sits under the title and lede, not above them. - const h2 = $('h2', section); - let anchor = h2; - if (h2 && h2.nextElementSibling && h2.nextElementSibling.tagName === 'P') anchor = h2.nextElementSibling; - if (anchor) anchor.after(tocInline); else section.prepend(tocInline); - } - function buildRail(section) { - railList.replaceChildren(); inlineList.replaceChildren(); - spy = []; - const items = [{ id: section.id, el: section, html: 'Overview', top: true }] - .concat($$('h3', section).filter(h3 => !h3.hidden).map(h3 => ({ id: h3.id, el: h3, html: railLabel(h3) }))); - railNav.hidden = items.length < 2; - placeInlineToc(section); - items.forEach(item => { - const li = document.createElement('li'); - const a = document.createElement('a'); a.href = '#' + item.id; a.innerHTML = item.html; - li.append(a); railList.append(li); - const li2 = document.createElement('li'); if (item.top) li2.className = 'is-top'; - const a2 = document.createElement('a'); a2.href = '#' + item.id; a2.innerHTML = item.html; - li2.append(a2); inlineList.append(li2); - spy.push({ el: item.el, link: a }); - }); - tocInline.open = false; - tocInline.hidden = items.length < 2; - } - let lastActive = null; - document.addEventListener('roadmap:filter', () => { - if (activeSection && activeSection.id === 'roadmap-features') { buildRail(activeSection); lastActive = null; updateSpy(); } - }); - function updateSpy() { - if (!spy.length || body.dataset.layout !== 'docs') return; - const line = $('#site-header').offsetHeight + 40; - let current = spy[0]; - for (let i = 1; i < spy.length; i++) if (spy[i].el.getBoundingClientRect().top <= line) current = spy[i]; - const doc = document.documentElement; - if (scrollY > 0 && doc.scrollHeight > innerHeight + 8 && innerHeight + scrollY >= doc.scrollHeight - 2) { - // At the very bottom the last headings can't reach the line; pick the last one that is on screen. - for (let i = spy.length - 1; i > 0; i--) { if (spy[i].el.getBoundingClientRect().top < innerHeight) { current = spy[i]; break; } } - } - if (current !== lastActive) { - spy.forEach(s => { s.link.classList.toggle('active', s === current); if (s === current) s.link.setAttribute('aria-current', 'location'); else s.link.removeAttribute('aria-current'); }); - lastActive = current; - // A long outline scrolls inside the rail; keep the current entry in view, clear of the edges. - if (railNav.scrollHeight > railNav.clientHeight + 1) { - const box = railNav.getBoundingClientRect(), r = current.link.getBoundingClientRect(); - if (r.top < box.top + 40 || r.bottom > box.bottom - 24) railNav.scrollTop += r.top - box.top - box.height / 3; - } - } - const a = current.link; - if (a.offsetParent) { - indicator.style.transform = 'translateY(' + a.offsetTop + 'px)'; - indicator.style.height = a.offsetHeight + 'px'; - indicator.classList.add('on'); - } - } - - /* ----------------------------------------------------------------- Header */ - const header = $('#site-header'); - function updateHeader() { - // On the landing the header keeps its on-band look everywhere: over the hero it is - // transparent, and once scrolled it becomes the site's dark floating pill. - const on = body.dataset.layout === 'landing'; - header.classList.toggle('on-band', Boolean(on)); - header.classList.toggle('is-scrolled', window.scrollY > 4); - } - - /* ------------------------------------------------ Entrance on scroll-in */ - // The site's deco-reveal: fade up once when a block first enters the viewport. - let pendingReveals = $$('.reveal'); - const revealAll = () => { pendingReveals.forEach(el => el.classList.add('in')); pendingReveals = []; }; - // Fallback beside the observer: anything already scrolled into (or past) view is shown. - function checkReveals() { - if (!pendingReveals.length || body.dataset.layout !== 'landing') return; - const limit = innerHeight * 0.95; - pendingReveals = pendingReveals.filter(el => { - const r = el.getBoundingClientRect(); - if (r.width && r.top < limit) { el.classList.add('in'); return false; } - return true; - }); - } - if (reduceMotion || !('IntersectionObserver' in window)) revealAll(); - else { - const io = new IntersectionObserver(entries => entries.forEach(entry => { - if (entry.isIntersecting) { entry.target.classList.add('in'); io.unobserve(entry.target); pendingReveals = pendingReveals.filter(el => el !== entry.target); } - }), { rootMargin: '0px 0px -6% 0px', threshold: 0.05 }); - pendingReveals.forEach(el => io.observe(el)); - window.addEventListener('beforeprint', revealAll); - } - - /* ---------------------------------------------------------------- Routing */ - let activePage = null; - let activeSection = null; - function setTabs(page) { - $$('.tabs a, .drawer-tabs a').forEach(a => { - if (a.dataset.tab === page) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); - }); - } - function showSection(section) { - const page = pageOf.get(section.id); - if (page !== activePage) { - activePage = page; - pages.forEach(p => p.classList.toggle('active', p.dataset.page === page)); - body.dataset.layout = page === 'home' ? 'landing' : 'docs'; - body.dataset.page = page; - setTabs(page); - buildToc(page); - const ph = $('#page-heading'); if (ph) ph.textContent = pageHeadings[page] || pageHeadings.docs; - } - if (section !== activeSection) { - activeSection = section; - sections.forEach(s => s.classList.toggle('is-current', s === section)); - navLinks.forEach((a, id) => { const on = id === section.id; a.classList.toggle('active', on); if (on) a.setAttribute('aria-current', 'page'); else a.removeAttribute('aria-current'); }); - lastActive = null; - if (page !== 'home') { buildBreadcrumb(section); buildPager(section); buildRail(section); fitCode(section); } - document.title = page === 'home' ? 'Deco Blocks — The AI-native headless CMS' : (navLabel(section) === 'Overview' ? pageLabels[page] : navLabel(section)) + ' — Deco Blocks'; - const active = navLinks.get(section.id); - if (active && active.scrollIntoView) { - // Keep the current link clear of the edge fades (28px top, 48px bottom), not just inside the box. - const box = toc.getBoundingClientRect(), r = active.getBoundingClientRect(); - if ((r.top < box.top + 28 && toc.scrollTop > 0) || r.bottom > box.bottom - 48) toc.scrollTop += r.top - box.top - box.height / 3; - } - fadeToc(); - } - } - // A deep link into a collapsed <details> opens it first, so the target can be scrolled to. - // (The Roadmap's feature list clears its own status/site filter, before this runs.) - function revealTarget(target, section) { - for (let d = target.closest('details'); d && section.contains(d); d = d.parentElement.closest('details')) d.open = true; - } - function currentId() { - const raw = location.hash.slice(1); - try { return decodeURIComponent(raw); } catch (e) { return raw; } - } - function route(fromUser) { - const id = currentId() || 'home'; - let target = byId(id); - let section = target && target.closest('main .page > section'); - if (!section) { - const entry = pageEntry[id]; - section = byId(entry || 'home'); - target = section; - } - const switching = section !== activeSection; - showSection(section); - closeMenu(false); - if (target && target !== section) revealTarget(target, section); - if (!target || target === section) { - window.scrollTo(0, 0); - if (fromUser && switching) { const h = titleHeading(section); if (h) h.focus({ preventScroll: true }); } - } else { - target.scrollIntoView({ block: 'start', behavior: !switching && !reduceMotion ? 'smooth' : 'auto' }); - } - updateHeader(); - requestAnimationFrame(() => { updateSpy(); checkReveals(); }); - } - document.addEventListener('click', event => { - const a = event.target.closest && event.target.closest('a[href^="#"]'); - if (!a || event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return; - const href = a.getAttribute('href'); - if (href === location.hash || (href === '#home' && !location.hash)) { event.preventDefault(); route(true); } - }); - window.addEventListener('hashchange', () => route(true)); - - $('.skip').addEventListener('click', event => { - event.preventDefault(); - const target = body.dataset.layout === 'docs' && activeSection ? ($('h2', activeSection) || $('#main')) : $('#main'); - target.focus(); - }); - $('#back-to-top').addEventListener('click', () => { - window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' }); - const h = activeSection && $('h2', activeSection); if (h) h.focus({ preventScroll: true }); - }); - - /* ------------------------------------------------------------ Mobile menu */ - const sidebar = $('#sidebar'), overlay = $('#mobile-overlay'), menu = $('#menu-button'); - // While open, the drawer is modal: the page behind it is inert for keyboard and screen readers. - const behindDrawer = () => [$('#main'), $('.site-footer')].filter(Boolean); - function setDrawerModal(on) { - if (on) { sidebar.setAttribute('role', 'dialog'); sidebar.setAttribute('aria-modal', 'true'); } - else { sidebar.removeAttribute('role'); sidebar.removeAttribute('aria-modal'); } - behindDrawer().forEach(el => { el.inert = on; }); - } - function openMenu() { - body.classList.add('nav-open'); menu.setAttribute('aria-expanded', 'true'); - setDrawerModal(true); - const first = $('#toc a.active') || $('.drawer-tabs a') || $('#menu-close'); - setTimeout(() => first && first.focus({ preventScroll: true }), 50); - } - function closeMenu(restoreFocus) { - if (!body.classList.contains('nav-open')) return; - body.classList.remove('nav-open'); menu.setAttribute('aria-expanded', 'false'); - setDrawerModal(false); - if (restoreFocus) menu.focus(); - } - menu.addEventListener('click', () => body.classList.contains('nav-open') ? closeMenu(true) : openMenu()); - $('#menu-close').addEventListener('click', () => closeMenu(true)); - overlay.addEventListener('click', () => closeMenu(true)); - sidebar.addEventListener('keydown', event => { - if (event.key !== 'Tab' || !body.classList.contains('nav-open')) return; - const focusables = $$('a[href], button', sidebar).filter(el => el.offsetParent !== null); - const first = focusables[0], last = focusables[focusables.length - 1]; - if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } - else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } - }); - - /* ----------------------------------------------------------------- Search */ - const dialog = $('#search-dialog'), searchInput = $('#search-input'), results = $('#search-results'), searchStatus = $('#search-status'); - // Combobox pattern: the input keeps focus; aria-activedescendant names the result Enter opens. - let optionSeq = 0; - function setActive(link) { - $$('a', results).forEach(a => { const on = a === link; a.classList.toggle('is-active', on); a.setAttribute('aria-selected', on ? 'true' : 'false'); }); - if (link) searchInput.setAttribute('aria-activedescendant', link.id); else searchInput.removeAttribute('aria-activedescendant'); - } - const index = []; - // Index what the reader sees in the content, not the injected code-panel chrome. - // Two elements that touch with no text between them (a caption and its code, a node's title - // and its note, table cells) are separate runs of text; code inside <pre> is left as written. - // (On the Roadmap page that also leaves out metadata, tags, feature chips, runs of links, the - // detail labels, the section counts and the screen-reader "To do".) - const unindexed = '.code-head, .copy-button, .code-lang, .heading-anchor, .gx-meta, .gx-kind, .gx-chips, .gx-fx-m, .gx-fx-d, .gx-fx-c, .gx-catc, .gx-filter, .gx-bar, .gx-bar-h, .gx-bar-l, .gx-tl-s, .gx-meta-k, .gx-wf dt, .gx-wf > div:not(.rm-t) + div, .rm-sr, .rm-count'; - const indexText = el => { - if (!el.querySelector) return el.textContent; - if (el.matches(unindexed)) return ''; - const clone = el.cloneNode(true); - $$(unindexed, clone).forEach(n => n.remove()); - $$('*', clone).forEach(n => { if (n.nextSibling && n.nextSibling.nodeType === 1 && !n.closest('pre')) n.after(' '); }); - return clone.textContent; - }; - sections.forEach(section => { - const page = pageOf.get(section.id); - const secLabel = page === 'home' ? 'Overview' : navLabel(section); - // The overview entry also carries the article's visible title and eyebrow, so a search - // for the title ("Routes, redirects, and content types") finds the section itself. - // The eyebrow (usually the group name) is searchable but earns no title bonus. - const eyebrow = [...section.children].find(c => c.classList.contains('eyebrow')); - const title = (section.dataset.title || '').replace(/\s+/g, ' ').trim(); - let bucket = { id: section.id, page, section: secLabel, name: page === 'home' ? '' : cardTitle(section), heading: '', title, text: eyebrow ? indexText(eyebrow) : '' }; - const flush = () => { bucket.text = bucket.text.replace(/\s+/g, ' ').trim(); if (bucket.text || !bucket.heading) index.push(bucket); }; - // On the Roadmap page an <h3> may sit inside a list item (a to-do); each one still starts its own - // entry there. (The doc pages keep indexing their direct children only, as before.) - const walk = parent => [...parent.children].forEach(child => { - if (child.tagName === 'H3') { flush(); bucket = { id: child.id, page, section: secLabel, heading: child.dataset.title || child.textContent.trim(), title: '', text: '' }; } - else if (page === 'roadmap' && child.querySelector('h3')) walk(child); - else if (child.tagName !== 'H2' && !child.classList.contains('eyebrow')) bucket.text += ' ' + indexText(child); - }); - walk(section); - flush(); - }); - // Every row of the Roadmap's feature list is its own result, so a feature's name opens its row. - $$('main .page[data-page="roadmap"] [id^="roadmap-f-"]').forEach(row => { - const section = row.closest('main .page > section'); - if (!section) return; - const named = $('h4', row); - const heading = (named ? named.textContent : '').replace(/\s+/g, ' ').trim(); - if (!heading) return; - const text = indexText(row).replace(/\s+/g, ' ').trim(); - index.push({ id: row.id, page: 'roadmap', section: navLabel(section), heading, title: '', text: text.startsWith(heading) ? text.slice(heading.length).trim() : text }); - }); - index.forEach(item => { - item.hay = (item.section + ' ' + item.title + ' ' + item.heading + ' ' + item.text).toLowerCase(); - item.label = (item.section + ' ' + item.title).toLowerCase(); - }); - const escapeRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - function highlightInto(el, text, words) { - if (!words.length) { el.textContent = text; return; } - const re = new RegExp('(' + words.map(escapeRe).join('|') + ')', 'gi'); - text.split(re).forEach((part, i) => { - if (i % 2) { const m = document.createElement('mark'); m.textContent = part; el.append(m); } - else if (part) el.append(document.createTextNode(part)); - }); - } - const suggestions = ['quickstart', 'model', 'routing', 'preview', 'standalone', 'nextjs', 'walkthrough', 'troubleshooting']; - function resultLink(item, words) { - const a = document.createElement('a'); a.href = '#' + item.id; - a.id = 'search-opt-' + (++optionSeq); a.setAttribute('role', 'option'); a.setAttribute('aria-selected', 'false'); - const ico = document.createElement('span'); ico.className = 'sr-icon'; ico.innerHTML = item.heading ? icon('hash') : icon('file'); - const bodyEl = document.createElement('span'); bodyEl.className = 'sr-body'; - const path = document.createElement('span'); path.className = 'sr-path'; - const group = groupOf.get(item.id); - path.textContent = pageLabels[item.page] + (item.heading ? ' › ' + item.section : (group && group !== pageLabels[item.page] && group !== item.section ? ' › ' + group : '')); - const title = document.createElement('span'); title.className = 'sr-title'; - highlightInto(title, item.heading || item.name || item.section, words); - const enter = document.createElement('span'); enter.className = 'sr-enter'; enter.innerHTML = icon('corner'); - bodyEl.append(path, title); - a.append(ico, bodyEl, enter); - if (words.length) { - const lower = item.text.toLowerCase(); - let at = -1; for (const w of words) { at = lower.indexOf(w); if (at >= 0) break; } - if (at >= 0) { - const start = Math.max(0, at - 48); - const snip = document.createElement('span'); snip.className = 'sr-snip'; - highlightInto(snip, (start ? '…' : '') + item.text.slice(start, start + 180) + '…', words); - bodyEl.append(snip); - } - } - a.addEventListener('click', () => { dialog.close(); closeMenu(false); }); - return a; - } - function search(query) { - results.replaceChildren(); - const words = query.toLowerCase().trim().split(/\s+/).filter(Boolean); - if (!words.length) { - const label = document.createElement('p'); label.className = 'search-label'; label.textContent = 'Suggested'; label.setAttribute('aria-hidden', 'true'); results.append(label); - suggestions.forEach(id => { const item = index.find(i => i.id === id); if (item) results.append(resultLink(item, [])); }); - setActive($('a', results)); - searchInput.setAttribute('aria-expanded', 'true'); - searchStatus.textContent = ''; - return; - } - let scored = index.filter(item => words.every(w => item.hay.includes(w))).map(item => { - let score = 0; - words.forEach(w => { - if (item.heading.toLowerCase().includes(w)) score += 6; - if (item.section.toLowerCase().includes(w)) score += 4; - else if (item.title.toLowerCase().includes(w)) score += 3; - if (!item.heading && item.label.includes(w)) score += 8; - score += Math.min(3, item.text.toLowerCase().split(w).length - 1); - }); - if (item.page === 'home') score -= 3; - return { item, score }; - }).sort((a, b) => b.score - a.score); - // The Roadmap page's many items would crowd the docs out of a search made from the docs: - // there, docs results come first and Roadmap ones follow (at least three kept). - if (activePage !== 'roadmap') { - const docs = scored.filter(s => s.item.page !== 'roadmap'), roadmap = scored.filter(s => s.item.page === 'roadmap'); - const mine = docs.slice(0, 20 - Math.min(3, roadmap.length)); - scored = mine.concat(roadmap.slice(0, 20 - mine.length)); - } else scored = scored.slice(0, 20); - if (!scored.length) { - const p = document.createElement('p'); p.className = 'search-empty'; - p.textContent = 'No matching sections. Try “schema”, “rollback”, or “TanStack”.'; - p.setAttribute('aria-hidden', 'true'); - results.append(p); setActive(null); - searchInput.setAttribute('aria-expanded', 'false'); - searchStatus.textContent = 'No results.'; - return; - } - const count = document.createElement('p'); count.className = 'search-label'; count.setAttribute('aria-hidden', 'true'); - count.textContent = scored.length + (scored.length === 1 ? ' result' : ' results') + (scored.length === 20 ? ', best matches first' : ''); - results.append(count); - scored.forEach(({ item }) => results.append(resultLink(item, words))); - setActive($('a', results)); - searchInput.setAttribute('aria-expanded', 'true'); - searchStatus.textContent = count.textContent + '.'; - } - function openSearch() { - closeMenu(false); - searchInput.value = ''; search(''); - if (!dialog.open) dialog.showModal(); - searchInput.focus(); - } - $$('[data-search-open]').forEach(button => button.addEventListener('click', openSearch)); - $('#search-close').addEventListener('click', () => dialog.close()); - searchInput.addEventListener('input', () => search(searchInput.value)); - searchInput.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); dialog.close(); } }); - dialog.addEventListener('keydown', event => { - const links = $$('a', results); - if (!links.length) return; - const i = links.indexOf(document.activeElement); - const cur = links.findIndex(a => a.classList.contains('is-active')); - const move = to => { setActive(links[to]); links[to].scrollIntoView({ block: 'nearest' }); return links[to]; }; - if (event.key === 'ArrowDown') { event.preventDefault(); const to = Math.min(links.length - 1, (i >= 0 ? i : cur) + 1); move(to).focus(); } - else if (event.key === 'ArrowUp') { event.preventDefault(); const from = i >= 0 ? i : cur; if (from <= 0) { move(0); searchInput.focus(); } else move(from - 1).focus(); } - else if (event.key === 'Enter' && document.activeElement === searchInput) { event.preventDefault(); const target = links[cur >= 0 ? cur : 0]; if (target) target.click(); } - }); - dialog.addEventListener('click', event => { - if (event.target !== dialog) return; - const r = dialog.getBoundingClientRect(); - if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) dialog.close(); - }); - document.addEventListener('keydown', event => { - const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement && document.activeElement.tagName); - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); if (dialog.open) dialog.close(); else openSearch(); } - else if (event.key === '/' && !typing && !dialog.open) { event.preventDefault(); openSearch(); } - else if (event.key === 'Escape') closeMenu(true); - }); - - /* ---------------------------------------------------------- Scroll + print */ - let scheduled = false; - function onScroll() { scheduled = false; updateSpy(); updateHeader(); checkReveals(); } - window.addEventListener('scroll', () => { if (!scheduled) { scheduled = true; requestAnimationFrame(onScroll); } }, { passive: true }); - let fitTimer = 0; - window.addEventListener('resize', () => { - if (window.innerWidth >= 900) closeMenu(false); - onScroll(); - clearTimeout(fitTimer); - fitTimer = setTimeout(() => { if (activeSection && activeSection.id !== 'home') fitCode(activeSection); fadeToc(); }, 120); - }); - $$('[data-print]').forEach(button => button.addEventListener('click', () => window.print())); - $$('[data-copy-link]').forEach(button => button.addEventListener('click', async () => { - const id = activeSection ? activeSection.id : 'home'; - const url = location.href.split('#')[0].split('?')[0] + '#' + id; - const ok = await copyText(url); - toast(ok ? 'Link copied' : 'Copy the address bar to share this page.'); - })); - let printedDetails = []; - window.addEventListener('beforeprint', () => { printedDetails = $$('details').map(el => [el, el.open]); printedDetails.forEach(([el]) => { el.open = true; }); }); - window.addEventListener('afterprint', () => printedDetails.forEach(([el, open]) => { el.open = open; })); - - /* ------------------------------------------------------- Landing: journey */ - // Baseline is what's committed on main; values are what the Studio form shows. - const saved = { newCheckout: 10, stickyHeader: 50, freeShippingBanner: 0 }; - const expLabels = { newCheckout: 'new checkout', stickyHeader: 'sticky header', freeShippingBanner: 'free shipping banner' }; - const values = { newCheckout: 25, stickyHeader: 50, freeShippingBanner: 0 }; - const jsonCode = $('#exp-json'); - const expFoot = $('#exp-foot'); - const saveBtn = $('#studio-save'); - let lastCommit = ''; - function commitMessage() { - const changed = Object.keys(saved).filter(k => values[k] !== saved[k]); - if (changed.length === 1) { const k = changed[0]; return (values[k] > saved[k] ? 'ramp ' : 'lower ') + expLabels[k] + ' to ' + values[k] + '%'; } - return changed.length ? 'update ' + changed.length + ' experiments' : ''; - } - function renderJson(changedKey) { - if (!jsonCode) return; - const keys = Object.keys(saved); - const line = (cls, gut, inner) => '<span class="dl' + (cls ? ' ' + cls : '') + '"><span class="gut">' + gut + '</span>' + inner + '</span>'; - const prop = (k, v, comma) => ' <span class="tk-p">"' + k + '"</span>: <span class="tk-n">' + v + '</span>' + comma; - let html = line('', ' ', '{') + line('', ' ', ' <span class="tk-p">"__resolveType"</span>: <span class="tk-s">"experiments"</span>,'); - keys.forEach((k, i) => { - const comma = i < keys.length - 1 ? ',' : ''; - if (values[k] !== saved[k]) { - html += line('del', '-', prop(k, saved[k], comma)); - html += line('add' + (k === changedKey ? ' flash' : ''), '+', prop(k, values[k], comma)); - } else html += line('', ' ', prop(k, values[k], comma)); - }); - html += line('', ' ', '}'); - jsonCode.innerHTML = html; - const changed = keys.filter(k => values[k] !== saved[k]).length; - if (changed) { - expFoot.dataset.state = 'dirty'; - $('#exp-status').textContent = changed + (changed === 1 ? ' change' : ' changes') + ' · not committed yet'; - $('#exp-commit').textContent = commitMessage(); - } else if (lastCommit) { - expFoot.dataset.state = 'committed'; - $('#exp-status').textContent = 'Committed'; - $('#exp-commit').textContent = lastCommit; - } else { - expFoot.dataset.state = 'clean'; - $('#exp-status').textContent = 'No changes'; - $('#exp-commit').textContent = 'up to date with main'; - } - if (saveBtn) { saveBtn.disabled = !changed; saveBtn.textContent = changed ? 'Save' : 'Saved'; } - $('#exp-odds').textContent = values.newCheckout; - } - $$('.studio input[type="range"]').forEach(input => { - const key = input.dataset.exp; - const out = byId('out-' + key); - const sync = () => { - values[key] = Number(input.value); - input.style.setProperty('--val', input.value + '%'); - out.textContent = input.value; - renderJson(key); - }; - input.addEventListener('input', sync); - }); - renderJson(); - if (saveBtn) saveBtn.addEventListener('click', () => { - const message = commitMessage(); - if (!message) return; - lastCommit = message; - Object.assign(saved, values); - renderJson(); - expFoot.classList.remove('just'); void expFoot.offsetWidth; expFoot.classList.add('just'); - toast('In Studio, Save commits the file to your repository.'); - }); - $$('[data-copy]').forEach(button => button.addEventListener('click', async () => { - const ok = await copyText(button.dataset.copy); - toast(ok ? 'Copied: ' + button.dataset.copy : 'Select the text to copy it.'); - if (ok) { button.classList.add('copied'); setTimeout(() => button.classList.remove('copied'), 1600); } - })); - - /* ------------------------------------- Landing: journey carousel (phones) */ - // Below 768px the four panels become one scroll-snap row; the step pills follow and drive it. - const journey = $('.journey'); - const jpSteps = $$('.journey-nav [data-jp]'); - if (journey && jpSteps.length) { - const panels = $$('.jp', journey); - const markStep = i => jpSteps.forEach((b, j) => { if (j === i) b.setAttribute('aria-current', 'step'); else b.removeAttribute('aria-current'); }); - let jpTick = false; - journey.addEventListener('scroll', () => { - if (jpTick) return; jpTick = true; - requestAnimationFrame(() => { - jpTick = false; - const left = journey.scrollLeft, pad = parseFloat(getComputedStyle(journey).paddingLeft) || 0; - let best = 0, 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; - markStep(best); - }); - }, { passive: true }); - const showPanel = i => { - const pad = parseFloat(getComputedStyle(journey).paddingLeft) || 0; - journey.scrollTo({ left: panels[i].offsetLeft - pad, behavior: reduceMotion ? 'auto' : 'smooth' }); - markStep(i); - }; - jpSteps.forEach((b, i) => b.addEventListener('click', () => showPanel(i))); - journey.addEventListener('focusin', event => { - if (journey.scrollWidth <= journey.clientWidth + 1) return; - const i = panels.findIndex(p => p.contains(event.target)); - if (i < 0) return; - const box = journey.getBoundingClientRect(), r = panels[i].getBoundingClientRect(); - if (r.left < box.left - 1 || r.right > box.right + 1) showPanel(i); - }); - } - - /* ------------------------------------------------------- Landing: stepper */ - const stepTabs = $$('.stepper [role="tab"]'); - function selectStep(n, focus) { - stepTabs.forEach(tab => { - const on = tab.dataset.step === String(n); - tab.setAttribute('aria-selected', String(on)); - tab.tabIndex = on ? 0 : -1; - if (on && focus) tab.focus(); - }); - $$('.sw-pane').forEach(pane => pane.classList.toggle('is-on', pane.id === 'step-pane-' + n)); - $$('.sw-tab').forEach(t => t.classList.toggle('is-on', t.dataset.step === String(n))); - } - stepTabs.forEach((tab, i) => { - tab.addEventListener('click', () => selectStep(tab.dataset.step)); - tab.addEventListener('keydown', event => { - const k = event.key; - let to = null; - if (k === 'ArrowDown' || k === 'ArrowRight') to = (i + 1) % stepTabs.length; - else if (k === 'ArrowUp' || k === 'ArrowLeft') to = (i - 1 + stepTabs.length) % stepTabs.length; - else if (k === 'Home') to = 0; else if (k === 'End') to = stepTabs.length - 1; - if (to !== null) { event.preventDefault(); selectStep(stepTabs[to].dataset.step, true); } - }); - }); - $$('.sw-tab').forEach(t => t.addEventListener('click', () => selectStep(t.dataset.step))); - - /* ------------------------------------------------ Walkthrough */ - const traceCode = $('#trace-code'); - if (traceCode) { - let mode = 'data', operation = 'resolve', step = 0; - const product = { name: 'Summer shirt', price: 49 }; - const stored = { __resolveType: 'product-card', title: 'Summer collection', product: { __resolveType: 'CurrentProduct' } }; - const expanded = { __resolveType: 'product-card', title: 'Summer collection', product: { __resolveType: 'catalog-product', slug: 'summer-shirt' } }; - const children = { __resolveType: 'product-card', title: 'Summer collection', product }; - const stringify = value => JSON.stringify(value, null, 2); - const captions = { - 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 = { - 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'], - }; - function renderTrace() { - const last = captions[operation].length - 1; - step = Math.min(step, last); - $$('[data-trace-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.traceMode === mode))); - $$('[data-trace-operation]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.traceOperation === operation))); - $$('[data-trace-step]').forEach(button => { button.setAttribute('aria-current', Number(button.dataset.traceStep) === step ? 'step' : 'false'); button.disabled = Number(button.dataset.traceStep) > last; }); - let code = [stored, expanded, children][step] && stringify([stored, expanded, children][step]); - let language = 'json'; - if (step === 3) { - if (mode === 'data') code = stringify({ component: 'ProductCard', props: { title: 'Summer collection', product } }); - else { code = '<ProductCard\n title="Summer collection"\n product={{ name: "Summer shirt", price: 49 }}\n/>'; language = 'tsx'; } - } - traceCode.textContent = code; - highlightCode(traceCode, language); - $('#trace-caption').textContent = captions[operation][step]; - $('#trace-stat').textContent = stats[operation][step]; - $('#trace-next').disabled = step === last; - } - $$('[data-trace-mode]').forEach(button => button.addEventListener('click', () => { mode = button.dataset.traceMode; renderTrace(); })); - $$('[data-trace-operation]').forEach(button => button.addEventListener('click', () => { operation = button.dataset.traceOperation; step = 0; renderTrace(); })); - $$('[data-trace-step]').forEach(button => button.addEventListener('click', () => { step = Number(button.dataset.traceStep); renderTrace(); })); - $('#trace-next').addEventListener('click', () => { step += 1; renderTrace(); }); - renderTrace(); - } - - /* ------------------------------------------------------------------- Boot */ - route(false); - // Web fonts can reflow the article after the first scroll; re-anchor a deep link once they're in, - // unless the reader has already started scrolling. - let userMoved = false; - ['wheel', 'touchmove', 'keydown', 'mousedown'].forEach(type => window.addEventListener(type, () => { userMoved = true; }, { passive: true, once: true })); - const reanchor = () => { - if (activeSection && activeSection.id !== 'home') fitCode(activeSection); - fadeToc(); - if (!userMoved) { - const target = byId(currentId()); - if (target && activeSection && target !== activeSection && activeSection.contains(target)) target.scrollIntoView({ block: 'start' }); - } - requestAnimationFrame(updateSpy); - }; - if (document.fonts && document.fonts.ready) document.fonts.ready.then(reanchor); - window.addEventListener('load', reanchor); -})(); diff --git a/site/src/content.html b/site/src/content.html deleted file mode 100644 index 95d7f794..00000000 --- a/site/src/content.html +++ /dev/null @@ -1,1940 +0,0 @@ -<!-- - Deco Blocks docs: the Docs and Under-the-hood pages. - - build.py takes every top-level section element (with an id) of each page div below, in order, - and the syntax-highlighter script at the end: a Prism 1.30.0 bundle, MIT licensed, with its - license notice at the top of the script. Anything else in this file is ignored. Section ids are - link targets: the sidebar, the search index and the Roadmap (data/roadmap.json) link to them, - so rename one only together with its links. ---> -<div class="page" data-page="docs"> -<section id="architecture" data-nav="How it works" class="doc-section" aria-labelledby="architecture-title"> - <p class="eyebrow">Getting started</p><h2 id="architecture-title">How it works</h2> - <div class="callout"><p><strong>Status.</strong> Deco Blocks is being designed in the open: the API on these pages is proposed and not released yet. The <a href="#roadmap">Roadmap</a> lists what's left.</p></div> - <p>Deco Blocks lets the people who run a site change what your code does without changing the code. You write ordinary TypeScript functions. The values they're called with, such as a headline, a banner image or the share of visitors who see a new checkout, live as JSON files in your repository. Editors change those files in a visual editor, AI agents edit them directly, and your app reads them at runtime and calls your functions with them.</p> - <p>Deco Blocks has two pieces you install, and one you don't:</p> - <div class="table-wrap"><table> - <thead><tr><th>Piece</th><th>What it does</th><th>Where it runs</th></tr></thead> - <tbody> - <tr><td><strong>SDK</strong> <code>@decocms/blocks</code></td><td>Reads your content (a module the CLI generates from files in your repo, the hosted Deco CMS, or your own store) and calls your functions with it.</td><td>Your app, in any JavaScript runtime: Node, Cloudflare Workers, Deno, Bun, React Native or the browser</td></tr> - <tr><td><strong>CLI</strong> <code>@decocms/blocks-cli</code></td><td>Provides the <code>deco</code> command. <code>deco schema</code> generates a JSON Schema from your types (and, in CI, checks a change against the previous one), and <code>deco content</code> turns your content files into a module your app imports.</td><td>Your machine or CI</td></tr> - <tr><td><strong>Studio</strong></td><td>Builds a visual editor from that schema and saves edits back to your files.</td><td>Part of the hosted Deco CMS (optional)</td></tr> - </tbody> - </table></div> - <p>The SDK and CLI are open source and run without a Deco account. The <strong>hosted Deco CMS</strong> is Deco's optional service. It adds <strong>Deco Studio</strong>, the visual editor, and the <strong>Deco API</strong>, the HTTP service that delivers published content and draft previews to your app without a redeploy, using your site's token.</p> - - <h3>Key terms</h3> - <p>These terms come up on every page. Each is explained again where it's first used.</p> - <dl class="terms"> - <dt>Block function</dt><dd>One of your functions with a typed first parameter, registered in a block map. It can return anything: data, a boolean, a descriptor or JSX. The framework treats them all the same. See <a href="#quickstart">Quickstart</a>.</dd> - <dt>Block map</dt><dd>A plain object of your block functions, such as <code>{ experiments }</code>. See <a href="#quickstart">Quickstart</a>.</dd> - <dt>Block type</dt><dd>A key in the block map: the name content uses to call that function.</dd> - <dt>Block</dt><dd>Either a function (code) or saved content (JSON), named by <code>__resolveType</code>. A function is a key in your block map and ships with each deploy. Saved content is a saved entry: it's versioned, can have drafts, and publishes without a deploy. The JSON that calls a block, <code>{ "__resolveType": …, …inputs }</code>, is called a block too. See <a href="#model">Blocks</a>.</dd> - <dt>Saved entry</dt><dd>Saved content: a block saved under a name, one JSON file in <code>.deco/blocks</code>, whose file name is the entry name. See <a href="#content">Content and loaders</a>.</dd> - <dt>Content module</dt><dd><code>.deco/blocks.gen.ts</code>, the file <code>deco content</code> generates from <code>.deco/blocks</code>. It imports each JSON file and exports a snapshot, <code>{ revision, blocks }</code>: one fixed copy of all your saved entries. See <a href="#content">Content and loaders</a>.</dd> - <dt>Content map</dt><dd>All saved entries, keyed by name.</dd> - <dt>Page</dt><dd>A built-in content type: a saved entry with a path, SEO and an ordered list of blocks (its <code>sections</code> field) that editors add, remove and reorder. See <a href="#content-types">Routes and redirects</a>.</dd> - <dt>Loader</dt><dd>Any object whose <code>load()</code> returns <code>{ revision, blocks }</code>. Pass one as <code>createCMS</code>'s <code>content</code> to read content from a key-value store, a database or your API. See <a href="#content">Content and loaders</a>.</dd> - <dt>Revision</dt><dd>The ID of one exact content map, released or not, usually a hash of its contents. Change any entry and you get a new revision.</dd> - <dt>Release</dt><dd>A published content map. With the hosted Deco CMS, each commit to the production branch becomes one. A release never changes, and its revision identifies it. See <a href="#releases">Releases and deployment</a>.</dd> - <dt>Draft</dt><dd>Content changed in Studio but not yet published, kept on a branch. See <a href="#preview">Preview</a>.</dd> - <dt>Client</dt><dd>The object you read content with, one per request: <code>cms.forRelease()</code> for visitors, <code>cms.forDraft(pointer)</code> for a preview. It has two methods, <code>list</code> and <code>resolve</code>. See <a href="#quickstart">Quickstart</a>.</dd> - <dt>Descriptor</dt><dd>A plain, serializable object such as <code>{ component, props }</code> that names a view and its props for your front end to render. See <a href="#rendering">Rendering</a>.</dd> - </dl> - - <h3>Authoring: from types to an editor</h3> - <div class="flow" aria-label="Authoring flow"> - <div class="flow-node"><strong>Your TypeScript</strong><small>Functions with typed inputs: UI and data fetchers alike</small></div><div class="flow-arrow" aria-hidden="true">→</div> - <div class="flow-node"><strong>CLI → JSON Schema</strong><small><code>deco schema</code> writes <code>.deco/schema.json</code></small></div><div class="flow-arrow" aria-hidden="true">→</div> - <div class="flow-node"><strong>Studio</strong><small>Forms and previews for editors, built from the schema</small></div> - </div> - <p>You write functions with typed inputs, for example a hero block that takes a title and an image. The CLI turns those types into a schema, and Studio turns the schema into forms. Editors, and AI agents, save content as JSON files in <code>.deco/blocks</code>, in your repository.</p> - - <h3>Delivery: from files to your app</h3> - <div class="flow" aria-label="Delivery flow"> - <div class="flow-node"><strong>Content</strong><small>The content module built from <code>.deco/blocks</code>, or a release (a published version of your content) from the Deco API</small></div><div class="flow-arrow" aria-hidden="true">→</div> - <div class="flow-node"><strong>SDK</strong><small><code>client.list</code> and <code>client.resolve</code> read and resolve content</small></div><div class="flow-arrow" aria-hidden="true">→</div> - <div class="flow-node"><strong>Your app</strong><small>Next.js, TanStack Start, or any server</small></div> - </div> - <p>At request time, your app asks the SDK for a piece of saved content by name. The SDK reads it from the content you gave <code>createCMS</code>, the one setting that knows where content lives (usually the content module generated from <code>.deco/blocks</code>; see <a href="#content">Content and loaders</a>). If that content refers to other saved entries, the SDK expands them. Then it calls the matching function from your <a href="#quickstart">block map</a> with the saved inputs and returns the result. How you render it is up to your framework. When you need more from the block map, such as lazy loading, see <a href="#manifest">Block maps</a>.</p> - <p>If you use the hosted Deco CMS, you can publish content without redeploying. In production your app reads the current release from the Deco API instead of from the content module. A request never waits on that: content stays in memory and refreshes in the background (each server checks for a new release about once a minute, when it's idle), and the content your build shipped with is always there as the fallback. See <a href="#releases">Releases and deployment</a>, or <a href="#loader-internals">how the loader stays current</a> for the details.</p> - - <h3>Small on purpose</h3> - <p>Deco Blocks stays out of your way, and every piece is replaceable:</p> - <ul> - <li><strong>Content is plain JSON.</strong> Open it in any editor, grep it, diff it, or generate it with a script. There's no database or content API for you to host. The hosted Deco CMS is optional, and Deco runs it.</li> - <li><strong>Block functions are plain functions.</strong> No framework plugin, no base classes, no magic directories. The block map is a plain object, and you can call any block function directly in a test.</li> - <li><strong>Loaders are one method.</strong> Reading from a key-value store such as Cloudflare Workers KV, or from your own database, is one <code>load()</code> method that returns <code>{ revision, blocks }</code>: an ID for this version of the content, and the saved entries by name. Files in your repository need none, because the CLI turns them into a module. Swap the source and nothing else changes (see <a href="#content">Content and loaders</a>).</li> - <li><strong>The schema is a file.</strong> Commit it, inspect it, or generate it another way. Studio builds its forms from that JSON alone.</li> - <li><strong>Nothing is hidden.</strong> If the SDK doesn't do what you need, read the content yourself: the block format is documented in <a href="#model">Blocks</a>, and the files and the content map in <a href="#content">Content and loaders</a>.</li> - </ul> -</section> - -<section id="quickstart" data-nav="Quickstart" class="doc-section"> - <p class="eyebrow">Getting started</p><h2>Quickstart</h2> - <div class="callout"><p><strong>Status.</strong> Deco Blocks is being designed in the open: the API on these pages is proposed and not released yet. The <a href="#roadmap">Roadmap</a> lists what's left.</p></div> - <p>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:</p> -<figure class="code-example"><figcaption>experiments.ts</figcaption><pre><code class="language-typescript">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), - }; -}</code></pre></figure> -<figure class="code-example"><figcaption>checkout.ts</figcaption><pre><code class="language-typescript">import experiments from "./experiments"; - -experiments({ newCheckout: 10, stickyHeader: 50, freeShippingBanner: 0 }); -// e.g. { newCheckout: false, stickyHeader: true, freeShippingBanner: false }</code></pre></figure> - <p>The split is hardcoded at the call site. Ramping the new checkout from 10% to 25% means a pull request and a deploy. In this guide you'll move those numbers into content: a JSON file that you, an AI agent, or an editor in Deco Studio can change, while the function stays exactly as it is. It runs in plain Node, with no framework.</p> - <p>This function doesn't render anything; it returns flags. A block function can just as well return JSX, and <a href="#model">Blocks</a> shows how.</p> - <p><strong>Prerequisites:</strong></p> - <ul> - <li>Node.js 24 or later, and <code>tsx</code> to run TypeScript.</li> - <li>An ESM project (<code>"type": "module"</code> in <code>package.json</code>), because the examples use top-level <code>await</code>.</li> - <li>Optional: a site on the hosted Deco CMS connected to this repository, and its token, if you want to try Studio or publish without a deploy. Creating a site and issuing a token for this API isn't available yet (see the <a href="#roadmap-platform--build-the-deco-api-release-service">Roadmap</a>). Everything on this page works without it.</li> - </ul> -<pre class="code-block"><code class="language-bash">npm install @decocms/blocks -npm install -D @decocms/blocks-cli</code></pre> - <p>The second package provides the <code>deco</code> command.</p> - - <h3>1. Make the function configurable</h3> - <p>Any function with a typed first parameter can become a block function. Register it in a block map, a plain object of functions. Each key is a block type, the name content uses to call that function; here it's <code>experiments</code>. The CLI reads this file, and your app hands it to <code>createCMS</code>.</p> -<figure class="code-example"><figcaption>blocks.ts</figcaption><pre><code class="language-typescript">import type { Blocks } from "@decocms/blocks"; -import experiments from "./experiments"; - -export default { experiments } satisfies Blocks;</code></pre></figure> - <p><code>satisfies Blocks</code> 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.</p> - - - <h3>2. Give editors a form</h3> - <p>The CLI reads the map and turns the <code>Experiments</code> type into a JSON Schema. The JSDoc tags shape the form: <code>@title</code> becomes the field's label, and <code>@minimum</code>/<code>@maximum</code> its allowed range. <a href="#schema">Schema generation</a> lists every tag.</p> -<pre class="code-block"><code class="language-bash">npx deco schema</code></pre> - <p>It finds the block map in <code>blocks.ts</code> or <code>blocks.tsx</code>, in the project root or in <code>src/</code> (<code>--entry</code> points it at any other file; see <a href="#schema">Schema generation</a>), and writes <code>.deco/schema.json</code>. Commit the file. If the repository is connected to the hosted Deco CMS, then once that code is deployed, Studio shows each experiment as a number field clamped to 0–100. Without it, the schema still describes the content you or an agent write by hand.</p> - - <h3>3. Add content</h3> - <p>There are two ways to create content:</p> - <ul> - <li><strong>By hand.</strong> Write the JSON file yourself, or ask an AI agent to "ramp the new checkout to 25%" and let it edit the file.</li> - <li><strong>In Studio (hosted Deco CMS).</strong> Pick the <code>experiments</code> block type, fill in the form, and save. Studio commits the same file.</li> - </ul> - <p>Both routes produce the same file in <code>.deco/blocks</code>, which is where the CLI looks for content (its <code>--root</code> flag points it at another <code>.deco</code> folder):</p> -<figure class="code-example"><figcaption>.deco/blocks/Experiments.json</figcaption><pre><code class="language-json">{ - "__resolveType": "experiments", - "newCheckout": 10, - "stickyHeader": 50, - "freeShippingBanner": 0 -}</code></pre></figure> -<pre class="code-block"><code class="language-typescript">// Means -experiments({ newCheckout: 10, stickyHeader: 50, freeShippingBanner: 0 })</code></pre> - <p>The file is a saved entry: one named piece of content that code, or other content, can refer to. Its file name without <code>.json</code> is its name (see <a href="#content">Content and loaders</a>). <code>__resolveType</code> is a reserved key (the underscores keep it from clashing with your fields) that names the function to call by its block type. The other keys are that function's inputs.</p> - <p>Two names are in play, on purpose: <code>experiments</code> is the block type and names a function, and <code>Experiments</code> is the entry name and names this one piece of saved content. You could save several entries that call <code>experiments</code>, such as <code>HolidayExperiments</code>. Names are case-sensitive, so they don't clash.</p> - - <h3>4. Generate the content module</h3> - <p>The SDK never reads files itself, so it runs the same way in any JavaScript runtime. Instead, the CLI turns your content files into a module your app imports, the <strong>content module</strong>:</p> -<pre class="code-block"><code class="language-bash">npx deco content</code></pre> - <p>It writes <code>.deco/blocks.gen.ts</code>, which imports each JSON file in <code>.deco/blocks</code> separately and exports a <strong>snapshot</strong>, <code>{ revision, blocks }</code>: one fixed copy of all your saved entries, plus its revision, an ID the CLI computes from their contents. The file is generated, so add it to <code>.gitignore</code>, as you would TanStack Router's <code>routeTree.gen.ts</code>. For this guide, running <code>npx deco content</code> once is enough. Rerun it when you add or remove a file in <code>.deco/blocks</code>; editing an existing file needs no rerun. In an app with dev and build scripts, add the two commands to <code>package.json</code> so they run first (see <a href="#content--the-content-module">The content module</a>):</p> -<pre class="code-block"><code class="language-json">"predev": "deco schema && deco content", -"prebuild": "deco schema && deco content"</code></pre> - - <h3>5. Read the content from your code</h3> - <p>Create the CMS once, at module scope, with your block map and the content module (to connect the hosted Deco CMS or another store, see <a href="#content--configuring-content">Configuring content</a>).</p> -<figure class="code-example"><figcaption>cms.ts</figcaption><pre><code class="language-typescript">import { createCMS } from "@decocms/blocks"; -import content from "./.deco/blocks.gen"; -import blocks from "./blocks"; - -export const cms = createCMS({ blocks, content });</code></pre></figure> - <p>Then ask it for a client, the object you read content with. <code>forRelease()</code> returns a client over the published content, the release; with plain files, the release is simply what's in the content module. Its sibling <code>forDraft()</code> reads unpublished drafts for previews (see <a href="#preview">Preview</a>). Make one client per request: a client reads one consistent version of the content for its whole life.</p> - <p>Fetch the entry by name. The client runs <code>experiments</code> on the saved inputs, so you get the same value as calling <code>experiments()</code> yourself. An entry name is just 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:</p> -<figure class="code-example"><figcaption>checkout.ts</figcaption><pre><code class="language-typescript">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)</code></pre></figure> - <p>Every call returns a <code>[value, error]</code> tuple, so a missing entry or a failing function never throws. The error names what went wrong, for example <code>NOT_FOUND</code>. <a href="#troubleshooting">Troubleshooting</a> lists every code.</p> - <p>Run it with <code>npx tsx checkout.ts</code>. Now open <code>.deco/blocks/Experiments.json</code>, change <code>newCheckout</code> to <code>100</code>, and run it again (no need to rerun <code>deco content</code>: only adding or removing a file needs that): <code>newCheckout</code> is <code>true</code> every time. You changed what the code does without touching the code.</p> - <p>That's it. The function didn't change, but its inputs now live in a file that editors change through a form and agents change directly, and every change is a commit. 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 <a href="#manifest">Block maps</a>).</p> - - <h3>Next steps</h3> - <ul> - <li><a href="#model">Blocks</a>: how blocks nest and refer to each other, which is how a page is built from a list of blocks.</li> - <li><a href="#content">Content and loaders</a>: where saved entries come from, and how to read them from somewhere other than <code>.deco/blocks</code>.</li> - <li><a href="#schema">Schema generation</a>: how your types become editor forms, and the JSDoc tags that shape them.</li> - <li><a href="#preview">Preview</a>: render drafts in your real pages, no deploy.</li> - <li><a href="#manifest">Block maps</a>, when you need more: lazy loading and naming types.</li> - <li><a href="#variants">Matchers and variants</a>: let content pick different values per visitor, date or experiment.</li> - <li><a href="#content-types">Routes and redirects</a>: the content types that give pages and redirects their URLs.</li> - <li><a href="#rendering">Rendering</a>: the two ways a block becomes UI.</li> - <li><a href="#routing">Routing</a>: match URLs to pages and redirects.</li> - <li>Framework guides for <a href="#nextjs">Next.js</a> and <a href="#tanstack-data">TanStack Start</a>.</li> - </ul> -</section> - -<section id="model" data-nav="Blocks" class="doc-section" aria-labelledby="model-title"> - <p class="eyebrow">Core concepts</p><h2 id="model-title">Blocks</h2> - <p>Most of what changes on a website isn't the code. It's the values the code is given: a headline, a banner image, the share of visitors who see the new checkout. Deco is built on that idea: developers write the functions, and the people who run the site decide what goes into them.</p> - <p>Blocks are how they make those decisions. A block is a small piece of JSON that names one of your functions and lists the inputs to call it with. Editors change the inputs in Studio, and agents edit the file directly. When your app resolves the block, Deco calls the function with whatever is saved and gives you back the result. This page shows what a block looks like, how blocks fit inside each other, and exactly how they turn into values.</p> - - <h3>Start from code you already write</h3> - <p>Here's the kind of code you write every day:</p> -<pre class="code-block"><code class="language-typescript">productCard({ - title: "Summer collection", - product: catalogProduct({ slug: "summer-shirt" }), -})</code></pre> - <p>It's function composition. The inner call runs first, and its result becomes an input to the outer one, so <code>productCard</code> receives product data, never a pending call. The functions are the developer's job. The composition is a decision: which product, which title, which function fetches the data. Written in code, changing that decision means editing code and deploying it.</p> - - <h3>A block is that composition, stored as data</h3> - <p>Write the same composition as JSON and nobody has to edit code to change it. <code>__resolveType</code> names the function, the other properties are its inputs, and a block inside an input is a call inside a call. Here <code>productCard</code> and <code>catalogProduct</code> are the functions your block map registers as <code>"product-card"</code> and <code>"catalog-product"</code>:</p> -<pre class="code-block"><code class="language-jsonc">// Stored as JSON -{ - "__resolveType": "product-card", - "title": "Summer collection", - "product": { "__resolveType": "catalog-product", "slug": "summer-shirt" } -} - -// Means -productCard({ - title: "Summer collection", - product: catalogProduct({ slug: "summer-shirt" }), -})</code></pre> - <p>Now an editor can change the title or the slug in Studio, and an agent can even point <code>product</code> at a different function, and the composition changes without a deploy. The functions are whatever your <a href="#manifest">block map</a> registers, and each one's input type is what <code>deco schema</code> turns into the editor form. Use the same model for anything your site needs: UI, product data, SEO, navigation, campaign settings. The CMS doesn't reserve any directories or categories.</p> - - <h3>Name a composition, reuse it</h3> - <p>In code, you'd give a composition you use often a name and reuse it. Saved entries do that. Alongside the block map of functions, the CMS reads a second map, the content map, where each name holds a piece of saved JSON. <code>{ "__resolveType": "SummerSEO" }</code> means whatever is saved under the name <code>SummerSEO</code>, written in place. The map can come from files in your repository, the hosted Deco CMS, or your own store; see <a href="#content">Content and loaders</a>.</p> - <p>Unlike a <code>const</code> that holds a result, what's named here is the call itself, inputs and all. That's why you can add inputs to a reference to override the saved entry's inputs for this one use:</p> -<pre class="code-block"><code class="language-jsonc">// 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" } - -// Means -seo({ title: "Summer sale", description: "Light layers for long days." })</code></pre> - <ul> - <li><strong>Overrides are shallow.</strong> The merge is <code>{ ...entry, ...inputs }</code>, so an override replaces the whole top-level property, and the saved entry itself doesn't change. It isn't currying or partial application: the reference is expanded into one complete call.</li> - <li><strong>An entry doesn't have to be a block.</strong> It can be any JSON that contains blocks, like a page with a list of blocks: an object literal with calls inside. Resolving it calls each block inside and leaves the rest of the JSON as it is.</li> - </ul> - - <h3>Resolving a page, step by step</h3> - <p>Resolving a block is evaluating its composition: named entries expand, then calls run from the inside out. Here's a page saved as <code>SummerSale</code>. It uses the <code>SummerSEO</code> entry with an override, and one <code>carousel</code> block in its <code>sections</code> list. A page is mostly an ordered list of blocks that editors add, remove and reorder. The block map has two functions. <code>seo</code> returns its inputs; that's still worth registering, because it gives editors a form for SEO and lets them save it as a reusable entry. <code>carousel</code> returns a <strong>descriptor</strong>, <code>{ component, props }</code>: a plain object that says which component to render with which props, for your app to render later. A block function can also return JSX directly; <a href="#rendering">Rendering</a> compares the two.</p> -<pre class="code-block"><code class="language-jsonc">// Saved as SummerSale -{ - "seo": { "__resolveType": "SummerSEO", "title": "Summer sale" }, - "sections": [{ "__resolveType": "carousel", "banners": [] }] -}</code></pre> - <p>This page has no <code>__resolveType</code>, so the page object itself isn't a call: resolving it resolves the blocks inside and keeps the rest of the JSON as it is. (Real pages usually carry <code>"__resolveType": "page"</code> so they can be listed by type. That name has no function behind it, so for those you resolve the fields instead; see <a href="#model--the-rule-in-detail">below</a>.)</p> - <p>Call <code>client.resolve("SummerSale")</code> and follow the page as it changes. <code>resolve</code> takes either an entry name (a string; a string is always an entry name) or any JSON value, such as one field of a page.</p> - <p><strong>Step 1. The reference expands.</strong> <code>SummerSEO</code> is a saved entry, so it's data. The saved entry is merged with the reference's own inputs, and the reference's inputs win. The <code>__resolveType</code> from the entry, <code>seo</code>, names a function.</p> -<pre class="code-block"><code class="language-jsonc">{ - "seo": { "__resolveType": "seo", "title": "Summer sale", "description": "Light layers for long days." }, - "sections": [{ "__resolveType": "carousel", "banners": [] }] -}</code></pre> - <p><strong>Step 2. The functions are called.</strong> What's left is a composition of plain calls. Both blocks have plain inputs, so both functions run. They're siblings, so neither waits for the other.</p> -<pre class="code-block"><code class="language-typescript">{ - seo: seo({ title: "Summer sale", description: "Light layers for long days." }), - sections: [carousel({ banners: [] })] -}</code></pre> - <p><strong>Step 3. The result.</strong> Each return value takes its block's place. The page itself was never a block, so its shape stays the same.</p> -<pre class="code-block"><code class="language-typescript">{ - seo: { title: "Summer sale", description: "Light layers for long days." }, - sections: [{ component: "Carousel", props: { banners: [] } }] -}</code></pre> - <p>To step through a deeper example, see <a href="#walkthrough">How resolution works</a>.</p> - <div class="callout"><p><strong>Reading without running.</strong> Pass <code>{ run: false }</code> to read content without running any function. <code>client.resolve("SummerSale", { run: false })</code> stops after step 1: saved entries like <code>SummerSEO</code> still expand, and blocks that name a function, like <code>seo</code> and <code>carousel</code>, come back as JSON, untouched. Nothing runs. That's useful for previews, editor tooling, and debugging.</p></div> - - <h3>The rule</h3> - <p>Every <code>__resolveType</code> names a block, and a block is either a function (code) or saved content (JSON): a function in your block map to call, or a saved entry in your content to expand. Both share one namespace, one registry. They differ in how they change: functions ship with a deploy, while saved content is versioned (each version has a revision), can have drafts, and can be published without a deploy.</p> -<pre class="code-block"><code class="language-typescript">const registry = { ...savedEntries, ...blocks }; // name → saved JSON | function</code></pre> - <p>Saved entries are the content map you pass as <code>createCMS</code>'s <a href="#content"><code>content</code></a>. Functions come from the block map you give <code>createCMS</code>. The CMS applies one rule to every object with a <code>__resolveType</code>:</p> - <ol> - <li><strong>Look up the name.</strong></li> - <li><strong>Data:</strong> replace the object with <code>{ ...entry, ...inputs }</code>, so your inputs override the entry's, and apply the rule to the result again.</li> - <li><strong>Function:</strong> resolve the inputs first, children before parents and siblings concurrently, then call the function. Its return value takes the block's place.</li> - <li><strong>Neither:</strong> fail with <code>UNKNOWN_BLOCK</code>.</li> - </ol> - - <h3>The rule in detail</h3> - <ul> - <li><strong>Failures stop the parent.</strong> If a nested block fails, its parent never runs and <code>resolve</code> returns the error, much as an outer call never runs when an inner one throws. A reference to an entry that doesn't exist fails with <code>NOT_FOUND</code>.</li> - <li><strong>Results are returned as is.</strong> The CMS never walks a function's return value. Return plain data, a descriptor, or JSX. This is also why a function that returns its input, like <code>seo</code> above, can't loop: <code>__resolveType</code> is stripped before the call, and whatever comes back is never looked up again.</li> - <li><strong>Block functions should only read.</strong> They run whenever content is resolved: for a visitor, in a Studio preview, for an agent. Put side effects in your own route handlers (see <a href="#operations">Caching and observability</a>).</li> - <li><strong>Only data can loop.</strong> 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 <code>CYCLE</code> the moment one repeats, with the chain in <code>error.path</code>.</li> - </ul> - <div class="callout"><p><strong>Pages that are only data.</strong> Some saved entries describe a shape but have no function behind them, such as a page (<code>page</code> is a built-in content type, saved with <code>"__resolveType": "page"</code>). Nothing can be called for them, so resolving the entry fails with <code>UNKNOWN_BLOCK</code>. Read the entry instead, then resolve the blocks inside it, for example <code>client.resolve(page.seo)</code>. (<code>SummerSale</code> above had no <code>__resolveType</code> at all, which is why it could be resolved whole.) <a href="#schema--content-that-is-only-data">Schema generation</a> shows how to declare such types, and <a href="#content-types">Routes and redirects</a> how to read them.</p></div> - <div class="callout warning"><p><strong>Entry names and block types share one registry.</strong> If a name is both, the function wins, so a saved entry called <code>seo</code> and a <code>seo</code> function can't coexist. <code>deco schema</code> fails when an entry in <code>.deco/blocks</code> has the name of a block type, so rename the entry. By convention, functions are lowercase or kebab-case and saved content is Capitalized.</p></div> -</section> - -<section id="content" data-nav="Content & loaders" class="doc-section" aria-labelledby="content-title"> - <p class="eyebrow">Core concepts</p><h2 id="content-title">Content and loaders</h2> - <p>Resolving takes two maps. The <a href="#manifest">block map</a> holds your functions, and it ships with your code. The content map holds your saved entries: each name points at a piece of JSON. You hand <code>createCMS</code> your content through its <code>content</code> option, the only setting that knows where content lives: either the content module the CLI generates from your files, or a <strong>loader</strong>, any object with a <code>load()</code> method that returns the content map. Your functions, the resolver, and your pages never do.</p> - <div class="callout"><p><strong>Other meanings of "loader".</strong> In these docs, a loader on its own always means a content loader: the object that supplies the content map. Two other things share the name. In Studio, and in sites built on Deco's earlier framework, "loader" also means a block function that fetches data, like <code>catalogProduct</code> in <a href="#model">Blocks</a>; those are ordinary functions in your block map. And frameworks such as TanStack Router and Remix call the function that fetches a route's data a route loader. Nothing on this page applies to either.</p></div> - <p>Content always arrives as the whole map at once, with a <strong>revision</strong> that identifies it. This pair, <code>{ revision, blocks }</code>, is called a <strong>snapshot</strong>: one fixed copy of all your saved entries.</p> -<pre class="code-block"><code class="language-json">{ - "revision": "b1946ac92492d2347c6235b4d2611184", - "blocks": { - "SummerSEO": { "__resolveType": "seo", "title": "Sunny!", "description": "Light layers for long days." }, - "SummerSale": { - "seo": { "__resolveType": "SummerSEO", "title": "Summer sale" }, - "sections": [{ "__resolveType": "carousel", "banners": [] }] - } - } -}</code></pre> - <p>Each key under <code>blocks</code> is an entry name. It's the name a <code>__resolveType</code> refers to, and the name you pass to <code>client.resolve("SummerSEO")</code>. The field is called <code>blocks</code> for compatibility with Studio's file format, but it holds saved entries: any JSON, never functions. Functions only ever come from the block map you give <code>createCMS</code>.</p> - <p>The revision names this exact set of entries. The CMS and your app use it like a version number: to tell when content changed, to keep cached data from different versions apart, and to tell one release from the next. The one hard rule: any change to any entry must produce a new revision, and a revision must never be reused for different content. <code>deco content</code>, <code>remoteLoader</code> and the Deco API all use the same content hash of the whole map, so two copies of the same content get the same revision wherever they're computed. A loader you write can use any string that follows the rule; <a href="#releases">Releases and deployment</a> shows what matching the hash buys you.</p> - <p>To read an entry exactly as stored, with references in place, index the map: <code>content.blocks["SummerSEO"]</code>, with <code>content</code> imported from <code>./.deco/blocks.gen</code> (the content module, below).</p> - <p>The map itself isn't a call: it's what the names in your content refer to. What each entry means is in <a href="#model--name-a-composition-reuse-it">Blocks</a>.</p> - - <h3>Where content comes from</h3> - <p>When you edit with Studio or an agent, content is authored as files: they write to <code>.deco/blocks</code> in your repository, and every change is a commit. The <code>content</code> option only decides where your app reads that content from at runtime. A <strong>release</strong> is one published version of the whole content map. A <strong>draft</strong> is content an editor has changed in Studio but not published yet (see <a href="#preview">Preview</a>). With the hosted Deco CMS, each commit to the production branch becomes a release that reaches your app without a deploy, and the content the build shipped with is the fallback (see <a href="#releases">Releases and deployment</a>). If you load from your own store, how content gets there is up to you.</p> - <div class="callout"><p><strong>There is no publish step.</strong> Content is published by changing it: commit a file in <code>.deco/blocks</code> and push, or save in Studio, which commits for you. With the hosted Deco CMS, that commit becomes the current release without a deploy; without it, the content goes live with your next deploy. See <a href="#releases--publishing">Publishing</a>. (Running <code>deco publish</code> only prints this explanation, so an agent that tries it learns to commit instead.)</p></div> - <p>The content map can come from anywhere that can return JSON. These are the usual sources:</p> - <div class="table-wrap"><table> - <thead><tr><th>Content in</th><th><code>createCMS</code> options</th><th>Use it for</th></tr></thead> - <tbody> - <tr><td>Files in your repository</td><td><code>content</code>: the content module, <code>.deco/blocks.gen.ts</code>, which <code>deco content</code> generates from one JSON file per entry in <code>.deco/blocks</code>; the file name without <code>.json</code> is the entry name (<code>.deco/blocks/SummerSEO.json</code> → <code>"SummerSEO"</code>)</td><td>Development, tests, every runtime, and the fallback under <code>token</code> in production</td></tr> - <tr><td>The hosted Deco CMS</td><td>Add <code>token</code>, which wraps your content as <code>remoteLoader(content, { token })</code></td><td>Production: reads the current release from the Deco API without a deploy, and falls back to your content module when it can't. Also serves drafts for preview</td></tr> - <tr><td>KV, a database, or your own API</td><td><code>content</code>: a <code>Loader</code> you write</td><td>Anything else</td></tr> - </tbody> - </table></div> - - <h3>The content module</h3> - <p>The SDK never touches the filesystem, so the same setup runs on Node, Cloudflare Workers, Deno, Bun, React Native and in browsers. Files reach your app through the <strong>content module</strong> instead: <code>.deco/blocks.gen.ts</code>, which the CLI command <code>deco content</code> generates from <code>.deco/blocks</code>. It imports each JSON file separately, using JSON import attributes (the <code>with { type: "json" }</code> clause), and its default export is the snapshot <code>{ revision, blocks }</code>, with the revision computed by the CLI:</p> -<figure class="code-example"><figcaption>.deco/blocks.gen.ts (excerpt)</figcaption><pre><code class="language-typescript">// Generated by deco content; don't edit. -import Experiments from "./blocks/Experiments.json" with { type: "json" }; -import SummerSEO from "./blocks/SummerSEO.json" with { type: "json" }; -// … one import per file - -export default { - revision: "b1946ac92492d2347c6235b4d2611184", - blocks: { Experiments, SummerSEO /* , … */ }, -};</code></pre></figure> - <p>The file is generated, so add it to <code>.gitignore</code>, like TanStack Router's <code>routeTree.gen.ts</code>, and run <code>deco content</code> before your dev server and your build, from <code>package.json</code>:</p> -<figure class="code-example"><figcaption>package.json</figcaption><pre><code class="language-json">{ - "scripts": { - "predev": "deco schema && deco content", - "prebuild": "deco schema && deco content" - } -}</code></pre></figure> - <p>Both commands are described in <a href="#schema--the-two-cli-commands">The two CLI commands</a>. Because each file is an ordinary import, editing one reloads through your framework's own hot module replacement (HMR, which swaps changed modules into the running app without a restart), and the new content is handed to the running CMS. Only adding or removing a file needs <code>deco content</code> again, or <code>deco content --watch</code> left running beside your dev server.</p> - <p>To keep content in another folder, point the CLI at it and import from there: <code>deco content --root ./content/.deco</code>, then <code>import content from "./content/.deco/blocks.gen"</code>.</p> - - <h3>Configuring content</h3> - <p><code>createCMS</code> takes your content in its <code>content</code> option: usually the content module, or a <code>Loader</code> for your own store. Add <code>token</code> to connect the hosted Deco CMS; it's shorthand for <code>remoteLoader(content, { token, interval })</code>, with your content as the fallback.</p> -<figure class="code-example"><figcaption>cms.ts</figcaption><pre><code class="language-typescript">import content from "./.deco/blocks.gen"; - -// Your files, through the content module -createCMS({ blocks, content }); - -// Releases from the hosted Deco CMS, with the content module as the fallback -createCMS({ blocks, content, token: process.env.DECO_SITE_TOKEN }); - -// Check for new releases every 2 minutes instead of every minute -createCMS({ blocks, content, token: process.env.DECO_SITE_TOKEN, interval: 120_000 }); - -// Content from your own store (see Write your own loader) -createCMS({ blocks, content: kvContent });</code></pre></figure> - <p><code>token</code> is your site's API key from the hosted Deco CMS, used to call the Deco API. (Issuing tokens for this API isn't available yet; see the <a href="#roadmap-platform--build-the-deco-api-release-service">Roadmap</a>.) Keep it in an environment variable, never in the repository. When it's undefined, in development for example, the CMS reads <code>content</code> only, so the same line works everywhere.</p> - <p><code>interval</code> is how often each server checks for a new release, in milliseconds. It defaults to the <code>DECO_CONTENT_INTERVAL</code> environment variable, or 60 000 (one minute) when that's unset. The minimum is 60 000: 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. How and when checks run is in <a href="#loader-internals">Loader internals</a>.</p> - <p>The content module you pass with <code>token</code> stays in memory as the fallback, alongside the live release. On Cloudflare Workers with a large content set, see “Large content on Workers” under <a href="#tanstack-data--2-create-the-cms">Create the CMS</a>.</p> - - <h3>Write your own loader</h3> - <p>The contract is one required method, <code>load(pointer?)</code>, which returns <code>{ revision, blocks }</code>. Here is one backed by Cloudflare Workers KV, Workers' key-value store, with the revision and the entries stored together under one key:</p> -<figure class="code-example"><figcaption>cms.ts</figcaption><pre><code class="language-typescript">import { createCMS, type Loader, type Snapshot } from "@decocms/blocks"; -import { env } from "cloudflare:workers"; -import blocks from "./blocks"; - -const kvContent: Loader = { - async load() { - // { revision, blocks }, stored together under one key - const snapshot = await env.CONTENT.get<Snapshot>("content", "json"); - if (!snapshot) throw new Error("no content in KV"); - return snapshot; - }, -}; - -export const cms = createCMS({ blocks, content: kvContent });</code></pre></figure> - <p>On Workers, importing <code>env</code> from <code>cloudflare:workers</code> gives you your bindings (here the KV namespace <code>CONTENT</code>) at module scope, so the CMS can still be created once, outside any request. <code>Snapshot</code> is the <code>{ revision, blocks }</code> type. If <code>load()</code> throws, the read fails with <code>LOADER_FAILED</code> (see <a href="#standalone--errors">Errors</a>). A first-party <code>kvLoader(env.CONTENT, { key })</code> helper is planned (see the <a href="#roadmap-api--document-and-support-kv-only-content-for-large-workers-sites">Roadmap</a>).</p> - <p>Pass it as <code>content</code>; add <code>token</code> to use it as the fallback under hosted releases. Store the revision with the content, and change it whenever any entry changes (see <a href="#content">the rule above</a>).</p> - <p>Two optional extras. If your store has drafts, use the <code>pointer</code> argument, the <a href="#preview">draft pointer</a> that names which draft to read. If content can change while the process runs, add <code>update()</code>, which checks for newer content; the CMS calls it on the same idle schedule and <code>interval</code> as <code>remoteLoader</code> (see <a href="#loader-internals">Loader internals</a>). Either way, the client only ever sees <code>{ revision, blocks }</code>.</p> -</section> - - -<section id="schema" data-nav="Schema generation" class="doc-section" aria-labelledby="schema-title"> - <p class="eyebrow">Core concepts</p><h2 id="schema-title">Schema generation</h2> - <p>Studio builds its editor from a JSON Schema, and the CLI writes that schema from your TypeScript. It reads the file that exports your block map, the <code>blocks.ts</code> from the <a href="#quickstart">quickstart</a>, and writes <code>.deco/schema.json</code>. It looks for that file as <code>blocks.ts</code> or <code>blocks.tsx</code>, in the project root or in <code>src/</code>. If your block map lives somewhere else, pass its path with <code>--entry</code>, for example <code>deco schema --entry app/cms/blocks.ts</code>:</p> -<pre class="code-block"><code class="language-bash">npx deco schema</code></pre> - <p>For each block type in the map, the CLI reads the type of the function's first parameter and turns it into a form definition. Commit <code>.deco/schema.json</code> with your code, and run the command in CI so the schema never drifts from your types (see <a href="#schema--keep-the-schema-compatible">Keep the schema compatible</a>).</p> - <p>The CLI also checks your content model: it fails if a saved entry in <code>.deco/blocks</code> has the name of a block type, or if an alias (a second name for a block type, used when you rename one; see <a href="#adoption">Renaming and migrating</a>) collides with either.</p> - - <h3>The two CLI commands</h3> - <p>The CLI has two commands, and each does one job:</p> - <ul> - <li><strong><code>deco schema</code></strong> turns the types in your block map into <code>.deco/schema.json</code>. Commit this file.</li> - <li><strong><code>deco content</code></strong> turns the files in <code>.deco/blocks</code> into the <a href="#content--the-content-module">content module</a>, <code>.deco/blocks.gen.ts</code>. It's generated, so gitignore it.</li> - </ul> -<pre class="code-block"><code class="language-bash">deco schema [--entry <path>] [--root <dir>] [--watch] [--check <base>] -deco content [--root <dir>] [--watch]</code></pre> - <ul> - <li><code>--entry <path></code> (<code>deco schema</code> only): the file that exports your block map. By default the CLI looks for <code>blocks.ts</code> or <code>blocks.tsx</code> in the project root or in <code>src/</code>.</li> - <li><code>--root <dir></code>: the <code>.deco</code> folder to use (by default <code>./.deco</code>).</li> - <li><code>--watch</code>: regenerate as files change.</li> - <li><code>--check <base></code> (<code>deco schema</code> only): compare the schema your code generates now with an earlier one, and fail on breaking changes. It writes nothing, and it isn't built yet (see the <a href="#roadmap-cli--add-deco-schema-check-a-compatibility-check">Roadmap</a>). See <a href="#schema--keep-the-schema-compatible">Keep the schema compatible</a>.</li> - </ul> - <p>There's no separate dev command: run both before your dev server and your build. Why the content module is gitignored, and what reloads without a rerun, is in <a href="#content--the-content-module">The content module</a>.</p> -<figure class="code-example"><figcaption>package.json</figcaption><pre><code class="language-json">{ - "scripts": { - "predev": "deco schema && deco content", - "prebuild": "deco schema && deco content" - } -}</code></pre></figure> - - <h3>Keep the schema compatible</h3> - <p>With the hosted Deco CMS, content goes live without a deploy, so the code that's already running renders it. That's the point of publishing without a deploy. Nothing at runtime checks that the two fit: the Deco API serves each commit as it lands, and <code>remoteLoader</code> doesn't compare schemas. They're kept compatible before anything merges, in two directions. (<code>deco schema --check</code>, which the first one relies on, is planned; see the <a href="#roadmap-cli--add-deco-schema-check-a-compatibility-check">Roadmap</a>.)</p> - <ul> - <li><strong>Backward compatible: new code still renders all existing content.</strong> A code change must not break content that's already saved. <code>deco schema --check <base></code> enforces this. It generates the schema from your code and compares it with <code><base></code>, the path to an earlier <code>.deco/schema.json</code>, such as the one on <code>main</code>. It lists every breaking change and exits with a non-zero code if it finds one. It writes nothing.</li> - <li><strong>Forward: already-deployed code renders new content.</strong> This holds as long as content only uses block types and fields that the deployed code has. Studio builds its forms from whichever schema you point it at, usually the one on your main branch (see <a href="#studio--what-studio-reads">What Studio reads</a>). Keep that schema in step with what's deployed, and deploy the code first, then publish content that uses it. An entry written by hand or by an agent that uses a type the deployed code lacks fails that block with <code>UNKNOWN_BLOCK</code> (see <a href="#standalone--errors">Errors</a>) until the code ships. Rolling code back is the exception: content that already uses the newer types or fields fails on the older code until you redeploy or revert that content, so check what's published before you roll back.</li> - </ul> - <p>What <code>--check</code> treats as breaking:</p> - <div class="table-wrap"><table> - <thead><tr><th>Change</th><th>Breaking?</th></tr></thead> - <tbody> - <tr><td>Removing a block type (a function in the block map) or a <code>Content</code> type</td><td>Yes. <code>--check</code> compares schemas only, so any removal counts, whether or not saved content uses the type. Remove a type in a later change, once no content uses it. To rename a block type, keep the old name as an alias (see <a href="#adoption--rename-a-type-with-an-alias">Rename a type with an alias</a>); the old name stays in the schema, so the check passes. Don't rename a <code>Content</code> type this way yet: the check would pass, but Content aliases don't work at runtime (see the <a href="#roadmap-cli--emit-a-runtime-alias-table">Roadmap</a>).</td></tr> - <tr><td>Removing or renaming a field</td><td>Yes. Fields have no aliases, so rename in steps: add the new name as an optional field, move the content over, then remove the old field once no content uses it.</td></tr> - <tr><td>Adding a required field, or making an optional field required</td><td>Yes: existing entries don't have it</td></tr> - <tr><td>Narrowing a field's type: a <code>string</code> becoming a union of allowed values, a union losing a member, a tighter limit (a lower <code>@maximum</code> or <code>@maxLength</code>, or a higher <code>@minimum</code> or <code>@minLength</code>)</td><td>Yes: saved values may fall outside it</td></tr> - <tr><td>Adding a block type, a <code>Content</code> type or a built-in type</td><td>No</td></tr> - <tr><td>Adding an optional field</td><td>No</td></tr> - <tr><td>Widening a field's type: a union gaining a member, a required field becoming optional</td><td>No</td></tr> - </tbody> - </table></div> - <p>Run it in CI on every pull request. This GitHub Actions workflow first checks that the committed <code>.deco/schema.json</code> is up to date: it regenerates the schema and fails if the file changed or was never committed. It then compares the schema with the one on the pull request's target branch. On the first pull request that adds a schema there's nothing to compare with yet, so it skips that step. Any other problem, such as a missing branch, fails the job instead of skipping it:</p> -<figure class="code-example"><figcaption>.github/workflows/schema.yml</figcaption><pre><code class="language-yaml">name: Schema -on: pull_request - -jobs: - schema: - runs-on: ubuntu-latest - env: - BASE_REF: ${{ github.base_ref }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: npm - - run: npm ci - - name: The committed schema matches the code - run: | - npx --no-install deco schema - if [ -n "$(git status --porcelain -- .deco/schema.json)" ]; then - git diff -- .deco/schema.json - echo ".deco/schema.json is missing or out of date: run deco schema and commit it." - exit 1 - fi - - name: Fetch the base schema - run: | - git rev-parse --verify --quiet "origin/$BASE_REF" > /dev/null \ - || { echo "origin/$BASE_REF not found"; exit 1; } - if git cat-file -e "origin/$BASE_REF:.deco/schema.json" 2> /dev/null; then - git show "origin/$BASE_REF:.deco/schema.json" > base-schema.json - fi - - name: No breaking changes - run: | - if [ -f base-schema.json ]; then - npx --no-install deco schema --check base-schema.json - else - echo "No .deco/schema.json on $BASE_REF yet; skipping the compatibility check." - fi</code></pre></figure> - <p><code>--no-install</code> makes <code>npx</code> fail if <code>@decocms/blocks-cli</code> isn't installed, instead of downloading some other package named <code>deco</code>. If you use <code>--root</code>, pass it to both <code>deco schema</code> calls and replace <code>.deco/schema.json</code> in the workflow with <code><dir>/schema.json</code>. Make the job a required check in your branch protection rules, so a failing pull request can't merge.</p> - <p>Studio's saves are commits to the same repository. They only change content, never the schema, so <code>--check</code> always passes them, and <code>deco schema</code> still checks the content model (entry names, aliases and route collisions). When Studio saves through pull requests, this workflow checks them before merge. When it commits straight to your production branch, they're live as soon as they land, so a check can only report a problem afterwards: add <code>push: { branches: [main] }</code> under <code>on</code> and guard the two comparison steps with <code>if: github.event_name == 'pull_request'</code>, since a push has no target branch.</p> - - <h3>Annotate fields with JSDoc</h3> - <p>The TypeScript type decides what kind of field each input gets: a <code>string</code> is a text box, a <code>number</code> a number field, a union of string literals a select, an array a list editors can add to and reorder. JSDoc tags on a field refine the form:</p> - <div class="table-wrap"><table> - <thead><tr><th>Tag</th><th>Effect in the editor</th><th>Example</th></tr></thead> - <tbody> - <tr><td><code>@title</code></td><td>The field's label (the default is the property name)</td><td><code>@title Headline</code></td></tr> - <tr><td><code>@description</code></td><td>Help text under the field</td><td><code>@description Shown above the fold</code></td></tr> - <tr><td><code>@default</code></td><td>The value a new block starts with, parsed as JSON when it can be</td><td><code>@default 10</code></td></tr> - <tr><td><code>@minimum</code> / <code>@maximum</code></td><td>The allowed range of a number</td><td><code>@maximum 100</code></td></tr> - <tr><td><code>@minLength</code> / <code>@maxLength</code></td><td>The allowed length of a string</td><td><code>@maxLength 60</code></td></tr> - <tr><td><code>@format</code></td><td>A specialized input for a string, such as <code>date</code>, <code>date-time</code>, <code>rich-text</code>, <code>textarea</code>, <code>color</code> or <code>image-uri</code></td><td><code>@format rich-text</code></td></tr> - <tr><td><code>@ignore</code></td><td>Leaves the field out of the form</td><td><code>@ignore</code></td></tr> - </tbody> - </table></div> - <p>Put each tag on its own line. This type:</p> -<pre class="code-block"><code class="language-typescript">export interface HeroProps { - /** - * @title Headline - * @maxLength 60 - */ - title: string; - /** - * @title Body - * @format rich-text - */ - body?: string; - /** - * @title Ends on - * @format date - */ - endsOn?: string; -}</code></pre> - <p>becomes a form with a <em>Headline</em> text box limited to 60 characters, an optional <em>Body</em> rich-text editor, and an optional <em>Ends on</em> date picker.</p> - - <h3>Content that is only data</h3> - <p>A page, a blog post, a redirect: these have a shape but no function. They're never resolved themselves: your app reads them, by type with <code>client.list("post")</code> or by URL with <code>matchRoute</code> (see <a href="#routing">Routing</a>), and resolves the blocks <em>inside</em> them. They still need a schema so Studio can create them, so declare them for the CLI in a type-level map next to the block map. <code>deco schema</code> always includes two built-in content types, <code>page: Page</code> and <code>redirect: Redirect</code>, so you declare only your own, like <code>post: Post</code>. <code>Page</code>, <code>Redirect</code> and <code>Route</code> are exported from <code>@decocms/blocks</code>.</p> -<figure class="code-example"><figcaption>blocks.ts</figcaption><pre><code class="language-typescript">import type { Blocks } from "@decocms/blocks"; -import type { Post } from "./post"; -import seo from "./seo"; -import hero from "./hero"; - -export default { seo, hero } satisfies Blocks; // code: a schema per function parameter - -export type Content = { // data: a schema per type - post: Post; // page and redirect are built in -};</code></pre></figure> - <p>The CLI reads both exports. Keys of the default export get a schema from each function's parameter type; keys of <code>Content</code> get one from the type itself. The two share one namespace, and a key in both is an error. The CMS never sees <code>Content</code>: <code>list("page")</code> works because the entries exist, and resolving a page means resolving its fields, not the page.</p> - <p><strong>Override or opt out.</strong> Declaring a built-in key in <code>Content</code> replaces it. Extend the exported type to add fields, or to narrow <code>sections</code>, a page's ordered list of blocks:</p> -<pre class="code-block"><code class="language-typescript">import type { Page } from "@decocms/blocks"; - -interface StorePage extends Page { theme: "light" | "dark" } - -export type Content = { page: StorePage; post: Post };</code></pre> - <p>An app that isn't a website opts out with <code>page: never</code> or <code>redirect: never</code>. Either way, only the schema changes: the CMS never reads <code>Content</code>.</p> - - <p>Studio learns the shape of your content only from the schema this command writes. What it looks for, which endpoints it calls on your site, and how the CLI bridges today's Studio conventions are covered <a href="#studio">under the hood</a>.</p> -</section> - -<section id="preview" data-nav="Preview" class="doc-section" aria-labelledby="preview-title"> - <p class="eyebrow">Core concepts</p><h2 id="preview-title">Preview</h2> - <p>A <strong>draft</strong> is content an editor has changed in Studio but not published yet, kept on a branch until someone publishes it. Editors want to see a draft before it's published. Because block <em>functions</em> ship with your app and only <em>content</em> changes, previewing a draft needs no deploy: the app renders the same code against different content.</p> - <p>Here's the whole mechanism. Studio opens your site with a <strong>draft pointer</strong> in the URL:</p> -<pre class="code-block"><code class="language-bash">https://store.example.com/summer?__draft=studio.decocms.com/api/acme/decofile/store/feat-summer%3Ftoken%3D…@9f3c1a</code></pre> - <p>The part after <code>__draft=</code> is URL-encoded, which is why <code>?</code> and <code>=</code> show as <code>%3F</code> and <code>%3D</code>. Decoded, <code>studio.decocms.com/api/…/feat-summer?token=…</code> is where the draft lives: the Deco API's address for this site's <code>feat-summer</code> branch, plus a token signed by Studio. Treat the path as opaque. <code>9f3c1a</code>, after the <code>@</code>, is the draft's version.</p> - <p>The pointer is a string, <code><host><path>@<version></code>: where the draft content lives, and which version. <code>draftPointer(request)</code> reads it, from <code>?__draft=</code> or from the cookie described below, and the CMS gives you a client over that draft. Everything after that line is the same code that serves visitors:</p> -<figure class="code-example"><figcaption>A plain request handler</figcaption><pre><code class="language-typescript">import { draftPointer } from '@decocms/blocks' -import { cms } from './cms' - -export async function handle(request: Request) { - const pointer = draftPointer(request) // null on an ordinary request - const client = pointer ? cms.forDraft(pointer) : cms.forRelease() - - return render(client, request) // your own function: list, matchRoute and resolve as usual -}</code></pre></figure> - <p><code>forDraft</code> hands the pointer to whatever supplies your content. <code>remoteLoader</code> fetches that exact version from the Deco API, waits for it, and caches it by version, so the second request for the same draft is a memory read. A draft replaces the whole revision, deletions included, so a page in preview looks exactly as it will after publishing. The content module has no drafts, so it ignores the pointer: in development you're already looking at the files. A loader you write honors it if your store has drafts (see <a href="#content--write-your-own-loader">Write your own loader</a>). That's why the code above never checks where its content comes from.</p> - - <h3>Keep the draft across navigation</h3> - <p>The pointer is only in the URL of the first page Studio opens. <code>draftCookie(request)</code> gives you the <code>Set-Cookie</code> value that response needs, and <code>null</code> on every other request. Append it, and <code>draftPointer</code> reads the cookie from then on, so every link the editor clicks stays in preview:</p> -<figure class="code-example"><figcaption>Query parameter first, cookie afterwards</figcaption><pre><code class="language-typescript">import { draftPointer, draftCookie } from '@decocms/blocks' -import { cms } from './cms' - -export async function handle(request: Request) { - const pointer = draftPointer(request) - const client = pointer ? cms.forDraft(pointer) : cms.forRelease() - const response = await render(client, request) - - const cookie = draftCookie(request) - if (cookie) response.headers.append('Set-Cookie', cookie) - return response -}</code></pre></figure> - <p>Studio ends a preview by opening the site with an empty <code>?__draft=</code>; <code>draftCookie</code> returns an expiring cookie for that, so the same two lines cover leaving preview. The parameter and cookie names never appear in your code. In Next.js the two steps split across <code>proxy.ts</code> (Next.js 16's name for <code>middleware.ts</code>), which appends the cookie, and a server helper that reads it with <code>cookies()</code>. The <a href="#nextjs">Next.js guide</a> has both files.</p> - - <h3>Not a website</h3> - <p><code>forDraft</code> takes the pointer string and nothing else, so nothing here assumes HTTP. A mobile app takes it from the deep link Studio opens, or from a "preview" field on a debug screen, and keeps it in app state:</p> -<pre class="code-block"><code class="language-typescript">// 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())</code></pre> - <p>A script passes it as an argument. To look inside a pointer, or build one from parts, use <code>parseDraftPointer</code> and <code>formatDraftPointer</code> from the <a href="#standalone">reference</a>; <code>forDraft</code> parses on its own, so normally you don't have to.</p> - - <h3>Who may preview</h3> - <p>Anyone can put <code>?__draft=</code> in a URL, so the CMS checks two things, and neither needs configuration in your app. First, the pointer must name your site's own Deco API host, which <code>remoteLoader</code> looks up with your token. Any other host is ignored, so a crafted pointer can't make your server fetch from somewhere else. Second, the pointer carries a token signed by Studio, which the Deco API checks before serving the draft.</p> - <p>If a website wants to show drafts on its preview domain only, it checks the request's host before calling <code>forDraft</code>:</p> -<pre class="code-block"><code class="language-typescript">const host = new URL(request.url).host -const pointer = host === 'preview.store.example.com' ? draftPointer(request) : null -const client = pointer ? cms.forDraft(pointer) : cms.forRelease()</code></pre> - <div class="callout warning"><p><strong>The token is the only secret.</strong> The site holds no other credential. Invalid or unexpected pointers are ignored and the request falls back to published content, never to an error page.</p></div> - - <h3>Connect Studio to your site</h3> - <p>Studio opens previews on your site's URL, so the site has to be set up in the hosted Deco CMS with your token (creating a site and a token for this API is on the <a href="#roadmap-platform--build-the-deco-api-release-service">Roadmap</a>). Support for sites on this API, including previews inside Studio's frame, is still being built; see the <a href="#roadmap-blockers--make-draft-preview-work-inside-studio-s-iframe">Roadmap</a>. To try the mechanism locally: with only local files, <code>forDraft</code> returns the release, because the content module has no drafts. Testing a real draft needs a token.</p> -</section> - -<section id="manifest" data-nav="Block maps" class="doc-section" aria-labelledby="manifest-title"> - <p class="eyebrow">Advanced</p><h2 id="manifest-title">Block maps</h2> - <p>A block map is the code half of the <a href="#model">registry</a>: a plain object from block type to function. The other half is the content map of saved entries, which you pass to <code>createCMS</code> as <a href="#content"><code>content</code></a>. The quickstart's <code>blocks.ts</code> is one. This section covers what else can go in it.</p> -<figure class="code-example"><figcaption>blocks.ts</figcaption><pre><code class="language-typescript">import type { Blocks } from "@decocms/blocks"; -import seo from "./seo"; -import productCard from "./product-card"; -import hero from "./hero"; - -export default { - seo, - "product-card": productCard, - hero, -} satisfies Blocks;</code></pre></figure> - <p>Each key is a block type. Each value is a function <code>(inputs) => value</code>, where <code>inputs</code> is the block without its <code>__resolveType</code>: saved entries already expanded and merged, and nested calls already run, so the function receives values, never a pending call. Use <code>satisfies Blocks</code> rather than a type annotation: it checks the shape while keeping the concrete keys and function types, which is what the schema is generated from.</p> - <p>Keep the map a plain object at module level. The <a href="#schema">CLI</a> reads the file without running it, so build it once and give it to <code>createCMS</code>. Only functions go in the map. Types that are pure data, such as pages, posts, and redirects, have nothing to run; they're declared for the CLI instead, see <a href="#schema">content that is only data</a>.</p> - <div class="callout"><p><strong>The CMS is pure.</strong> It passes your function the block and nothing else: no context object, no request, no hidden state. When a block needs the current request, a cookie, or a route parameter, read it the way the rest of your app does, usually through request-scoped storage your framework already provides (Node's <code>AsyncLocalStorage</code>, which <code>next/headers</code> and TanStack Start's <code>getRequest()</code> use under the hood). That keeps block functions ordinary functions you can call and test directly.</p></div> - - <h3>Lazy loading</h3> - <p>The map above imports every block at the top, so they all load with the map. That's fine for functions like <code>seo</code>. It's not what you want for a component that drags in a charting library, or for the two hundred components a large store has when any page uses five.</p> - <p>What's heavy is the component a block renders, and your framework already knows how to load components lazily. So do it in one place, the map, and let the block files stay plain components:</p> -<figure class="code-example"><figcaption>blocks.tsx (Next.js)</figcaption><pre><code class="language-tsx">import dynamic from 'next/dynamic' -import type { Blocks } from '@decocms/blocks' -import seo from './seo' -import type { ProductProps } from './components/Product' -import type { HeroProps } from './components/Hero' - -// Each component is split into its own chunk and fetched on first render. -const Product = dynamic(() => import('./components/Product')) -const Hero = dynamic(() => import('./components/Hero')) - -export default { - seo, // eager: tiny, used everywhere - product: (input: ProductProps) => <Product {...input} />, - hero: (input: HeroProps) => <Hero {...input} />, -} satisfies Blocks</code></pre></figure> - <p>With TanStack Start, use React's <code>lazy</code> instead of <code>dynamic</code>. Where it goes depends on which of the two rendering modes you use (see <a href="#rendering">Rendering</a>), and the <a href="#tanstack-data">TanStack guides</a> show the code. In every case the component files know nothing about the CMS, the map stays a plain object the CLI can read, and the bundler splits on the <code>import()</code>.</p> - <p>For a block that's heavy for a non-UI reason, say a data function that pulls in a large SDK, a dynamic import inside the function body is enough. It's one line and needs no helper:</p> -<pre class="code-block"><code class="language-typescript">"catalog-product": async (input: ProductQuery) => { - const { fetchProduct } = await import("./commerce") // loaded on first call; the JS module system caches it after that - return fetchProduct(input) -},</code></pre> - <p>Keep import paths literal in both cases. A bundler can split <code>import("./components/Product")</code> into its own chunk; it can't split <code>import(`./components/${name}`)</code>.</p> - - <h3>Choose type names that last</h3> - <p>A block type's name is what content stores in <code>__resolveType</code> to call it, so pick it like a database column, not like a file path: short (<code>seo</code>, <code>hero</code>) or namespaced when a site has many (<code>commerce/product</code>). Moving a file only means updating an import; the name doesn't change. Renaming a type means migrating every block that uses it, or adding an alias, a second name for the same block type (see <a href="#adoption">Renaming and migrating</a>). Type names share one namespace with saved entry names (see <a href="#model">Blocks</a>), and <code>deco schema</code> fails on a clash (at runtime, the function wins). A simple convention avoids clashes: lowercase or kebab-case for block types (<code>seo</code>, <code>product-card</code>), capitalized names for entries (<code>SummerSEO</code>).</p> -</section> - -<section id="variants" data-nav="Matchers & variants" class="doc-section" aria-labelledby="variants-title"> - <p class="eyebrow">Advanced</p><h2 id="variants-title">Matchers and variants</h2> - <p>Editors often want content that depends on a condition: a banner that only shows during a campaign, a title that changes on a date, an experiment. Two small ideas cover all of it, and neither knows what a website is, so they work the same in a website, a mobile app, or a background job (a script or cron task).</p> - <p>A <strong>variant</strong> is one of several alternative values for the same input, together with the rule that selects it. A <strong>matcher</strong> is a block function that returns a boolean, used as a variant's rule. A <strong>multivariate</strong> (after multivariate testing) is a block function that takes a list of variants, <code>{ rule, value }</code> pairs, and returns the first value whose rule is true. It's three lines, so you own it and can change how it picks. Because the CMS resolves inputs before calling a function, the multivariate never sees a matcher, only booleans.</p> - - <h3>The three matchers that ship</h3> - <p>They depend on nothing but the clock, so they're the only ones the framework provides:</p> -<pre class="code-block"><code class="language-typescript">import { always, never, date } from "@decocms/blocks/matchers"; - -// always() => true the default rule of a variant -// never() => false Studio hides a block by wrapping it in a variant whose rule is never() -// date({ start?: string; end?: string }) => boolean, true when now is inside [start, end]</code></pre> - <p><code>start</code> and <code>end</code> are ISO 8601 strings. A date without a time, like <code>"2026-11-30"</code>, means midnight UTC, so add a time and offset (<code>"2026-11-30T23:59:59-03:00"</code>) to end on your own clock.</p> - <p>Everything else that could gate content, a device, a cookie, a country, a user's plan, depends on where your app runs and how it sees its environment. Those are yours to write, below.</p> - - <h3>The multivariate is three lines you own</h3> -<figure class="code-example"><figcaption>multivariate.ts</figcaption><pre><code class="language-typescript">export function multivariate<T>({ variants }: { variants: { rule: boolean; value: T }[] }) { - return variants.find((v) => v.rule)?.value; // first match wins, so put the always() variant last, as the fallback -}</code></pre></figure> -<figure class="code-example"><figcaption>blocks.ts</figcaption><pre><code class="language-typescript">import type { Blocks } from "@decocms/blocks"; -import { always, never, date } from "@decocms/blocks/matchers"; -import { multivariate } from "./multivariate"; -import hero from "./hero"; - -export default { always, never, date, multivariate, hero } satisfies Blocks;</code></pre></figure> - <p>Editors compose them in Studio. Each variant is a tab, and its rule is picked from the matchers in your map. Variants are checked in order, so the one with the <code>always</code> rule goes last and catches everything else. A hero whose title changes for a campaign is a <code>hero</code> whose <code>title</code> is a <code>multivariate</code>:</p> -<figure class="code-example"><figcaption>Saved as HomeHero</figcaption><pre><code class="language-json">{ - "__resolveType": "hero", - "title": { - "__resolveType": "multivariate", - "variants": [ - { "rule": { "__resolveType": "date", "start": "2026-11-20", "end": "2026-11-30" }, "value": "Black Friday starts now" }, - { "rule": { "__resolveType": "always" }, "value": "New season, new arrivals" } - ] - } -}</code></pre></figure> -<pre class="code-block"><code class="language-typescript">// Means -hero({ - title: multivariate({ - variants: [ - { rule: date({ start: "2026-11-20", end: "2026-11-30" }), value: "Black Friday starts now" }, - { rule: always(), value: "New season, new arrivals" }, - ], - }), -})</code></pre> - <p>Resolution does the rest: each rule runs and yields a boolean, <code>multivariate</code> runs with those booleans and yields a string, <code>hero</code> runs with a plain <code>title</code>. Nothing in <code>hero</code> knows a variant was involved. The same shape wraps any block in a page, or the page's whole <code>sections</code> list, which is how Studio does page-level variants.</p> - - <h3>Write your own matcher</h3> - <p>A matcher is any block function that returns a boolean. It reads whatever your app knows about the current request or user the way every other block does, through the context your runtime already provides:</p> -<figure class="code-example"><figcaption>matchers.ts (website)</figcaption><pre><code class="language-typescript">import { headers } from "next/headers"; - -// A website: the device from the user agent. -export async function device({ device }: { device: "mobile" | "desktop" }) { - const ua = (await headers()).get("user-agent") ?? ""; - return (/Mobi|Android/i.test(ua) ? "mobile" : "desktop") === device; -}</code></pre></figure> -<figure class="code-example"><figcaption>matchers.ts (app)</figcaption><pre><code class="language-typescript">// A mobile app: the user's plan from app state. No request, no headers. -export const plan = ({ plan }: { plan: "free" | "pro" }) => session.current().plan === plan;</code></pre></figure> - <p>Add them to the map and Studio's rule picker shows them next to <code>date</code>. The CLI lists every block function that returns a boolean as a matcher, which is how Studio knows to offer it in the rule picker (see <a href="#studio--what-studio-reads">what Studio reads</a>). Combinators are matchers too: <code>and</code>, <code>or</code>, and <code>not</code> are one-liners over booleans, if you want them.</p> - - <h3>Every variant resolves</h3> - <p>Inputs resolve before a function runs, so every variant's <code>value</code> resolves before <code>multivariate</code> picks one. For a title that's free. For two product queries it isn't, and a failure in a variant you didn't pick still fails the whole thing.</p> - <p>The lazy form is to make the values <em>entry names</em> instead of inline blocks. A string isn't a block, so nothing runs; the multivariate returns a name, and your app resolves it:</p> -<pre class="code-block"><code class="language-json">{ - "__resolveType": "multivariate", - "variants": [ - { "rule": { "__resolveType": "date", "start": "2026-11-20", "end": "2026-11-30" }, "value": "BlackFridayHero" }, - { "rule": { "__resolveType": "always" }, "value": "DefaultHero" } - ] -}</code></pre> -<pre class="code-block"><code class="language-typescript">// Means -multivariate({ - variants: [ - { rule: date({ start: "2026-11-20", end: "2026-11-30" }), value: "BlackFridayHero" }, - { rule: always(), value: "DefaultHero" }, - ], -}) // the values are entry names, not calls: only the rules and multivariate run - -// block is the JSON above, e.g. from a page's sections list; -// client.resolve accepts an entry name or an inline block -const [name, err] = await client.resolve(block); // "BlackFridayHero" -if (err) throw err; -const [hero] = await client.resolve(name); // only this variant ran</code></pre> - <div class="callout warning"><p><strong>A variant is not access control.</strong> It chooses between values that are all fine to compute and show. 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.</p></div> - <p>If you cache rendered pages, at a CDN for example, anything a matcher looks at must be part of the cache key. Otherwise the first visitor's variant is cached and served to everyone: a page that varies by device would show the cached desktop hero to mobile visitors. See <a href="#operations">Caching and observability</a>.</p> - <p>Nothing so far has assumed a website. The next part adds URLs.</p> -</section> - -<section id="content-types" data-nav="Routes & redirects" class="doc-section" aria-labelledby="content-types-title"> - <p class="eyebrow">Websites</p><h2 id="content-types-title">Routes, redirects, and content types</h2> - <p>A <strong>content type</strong> is a kind of saved entry that holds data and runs no function, such as a page, a blog post or a redirect. Its name is the value of <code>__resolveType</code>. You declare your own in <code>Content</code>; <code>page</code> and <code>redirect</code> are built in, and <code>deco schema</code> always includes them (see <a href="#schema--content-that-is-only-data">Content that is only data</a>). The framework also ships the <code>Route</code> interface, which any content type can extend to get a URL.</p> -<pre class="code-block"><code class="language-typescript">/** 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" -} - -/** A saved entry of the built-in `redirect` content type. */ -interface Redirect { - from: string; // literal or template - to: string; - permanent: boolean; // 301 or 302 -}</code></pre> - <p>A page is a route with SEO and a list of <a href="#model--resolving-a-page-step-by-step">blocks</a> (its <code>sections</code> field). It's not special to the router; it's just the first type that extends <code>Route</code>. It's shaped like the <code>SummerSale</code> entry in <a href="#model">Blocks</a>, with three additions: <code>"__resolveType": "page"</code>, so the CMS can list it by type, and a <code>name</code> and <code>path</code>, so the router can find it. It's built in, so you don't declare it. To add fields, override it in <code>Content</code> (<code>interface StorePage extends Page { … }</code>; see <a href="#schema--content-that-is-only-data">Schema generation</a>). It's exported from <code>@decocms/blocks</code>:</p> -<pre class="code-block"><code class="language-typescript">/** The built-in `page` content type. */ -interface Page extends Route { - seo: Block; // an SEO block, or a reference to a saved one - sections: VariantOf<Array<VariantOf<Block>>>; // a list of blocks; the whole list, or any one block, can be a variant -}</code></pre> - <p><code>Block</code> is a block as stored: JSON whose <code>__resolveType</code> names a function or a saved entry. <code>VariantOf<T></code> accepts a <code>T</code> or a <a href="#variants">multivariate</a> block that picks one. Both fields are deliberately loose, because the package can't know your block types. To narrow them, override <code>page</code>: for example <code>interface StorePage extends Page { seo: MySeoBlock; sections: Block[] }</code>, then <code>export type Content = { page: StorePage }</code>.</p> -<figure class="code-example"><figcaption>Saved as SummerPage</figcaption><pre><code class="language-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" } - ] -}</code></pre></figure> -<pre class="code-block"><code class="language-typescript">// What its fields mean. page isn't a function, so the object stays data and you resolve each field separately. -{ - __resolveType: "page", - 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" })], -}</code></pre> -<figure class="code-example"><figcaption>Saved as LegacySummer</figcaption><pre><code class="language-json">{ - "__resolveType": "redirect", - "from": "/campaigns/summer", - "to": "/summer", - "permanent": true -}</code></pre></figure> - <p>Two SDK functions turn these entries into URLs. <code>client.list("page")</code> returns every saved entry whose <code>__resolveType</code> is <code>page</code>. <code>matchRoute(url, { routes, redirects })</code> picks the entry that matches a URL. <a href="#routing">Routing</a> puts them together.</p> - <p><strong>The router's rule is one line: an entry with a string <code>path</code> matches at that path.</strong> <code>Route</code> is what makes that intentional. The CLI checks that any type declaring <code>path</code> extends <code>Route</code>, so a stray <code>path</code> field on an unrelated block is a schema error rather than a surprise URL. Editors see <code>name</code> and <code>path</code> as ordinary fields on every routable thing, in Studio, which is how a marketing team gets <code>/summer-sale</code> live without a developer. Keep <code>path</code> and <code>from</code> plain strings, not blocks, so the router can match without running code.</p> - - <h3>Your own routable types</h3> - <p>A blog doesn't want a <code>page</code> entry per post. Make <code>Post</code> extend <code>Route</code>, declare it in <code>Content</code> as <code>post: Post</code>, and pass the saved posts to <code>matchRoute</code> along with your pages (see <a href="#routing">Routing</a>). Each post is then served at its own <code>path</code>:</p> -<figure class="code-example"><figcaption>src/post.ts</figcaption><pre><code class="language-typescript">import type { Route } from "@decocms/blocks"; - -export interface Post extends Route { - /** @title Published on @format date */ - date: string; - /** @format rich-text */ - body: string; -}</code></pre></figure> -<figure class="code-example"><figcaption>Saved as HelloWorld</figcaption><pre><code class="language-json">{ "__resolveType": "post", "name": "Hello, world", "path": "/blog/hello-world", "date": "2026-09-01", "body": "…" }</code></pre></figure> - <p>Two entries with the same path, or two templates that can match the same URL, are rejected by the CLI and by <code>matchRoute</code> (see <a href="#routing--path-templates-and-match-order">Path templates and match order</a>).</p> - - <h3>Pages are data; resolve their fields</h3> - <p>A page runs no function, so you never resolve the page itself. Read it, then resolve the blocks inside it: <code>client.resolve(page.seo)</code>, and one <code>client.resolve(block)</code> per block in <code>page.sections</code>. Separate calls let each block render as soon as its own data is ready (see <a href="#rendering">Rendering</a>). A redirect is never resolved; <code>matchRoute</code> only reads it.</p> - <p><code>client.resolve("SummerPage")</code> and <code>client.list("page", { run: true })</code> fail with <code>UNKNOWN_BLOCK</code>, because <code>page</code> names no function, and the CMS never sees <code>Content</code> (see <a href="#model--the-rule">the rule</a>). To read a page, list it, or pass <code>{ run: false }</code>.</p> - <div class="callout"><p><strong>Running code for a whole page.</strong> If you want code to run for a whole page, such as validation or a layout step, add a <code>page</code> function to the block map and opt the built-in type out with <code>page: never</code> in <code>Content</code>, since a key can't be both. Then <code>client.resolve(page)</code> runs it after every block in the page has resolved.</p></div> - - <h3>Content types without a URL</h3> - <p>Navigation, email templates, and campaign settings work the same way, minus <code>Route</code>: declare the type in <code>Content</code> if it's data, or register a function if something should run, and save entries of that type.</p> -</section> - -<section id="rendering" data-nav="Rendering" class="doc-section"> - <p class="eyebrow">Websites</p><h2>Rendering</h2> - <p>A block function can return anything. For a website it usually returns something that becomes UI, and there are two ways to get that to the browser.</p> - <ul> - <li><strong>Descriptors (data mode):</strong> the block returns plain JSON such as <code>{ component: "product", props: {…} }</code>, and a view registry in your app (an object mapping each <code>component</code> name to a React component) renders it on the server and again in the browser.</li> - <li><strong>React Server Components (RSC):</strong> 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 <code>"use client"</code> ship JavaScript.</li> - </ul> - <p>The CMS doesn't care which you pick; it returns whatever your block function returns. Each integration uses one of the two:</p> - <div class="table-wrap"><table> - <thead><tr><th>Integration</th><th>Block returns</th><th>Streaming</th><th>Ships to the browser</th></tr></thead> - <tbody> - <tr><td><a href="#nextjs">Next.js App Router</a></td><td>JSX</td><td>Server Component Suspense boundaries</td><td>Client Components</td></tr> - <tr><td><a href="#tanstack-data">TanStack Start, data</a></td><td>Serializable descriptors</td><td>Deferred promises with <code>Await</code></td><td>The view registry and its components</td></tr> - <tr><td><a href="#tanstack-rsc">TanStack Start, RSC</a></td><td>JSX</td><td>RSC stream (Flight)</td><td>Client references and the RSC runtime</td></tr> - </tbody> - </table></div> - - <h3>Which mode to use</h3> - <p>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.</p> - - <h3>Block functions run on the server</h3> - <p>In all three integrations, 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.</p> - <p>Don't put a Client Component (a component from a <code>"use client"</code> file) in the block map directly. On the server, importing it gives you a placeholder React can render but your code can't call, and the CMS calls every block function. Wrap it instead: <code>cart: (input: CartProps) => <CartButton {...input} /></code>.</p> - - <h3>Errors, streaming, and cancellation</h3> - <ul> - <li><strong>Start every block first.</strong> 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.</li> - <li><strong>One Suspense boundary per block</strong> lets each block appear as soon as it's ready.</li> - <li><strong>Suspense handles loading, not errors.</strong> Each <code>client.resolve(block)</code> returns <code>[value, error]</code>; render a fallback when <code>error</code> is set. Errors thrown later while React renders need an error boundary.</li> - <li><strong>Cancellation is your app's job.</strong> The CMS never sees the request. A block that fetches upstream should take the request's <code>signal</code> from wherever your app keeps request state.</li> - <li><strong>Once streaming starts, the status code is fixed.</strong> The status line and headers are already sent, so a later error can't change them. Decide 404s and redirects (the <code>matchRoute</code> result) before you stream.</li> - </ul> -</section> - -<section id="routing" data-nav="Routing" class="doc-section"> - <p class="eyebrow">Websites</p><h2>Routing</h2> - <p>The CMS doesn't route. It lists and resolves. Routing is three steps in your request handler: list the types you want at URLs, hand them to <code>matchRoute</code>, resolve the winner. Do all three with one client, so they see the same content even if editors publish mid-request (see <a href="#releases--one-revision-per-response">One revision per response</a>).</p> - <h3>The example used here and in the guides</h3> - <p>This page and the framework guides share these files: the model in <code>src/model.ts</code>, the post type in <code>src/post.ts</code> (from <a href="#content-types">Your own routable types</a>), and two content entries, stored as files in <code>.deco/blocks</code>, which <code>deco content</code> turns into the content module (see <a href="#content">Content and loaders</a>). Each guide's block map (<code>src/blocks.tsx</code> or <code>src/blocks.ts</code>) declares <code>seo</code> and its own <code>product</code>. Pages and redirects are built-in content types (see <a href="#schema--content-that-is-only-data">Content that is only data</a>); the guides override <code>page</code> with <code>StoredPage</code>, below, to narrow its <code>sections</code> field, so Studio's form matches what the guide code reads.</p> - <p>In <code>src/model.ts</code>, <code>ProductDescriptor</code> is the descriptor a <code>product</code> block returns in data mode (see <a href="#rendering">Rendering</a>), and <code>StoredPage</code> is a page exactly as saved, before any of its blocks run.</p> -<figure class="code-example"><figcaption>src/model.ts</figcaption><pre><code class="language-typescript">import type { Block, Page } from '@decocms/blocks' - -export interface SEO { title: string; description: string } -export interface ProductProps { id: string; name: string; price: number; currency: string } -export interface ProductDescriptor { component: 'product'; props: ProductProps } - -// A saved page, as client.list returns it by default (nothing run). -// Overrides the built-in Page to keep sections a plain array: no variant around the whole list. -export interface StoredPage extends Page { - sections: Block[] -}</code></pre></figure> - <p><code>StoredPage</code> narrows the built-in <code>Page</code> type from <a href="#content-types">Routes and redirects</a>: it doesn't allow a <a href="#variants">variant</a> around the whole <code>sections</code> list. Each guide's block map exports <code>export type Content = { page: StoredPage }</code>, so <code>deco schema</code> gives Studio the narrower form. If you keep the built-in <code>Page</code>, which does allow it, resolve the list first, <code>const [list] = await client.resolve(page.sections)</code>, which picks the variant and also runs every block in it, or store the variant's values as entry names as in <a href="#variants--every-variant-resolves">Every variant resolves</a>.</p> -<figure class="code-example"><figcaption>.deco/blocks/ProductPage.json</figcaption><pre><code class="language-json">{ - "__resolveType": "page", - "name": "Product page", - "path": "/:slug/p", - "seo": { - "__resolveType": "seo", - "title": "Summer shirt | Deco example", - "description": "A small example of independently resolved fields." - }, - "sections": [ - { "__resolveType": "product", "id": "details", "name": "Summer shirt", "price": 49, "currency": "USD" }, - { "__resolveType": "product", "id": "related", "name": "Related shirt", "price": 39, "currency": "USD" } - ] -}</code></pre></figure> -<pre class="code-block"><code class="language-typescript">// Means (page isn't a function, so the object stays data; the blocks inside it are calls) -{ - __resolveType: "page", - name: "Product page", - path: "/:slug/p", - seo: seo({ title: "Summer shirt | Deco example", description: "A small example of independently resolved fields." }), - sections: [ - product({ id: "details", name: "Summer shirt", price: 49, currency: "USD" }), - product({ id: "related", name: "Related shirt", price: 39, currency: "USD" }), - ], -} -// product is whatever each guide's block map registers: JSX in Next.js and TanStack RSC, a descriptor in data mode</code></pre> -<figure class="code-example"><figcaption>.deco/blocks/LegacyProduct.json</figcaption><pre><code class="language-json">{ - "__resolveType": "redirect", - "from": "/old-products/:slug", - "to": "/:slug/p", - "permanent": true -}</code></pre></figure> - <p>The redirect sends <code>/old-products/summer</code> to <code>/summer/p</code>. The page uses literal product values to keep the example small. A real page would use a block that fetches the product for the slug, reading <code>match.params</code> from your app's request state (see <a href="#manifest">Block maps</a>).</p> - - <h3>Route a request</h3> - <p>With those files in place, this is the whole request handler:</p> -<pre class="code-block"><code class="language-typescript">import { matchRoute, type Redirect } from "@decocms/blocks"; -// 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, and catch matchRoute's throw (see below) - -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": - switch (match.entry.__resolveType) { - case "page": { - const blocks = match.entry.sections.map((b) => client.resolve(b)); // start them all - const [seo] = await client.resolve(match.entry.seo); // then await - return render(seo, blocks); - } - case "post": - return renderPost(match.entry); // data: nothing to resolve - } -}</code></pre> - <p>Mount this handler as your app's catch-all route, the one that runs when no other route matches.</p> - <div class="table-wrap"><table> - <thead><tr><th><code>kind</code></th><th>Fields</th></tr></thead> - <tbody> - <tr><td><code>"match"</code></td><td><code>entry</code> (the stored route you passed in) and <code>params</code> from the path template.</td></tr> - <tr><td><code>"redirect"</code></td><td><code>location</code> with parameters filled in, and <code>status</code> (<code>301</code> or <code>302</code>).</td></tr> - <tr><td><code>"not-found"</code></td><td>None</td></tr> - </tbody> - </table></div> - <div class="callout"><p><strong><code>matchRoute</code> is a helper, not a requirement.</strong> The CMS has no idea what a URL is. If your framework already routes, or you'd rather read a page by name (<code>client.resolve("SummerPage", { run: false })</code>, then resolve its fields) or find it by a field of your own, skip it. It exists because "an editor typed a path into Studio and that page is now live" is the common case, and getting precedence and parameters right by hand is tedious.</p></div> - <p>Which types route is your decision, per handler: a blog lists <code>post</code>, a docs site lists <code>doc</code>. <code>match.params</code> holds the URL parameters; hand them to your code however your app shares request state. The CMS never injects them into blocks.</p> - <p>For example, with Node's <code>AsyncLocalStorage</code>: <code>export const routeParams = new AsyncLocalStorage<Record<string, string>>()</code>. Wrap rendering in <code>routeParams.run(match.params, () => render(…))</code>, and a block reads <code>routeParams.getStore()?.slug</code>. The <a href="#manifest">Block maps</a> page covers request-scoped state in more detail.</p> - - <p>Matching is a lookup, not a scan: entries are compiled into a segment trie (a tree keyed by path segment), so a lookup costs the URL's depth, not the number of routes. The trie is cached per <code>routes</code> array object, so passing the same array again skips the build. The handler above builds a new array per request, which rebuilds the trie each time; that's one pass over the paths, fine for most sites. Reusing one array per revision needs the client to expose its revision, which is on the <a href="#roadmap-api--add-a-revision-handle">Roadmap</a>. The algorithm is described <a href="#router-internals">under the hood</a>.</p> - - <h3>Path templates and match order</h3> - <p>A route's <code>path</code> and a redirect's <code>from</code> accept literal segments and named parameters. A route at <code>/:slug/p</code> matches <code>/summer/p</code> with <code>params.slug = "summer"</code>. Redirects are checked before routes, so a redirect overrides a route at the same path. Within each group, exact paths win over templates. Two entries with the same path, or two templates that can match the same URL, make <code>matchRoute</code> throw. <code>deco schema</code> reports the same conflict, so run it in CI on every commit, including the content commits Studio makes. Content published without that check can still make <code>matchRoute</code> throw at request time, so catch the error and return a 500 rather than crash (removing the throw is on the <a href="#roadmap-api--add-a-splat-to-matchroute-and-stop-request-time-throws">Roadmap</a>). Serve framework endpoints, API routes and static assets from their own routes, so they never reach this catch-all.</p> - - <h3>Linking and listing</h3> - <p>Because the URL is a field, linking to an entry is reading it, and listing a routable type gives you an index for free:</p> -<pre class="code-block"><code class="language-typescript">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> - -const match = matchRoute("/blog/hello-world", { routes: posts }); -// { kind: "match", entry: { __resolveType: "post", name: "Hello, world", path: "/blog/hello-world", … }, params: {} }</code></pre> -</section> - -<section id="standalone" data-nav="API reference" class="doc-section" aria-labelledby="standalone-title"> - <p class="eyebrow">Reference</p><h2 id="standalone-title">API reference</h2> - <p>Everything on this page is exported from <code>@decocms/blocks</code>. The built-in matchers (<code>always</code>, <code>never</code>, <code>date</code>) are in <code>@decocms/blocks/matchers</code>.</p> - - <h3><code>createCMS(config)</code></h3> - <p><code>createCMS</code> returns a <strong>CMS</strong>: your block map plus your content. Create it once, at module scope; it returns the same instance for the same configuration (see <a href="#standalone--one-instance-per-process">One instance per process</a>). For each request, ask it for a <strong>client</strong>: <code>forRelease()</code> for visitors, <code>forDraft(pointer)</code> for a preview. A client loads one revision on first use and reads only that revision afterwards, so everything in one response agrees. Every later call, <code>list</code> or <code>resolve</code>, reuses that content, so a page's blocks can stream as separate calls; the next request's client sees the next revision, so don't share a client across requests. A client also remembers what it has already resolved: an entry referenced three times runs its function once per client. That memory is tied to the client and to the block map object (results are kept per function of the map you passed), so a map rebuilt inside your handler starts with an empty cache; build the map once at module scope. Clients are cheap to create, because the CMS caches the content.</p> -<pre class="code-block"><code class="language-typescript">function createCMS(config: { - blocks: Blocks; - content: Snapshot | Loader; // the content module (.deco/blocks.gen.ts), or any Loader - token?: string; // hosted releases over content: remoteLoader(content, { token, interval }) - interval?: number; // ms between release checks; default DECO_CONTENT_INTERVAL or 60_000; minimum 60_000 -}): CMS; - -interface CMS { - forRelease(): Client; // a client reading the current release - forDraft(pointer: string): Client; // a client reading the draft a pointer names; an invalid pointer behaves like the release - update(): Promise<{ updated: boolean }>; // check for a new release now (manual refresh); checks also run on their own -} - -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[]>>; -}</code></pre> - <p><code>content</code> takes the content module or a <code>Loader</code>; <code>token</code> and <code>interval</code> add hosted releases. See <a href="#content--configuring-content">Configuring content</a>. The <a href="#model--the-rule">rule from Blocks</a> is how a client resolves.</p> - - <h3>Loaders</h3> - <p>One interface. <code>load()</code> with no argument is production; <code>load(pointer)</code> is a draft. <code>update()</code> checks for newer content. Which one to use, and how to write your own, is in <a href="#content">Content and loaders</a>.</p> -<pre class="code-block"><code class="language-typescript">interface Loader { - load(pointer?: string | null): Promise<Snapshot>; // Snapshot = { revision, blocks }; see Types - update?(): Promise<{ updated: boolean }>; -}</code></pre> - <div class="table-wrap"><table> - <thead><tr><th>Content</th><th>Loads</th><th>Use it for</th></tr></thead> - <tbody> - <tr><td>A snapshot <code>{ revision, blocks }</code> (the content module)</td><td>The content that ships with the app, generated by <code>deco content</code>. Ignores the pointer: there are no drafts in it. No <code>update()</code>.</td><td>Development, tests, and the fallback in production</td></tr> - <tr><td><code>remoteLoader(fallback: Snapshot | Loader, { token, interval? })</code></td><td>The current release from the Deco API over the fallback, or the draft a pointer names. The token identifies the site; everything else comes from the Deco API.</td><td>Production</td></tr> - <tr><td>A <code>Loader</code> you write</td><td>Whatever your store returns (see <a href="#content--write-your-own-loader">Write your own loader</a>).</td><td>KV, a database, or your own API</td></tr> - </tbody> - </table></div> - <p><code>remoteLoader(content, { token })</code>: <code>load()</code> never waits on the network. It returns what's in memory, the newest release fetched or else the fallback's content, and <code>update()</code> checks for newer content in the background. See <a href="#loader-internals">how it stays current</a>. <code>load(pointer)</code> fetches the draft a pointer names and caches it by version. If the draft can't be fetched, the client serves what a normal request would get, never a mix of draft and published entries. See <a href="#preview">Preview</a>.</p> - <p><code>remoteLoader</code> checks for a new release on its own:</p> - <ul> - <li>every <code>interval</code>, which defaults to <code>DECO_CONTENT_INTERVAL</code> or 60 000 ms and is never below 60 000;</li> - <li>each check one interval, plus or minus up to 10 s, after the previous one (60 s ± 10 s by default), fetching only the revision hash;</li> - <li>when a check is due, at the next idle moment (see <a href="#loader-internals">Loader internals</a>). On Cloudflare Workers, which run no timers between requests, it runs after the response inside <code>ctx.waitUntil</code> (<code>ctx</code> is the Worker's execution context; <code>waitUntil</code> lets work finish after the response is sent), so you never call <code>update()</code> yourself.</li> - </ul> - <p><code>cms.update()</code> stays for a manual refresh, such as a webhook or an admin "refresh now" button; it refreshes only the server that runs it. It never throws. A loader you write that has <code>update()</code> gets the same idle schedule and <code>interval</code>. A snapshot, like the content module, never changes, and a loader without <code>update()</code> is always current by construction.</p> - <p>Caching, ETags, and fallbacks live inside the CMS, which any number of clients share. The client only ever sees <code>{ revision, blocks }</code>.</p> - <h3>One instance per process</h3> - <p><code>createCMS</code> and <code>remoteLoader</code> instances are process-wide singletons. Each is stored on <code>globalThis</code> under a <code>Symbol.for("decocms.blocks…")</code> key derived from its configuration (the site token and the content's identity: for the content module, the <code>.deco</code> 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 a package loaded twice, by two bundles or by a dev reload (hot module replacement, HMR), still shares one cache and one poller. 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. Tests can start clean:</p> -<pre class="code-block"><code class="language-typescript">function resetForTests(): void; // clears every stored instance</code></pre> - <h3>Draft pointers</h3> - <p>A pointer is a string, <code><host[:port]><path[?query]>@<version></code>, that names where a draft lives and which version it is. Studio makes them; <code>forDraft</code> reads them. Two helpers cover everything in between:</p> -<pre class="code-block"><code class="language-typescript">interface DraftPointer { - host: string; // host[:port] of the Deco API, e.g. "studio.decocms.com" - path: string; // starts with "/"; its query holds a token signed by Studio - version: string; // opaque, immutable: the branch head or ETag -} - -function parseDraftPointer(raw: string | null | undefined): DraftPointer | null; -function formatDraftPointer(pointer: DraftPointer): string;</code></pre> - <p><code>parseDraftPointer</code> is strict and returns <code>null</code> on anything unexpected: a scheme, a stray <code>@</code>, an unrooted path, an odd character in the host or version. It's what <code>forDraft</code> 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 <code>version</code>, or to reject a pointer before storing it in a cookie. <code>formatDraftPointer</code> is the inverse, for building one from parts, as a mobile app might from a deep link.</p> - <p>For websites, two more helpers do the handshake, so the parameter and cookie names never appear in app code:</p> -<pre class="code-block"><code class="language-typescript">function draftPointer(request: Request | { url: string; headers: Headers }): string | null; -// ?__draft= from the URL first, then the deco-draft cookie. Null when neither is present. - -function draftCookie(request: Request | { url: string; headers: Headers }): string | null; -// When the URL carries ?__draft=, the Set-Cookie value that stores it (HttpOnly, SameSite=Lax, Path=/), -// or one that expires the cookie when the value is empty. Null on every other request. - -const DRAFT_COOKIE = "deco-draft"; // for frameworks whose cookie API has no Request, e.g. Next.js cookies()</code></pre> - <p>Both take anything with a <code>url</code> and <code>headers</code>, so they work with a fetch <code>Request</code>, a Next.js <code>NextRequest</code>, or a framework's request wrapper. <code>DRAFT_COOKIE</code> is for the one place that has no request at all: reading the cookie in a Next.js Server Component.</p> -<pre class="code-block"><code class="language-typescript">const pointer = parseDraftPointer(cookie); -if (pointer) console.log(`previewing ${pointer.version} from ${pointer.host}`); - -// the path is opaque: copy it, don't build it -formatDraftPointer({ host: "studio.decocms.com", path: "/api/acme/decofile/store/main?token=…", version: "9f3c1a" }); -// "studio.decocms.com/api/acme/decofile/store/main?token=…@9f3c1a"</code></pre> - - <h3><code>client.resolve(target, options?)</code></h3> - <p>Loads the content once and applies the <a href="#model--the-rule">registry rule</a> to the target: saved entries expand, and each function the target names in your block map runs with its resolved inputs. With <code>{ run: false }</code> it reads without running: entries expand, but no function runs.</p> - <div class="table-wrap"><table> - <thead><tr><th>Call</th><th>Returns</th></tr></thead> - <tbody> - <tr><td><code>resolve("SummerSEO")</code></td><td>The result of running the entry's function: for <code>SummerSEO</code>, <code>seo({ title: "Sunny!", description: "Light layers for long days." })</code></td></tr> - <tr><td><code>resolve("SummerSEO", { run: false })</code></td><td>The saved entry, with references expanded and nothing run</td></tr> - <tr><td><code>resolve({ __resolveType: "seo", title: "Sale" })</code></td><td>The result for an inline block: <code>seo({ title: "Sale" })</code></td></tr> - <tr><td><code>resolve(page.sections)</code></td><td>An array of results, one per block in the list</td></tr> - <tr><td><code>resolve({ title: "Store" })</code></td><td>The same object: it contains no blocks</td></tr> - </tbody> - </table></div> - <p>A string target is always an entry 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. <code>T</code> describes the result you expect, because an entry name can't carry a static type; it isn't validated at runtime.</p> - - <h3><code>client.list(type, options?)</code></h3> - <p>Every saved entry whose <code>__resolveType</code> is <code>type</code> or an <a href="#adoption">alias</a> of it (another key for the same type, used when renaming). By default nothing runs: each entry comes back as <code>resolve(name, { run: false })</code> would return it. With <code>run: true</code> each entry is resolved, so every block inside it must name a function in your block map, or it fails with <code>UNKNOWN_BLOCK</code>.</p> -<pre class="code-block"><code class="language-typescript">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, -});</code></pre> - <p>Filters run in memory over the loaded content map, the cost of content arriving as the whole map at once (see <a href="#content">Content and loaders</a>). Sites with tens of thousands of entries of one type should keep an index elsewhere.</p> - - <h3><code>matchRoute(url, items)</code></h3> - <p>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 <a href="#routing">Routing</a>.</p> -<pre class="code-block"><code class="language-typescript">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 } - | { kind: "not-found" };</code></pre> - <p>Redirects win over routes, exact paths win over templates, and two entries that can match the same URL throw. Entries are compiled into a segment trie, cached per <code>routes</code> 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 <a href="#router-internals">how it matches</a>.</p> - - <h3>Types</h3> -<pre class="code-block"><code class="language-typescript">type Block = { __resolveType: string; [input: string]: unknown }; -type VariantOf<T> = T | Block; // T, or a block (usually a multivariate) that resolves to one - -// A block map: what blocks.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 - -interface Loader { - load(pointer?: string | null): Promise<Snapshot>; - update?(): Promise<{ updated: boolean }>; -} - -interface DraftPointer { host: string; path: string; version: string } - -interface Route { name: string; path: string } // extend it to give a type a URL - -// The built-in content types; override them in Content with an interface that extends them. -interface Page extends Route { seo: Block; sections: VariantOf<Array<VariantOf<Block>>> } -interface Redirect { from: string; to: string; permanent: boolean } - -type Match<T> = - | { kind: "match"; entry: T; params: Record<string, string> } - | { kind: "redirect"; location: string; status: 301 | 302 } - | { 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 -}</code></pre> - - <h3>Errors</h3> - <p>Client calls never throw: <code>resolve</code> and <code>list</code> return <code>[value, null]</code> or <code>[null, error]</code>. The one exception in the SDK is <code>matchRoute</code>, which throws when two entries can match the same URL. That's a content bug <code>deco schema</code> also reports, so run it in CI on every commit, including Studio's (see <a href="#schema--keep-the-schema-compatible">Keep the schema compatible</a>), and catch the throw in your handler (see <a href="#routing--path-templates-and-match-order">Path templates and match order</a>). Check the error, not the value, because a block function can legitimately return <code>null</code>.</p> - <div class="table-wrap"><table> - <thead><tr><th>Code</th><th>When</th></tr></thead> - <tbody> - <tr><td><code>NOT_FOUND</code></td><td>A string target or a reference names an entry that doesn't exist.</td></tr> - <tr><td><code>UNKNOWN_BLOCK</code></td><td>A name is neither a saved entry nor a type in <code>blocks</code>.</td></tr> - <tr><td><code>CYCLE</code></td><td>References lead back to an entry that's already being expanded.</td></tr> - <tr><td><code>BLOCK_FAILED</code></td><td>A block function threw. The original error is in <code>cause</code>.</td></tr> - <tr><td><code>LOADER_FAILED</code></td><td>The loader couldn't load the content. The original error is in <code>cause</code>.</td></tr> - </tbody> - </table></div> - <p>A <code>CMSError</code> has a <code>code</code>, a <code>message</code>, a <code>path</code> (where in the tree it happened, such as <code>["sections", 2, "product"]</code>), and an optional <code>cause</code>. Log it on the server, and show visitors a generic message.</p> -</section> - -<section id="nextjs" data-nav="Next.js App Router" class="doc-section"> - <p class="eyebrow">Framework guides</p><h2>Next.js App Router</h2> - <p>In this guide, you'll render CMS pages with Server Components, stream each of a page's blocks under its own Suspense boundary, and generate metadata from the page's SEO block.</p> - <p>Each of a page's <a href="#model--resolving-a-page-step-by-step">blocks</a> resolves on its own, so a slow block never holds up the rest. Editors also choose page URLs in Studio, so your app can't know its routes ahead of time. Instead, one catch-all route receives every URL, asks <code>matchRoute</code> which page or redirect owns it, and renders that page's blocks.</p> - <p><strong>Prerequisites:</strong> an App Router app (the SDK reads no files, so the Node and Edge runtimes both work), with a root layout; <code>npm install @decocms/blocks server-only</code> and the CLI from <a href="#quickstart">Quickstart</a>; and the <a href="#routing--the-example-used-here-and-in-the-guides">example project</a>: <code>src/model.ts</code> plus the entries in <code>.deco/blocks</code> at the project root.</p> - - <h3>1. Add the components</h3> - <p><code>Product</code> is the component the <code>product</code> block renders. <code>CartButton</code> is a Client Component with state, there to show that interactive components keep working inside blocks the CMS renders on the server. Neither knows about the CMS.</p> -<figure class="code-example"><figcaption>src/CartButton.tsx</figcaption><pre><code class="language-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> - ) -}</code></pre></figure> -<figure class="code-example"><figcaption>src/Product.tsx</figcaption><pre><code class="language-tsx">import CartButton from './CartButton' -import type { ProductProps } from './model' - -const money = (props: ProductProps) => - new Intl.NumberFormat('en-US', { style: 'currency', currency: props.currency }).format(props.price) - -export default function Product(props: ProductProps) { - return ( - <section id={props.id}> - <h2>{props.name}</h2> - <p>{money(props)}</p> - <CartButton /> - </section> - ) -}</code></pre></figure> - - <h3>2. Create the CMS</h3> - <p>The <code>product</code> block returns JSX. Next renders it as a Server Component, and <code>CartButton</code> stays a Client Component.</p> -<figure class="code-example"><figcaption>src/blocks.tsx</figcaption><pre><code class="language-tsx">import type { Blocks } from '@decocms/blocks' -import Product from './Product' -import type { ProductProps, SEO, StoredPage } from './model' - -export type Content = { page: StoredPage } - -export default { - seo: (input: SEO) => input, - product: (input: ProductProps) => <Product {...input} />, -} satisfies Blocks</code></pre></figure> - <p><code>seo</code> just returns its input. Registering it lets editors save SEO as a reusable entry with its own form. <code>page</code> and <code>redirect</code> are built-in content types (see <a href="#schema--content-that-is-only-data">Content that is only data</a>). The <code>Content</code> export overrides <code>page</code> with the narrower <code>StoredPage</code> from <a href="#routing--the-example-used-here-and-in-the-guides">the example</a>, so Studio only saves pages this guide's code can read.</p> - <p><code>cms.ts</code> imports the <a href="#content--the-content-module">content module</a>, <code>.deco/blocks.gen.ts</code>: the file the CLI command <code>deco content</code> generates from your JSON files, exporting a snapshot <code>{ revision, blocks }</code> (one fixed copy of all your saved entries). Step 3 sets up that command; until it has run once, TypeScript can't find the import.</p> -<figure class="code-example"><figcaption>src/cms.ts</figcaption><pre><code class="language-typescript">import { createCMS } from '@decocms/blocks' -import content from '../.deco/blocks.gen' -import blocks from './blocks' - -// Development (no token) reads the content module. Production reads releases from the Deco API -// and falls back to it. -export const cms = createCMS({ blocks, content, token: process.env.DECO_SITE_TOKEN })</code></pre></figure> - <p>The content options and their fallbacks are in <a href="#content--configuring-content">Configuring content</a>.</p> -<figure class="code-example"><figcaption>src/client.server.ts</figcaption><pre><code class="language-typescript">import 'server-only' -import { cookies } from 'next/headers' -import { DRAFT_COOKIE } from '@decocms/blocks' -import { cms } from './cms' - -// The client for this request: a draft when the cookie holds a valid pointer, production otherwise. -// In development (no token), drafts and releases read the same content. -export async function client() { - const pointer = (await cookies()).get(DRAFT_COOKIE)?.value - return pointer ? cms.forDraft(pointer) : cms.forRelease() -}</code></pre></figure> - <p><code>import 'server-only'</code> makes the build fail if a Client Component imports this file, which keeps the token and the CMS out of the browser.</p> - - <h3>3. Generate the schema and content</h3> - <p>Two CLI commands generate the files the app and Studio need (see <a href="#schema--the-two-cli-commands">The two CLI commands</a>): <code>deco schema</code> writes <code>.deco/schema.json</code>, a form for each block type in Studio, from the block map it finds in <code>src/blocks.tsx</code>, and <code>deco content</code> writes the content module <code>cms.ts</code> imports. Run both before the dev server and the build:</p> -<figure class="code-example"><figcaption>package.json</figcaption><pre><code class="language-json">{ - "scripts": { - "predev": "deco schema && deco content", - "prebuild": "deco schema && deco content" - } -}</code></pre></figure> - <p>Commit <code>.deco/schema.json</code> and gitignore <code>.deco/blocks.gen.ts</code>. What reloads on its own is in <a href="#content--the-content-module">The content module</a>; while you work, you can leave <code>deco content --watch</code> running beside <code>next dev</code>.</p> - - <h3>4. Keep preview working across pages</h3> - <p>Studio opens your site with the <a href="#preview">draft pointer</a> in the URL. <code>proxy.ts</code> (called <code>middleware.ts</code> before Next.js 16) runs before every request and turns that pointer into a cookie, so the editor stays in preview while clicking around. <code>client()</code> reads the cookie.</p> - -<figure class="code-example"><figcaption>src/proxy.ts</figcaption><pre><code class="language-typescript">import { NextResponse, type NextRequest } from 'next/server' -import { draftCookie } from '@decocms/blocks' - -export function proxy(request: NextRequest) { - const response = NextResponse.next() - const cookie = draftCookie(request) // only set on the request Studio opens; expiring when leaving preview - if (cookie) response.headers.append('Set-Cookie', cookie) - return response -}</code></pre></figure> - - <h3>5. Match the URL to a page</h3> - <p><code>openPage</code> lists pages and redirects, matches the pathname, and starts resolving every block without waiting for any of them. Listing is cheap: the CMS holds the content in memory. Block keys include the pathname, so React mounts fresh Suspense boundaries when you navigate.</p> - <p><code>generateMetadata</code> and the page component both need the page. Wrap the lookup in React's <code>cache</code> so they share one client, one match, and the same block promises.</p> -<figure class="code-example"><figcaption>src/open-page.server.ts</figcaption><pre><code class="language-typescript">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 { SEO, 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) permanentRedirect(match.location) - redirect(match.location) - } - - const page = match.entry - return { - blocks: page.sections.map((block, index) => ({ - key: `${pathname}:${index}`, - result: c.resolve<ReactNode>(block), - })), - seo: c.resolve<SEO>(page.seo), - } -}) - -export const pathnameFor = (segments: string[] = []) => - '/' + segments.map(encodeURIComponent).join('/')</code></pre></figure> - - <h3>6. Render the catch-all route</h3> - <p>Each block is a promise of a <code>Result</code>, <code>[value, error]</code>. <code>BlockSlot</code> awaits it inside its own Suspense boundary and shows a fallback if that one block failed.</p> -<figure class="code-example"><figcaption>src/app/[[...path]]/page.tsx</figcaption><pre><code class="language-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 } -} - -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 -} - -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> - ) -}</code></pre></figure> - - <h3>7. List another content type</h3> - <p>Add <code>src/post.ts</code> from <a href="#content-types--your-own-routable-types">Your own routable types</a>, change the <code>Content</code> export in <code>src/blocks.tsx</code> to <code>{ page: StoredPage; post: Post }</code>, and save an entry such as <code>.deco/blocks/HelloWorld.json</code> (a new file, so run <code>deco content</code> again unless <code>--watch</code> is running). Then route posts in <code>openPage</code>; without this, every post link is a 404. List posts next to pages, and return the post as is when it wins, since a post is data with nothing to resolve:</p> -<figure class="code-example"><figcaption>src/open-page.server.ts (changes)</figcaption><pre><code class="language-typescript">import type { Result } from '@decocms/blocks' -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 (match.entry.__resolveType === 'post') { - const post = match.entry as Post - const seo: Result<SEO> = [{ title: post.name, description: '' }, null] - return { post, blocks: [], seo: Promise.resolve(seo) } - } - const page = match.entry as StoredPage - return { post: null, blocks: …, seo: … } // blocks and seo exactly as before</code></pre></figure> - <p>In <code>Page</code>, render the post when <code>page.post</code> is set (for example <code><article><h1>{page.post.name}</h1>…</article></code>), and the page's blocks otherwise. <code>generateMetadata</code> keeps working unchanged.</p> - <p>A blog index needs no page entry. List the <code>post</code> type; each entry carries its own <code>path</code>:</p> -<figure class="code-example"><figcaption>src/app/blog/page.tsx</figcaption><pre><code class="language-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> - ) -}</code></pre></figure> - <h3>How it works</h3> - <ul> - <li><strong>Preview is a different client.</strong> <code>client()</code> is <code>pointer ? cms.forDraft(pointer) : cms.forRelease()</code>, and the pointer only exists while the cookie does. Nothing else in the guide knows about preview.</li> - <li><strong><code>openPage</code> takes a pathname.</strong> React's <code>cache</code> 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 <code>next/headers</code>, like any other server code.</li> - <li><strong>Metadata waits only for SEO.</strong> Next can stream metadata separately. A metadata error after streaming starts can't change headers that were already sent.</li> - <li><strong>Redirect status codes.</strong> Next's navigation helpers send 308 and 307. For an exact 301 or 302, match redirects in <code>proxy.ts</code> with <code>matchRoute</code> and return <code>NextResponse.redirect(url, match.status)</code>. Proxy makes its own client, so if a new release lands between the two, the redirect check and the page can briefly see different versions of the content.</li> - </ul> - <p>These files use the default App Router configuration. <a href="https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents">Cache Components</a> adds caching and Suspense requirements of its own. See the Next.js docs on <a href="https://nextjs.org/docs/app/api-reference/functions/generate-metadata">metadata</a> and <a href="https://nextjs.org/docs/app/api-reference/file-conventions/proxy">proxy</a>.</p> -</section> - -<section id="tanstack-data" data-nav="TanStack Start · descriptors" class="doc-section"> - <p class="eyebrow">Framework guides</p><h2>TanStack Start with data descriptors</h2> - <p>There are two ways to render with TanStack Start: this guide, which works with any React setup, and the experimental <a href="#tanstack-rsc">RSC guide</a>; <a href="#rendering--which-mode-to-use">Which mode to use</a> compares them. Here, block functions don't return JSX. They return a descriptor, a plain object such as <code>{ component: 'product', props }</code> (see <a href="#rendering">Rendering</a>). Start sends a route's loader data to the browser as serialized data, and JSX can't make that trip, but a descriptor can. (Here "loader" is TanStack Router's route loader, the function that fetches a route's data. It isn't a content loader from <a href="#content">Content and loaders</a>.) In the browser, a view registry (<code>const views = { product: Product }</code>) maps each name to a component. Start streams each block's descriptor as it resolves, renders the views on the server, and hydrates them in the browser. It deploys to Cloudflare Workers.</p> - <p>Editors choose page URLs in Studio, so your app can't know its routes ahead of time. One catch-all route receives every URL, asks <code>matchRoute</code> which page or redirect owns it, and renders that page's <a href="#model--resolving-a-page-step-by-step">blocks</a>.</p> - <p><strong>Prerequisites:</strong> a TanStack Start app (see the <a href="https://tanstack.com/start/latest/docs/framework/react/quick-start">Start quick start</a>); <code>npm install @decocms/blocks zod</code> and <code>npm install -D @cloudflare/vite-plugin wrangler</code>, plus the CLI from <a href="#quickstart">Quickstart</a>; the <a href="#routing--the-example-used-here-and-in-the-guides">example project</a>; and <code>Product</code> and <code>CartButton</code> from step 1 of the <a href="#nextjs">Next.js guide</a>. Enable <code>resolveJsonModule</code> in <code>tsconfig.json</code> so TypeScript accepts the JSON imports in <code>.deco/blocks.gen.ts</code>, the content module.</p> - - <h3>1. Return descriptors from blocks</h3> -<figure class="code-example"><figcaption>src/blocks.ts</figcaption><pre><code class="language-typescript">import type { Blocks } from '@decocms/blocks' -import type { ProductDescriptor, ProductProps, SEO, StoredPage } from './model' - -export type Content = { page: StoredPage } - -export default { - seo: (input: SEO) => input, - product: (input: ProductProps): ProductDescriptor => ({ component: 'product', props: input }), -} satisfies Blocks</code></pre></figure> - <p>As in the Next.js guide, <code>Content</code> overrides the built-in <code>page</code> with the narrower <code>StoredPage</code>. Generate the schema from the map file (<code>deco schema</code> finds <code>src/blocks.ts</code>), and the content module (<code>.deco/blocks.gen.ts</code>, which <code>cms.ts</code> imports) from <code>.deco/blocks</code>, before the dev server and the build (see <a href="#schema--the-two-cli-commands">The two CLI commands</a>):</p> -<figure class="code-example"><figcaption>package.json</figcaption><pre><code class="language-json">{ - "scripts": { - "predev": "deco schema && deco content", - "prebuild": "deco schema && deco content" - } -}</code></pre></figure> - <p>Commit <code>.deco/schema.json</code> and gitignore <code>.deco/blocks.gen.ts</code> (see <a href="#content--the-content-module">The content module</a>).</p> - - <h3>2. Create the CMS</h3> - <p><code>DECO_SITE_TOKEN</code> is your site's API key for the Deco API. On Workers, environment variables come from <code>env</code> in <code>cloudflare:workers</code>; locally, <code>wrangler dev</code> reads them from a <code>.dev.vars</code> file you keep out of Git. In production, set it with <code>npx wrangler secret put DECO_SITE_TOKEN</code>.</p> -<figure class="code-example"><figcaption>src/cms.ts</figcaption><pre><code class="language-typescript">import { createCMS, draftPointer } from '@decocms/blocks' -import { env } from 'cloudflare:workers' -import content from '../.deco/blocks.gen' -import blocks from './blocks' - -// Production reads releases from the Deco API and falls back to the content module. -export const cms = createCMS({ blocks, content, token: env.DECO_SITE_TOKEN }) - -// The client for this request: the draft it points at, or the current release. -export const client = (request: Request) => { - const pointer = draftPointer(request) - return pointer ? cms.forDraft(pointer) : cms.forRelease() -}</code></pre></figure> - <p>The content module is ordinary imports, so Vite bundles it into the Worker like any other module; nothing here is specific to Workers. Its revision is the same content hash the Deco API computes, so a new Worker instance whose build already holds the current release skips downloading it (see <a href="#loader-internals">Loader internals</a>). Workers run no timers between requests, so when a release check is due, the SDK runs it after the response inside <code>ctx.waitUntil</code> (<code>ctx</code> is the Worker's execution context; <code>waitUntil</code> lets work finish after the response is sent): you don't call <code>cms.update()</code>.</p> - <p><strong>Large content on Workers.</strong> When you pass both <code>content</code> and <code>token</code>, the content module stays in memory as the fallback, alongside the release the loader fetched. Once you publish, the live release and the fallback are different revisions, so your content is in memory more than once. A Worker isolate has a 128 MB memory limit, and the bundled content also counts toward the Worker's script size limit. For a few hundred kilobytes of JSON this doesn't matter; for several megabytes, it can. On large sites, pass a <a href="#content--write-your-own-loader">loader that reads from KV</a> as <code>content</code> instead of the content module. No copy is bundled into the Worker, and none is held in memory just because the module was imported: the fallback is read from KV only when it's needed. Once read, it's held in memory like any other content, so this removes the bundled copy, not every duplicate:</p> -<figure class="code-example"><figcaption>src/cms.ts (large sites)</figcaption><pre><code class="language-typescript">import { env } from "cloudflare:workers" -// kvContent: the KV loader from "Write your own loader" - -// The fallback comes from KV, not from the bundle -export const cms = createCMS({ blocks, content: kvContent, token: env.DECO_SITE_TOKEN })</code></pre></figure> - <p>The trade-off: the fallback is now only as good as the KV key. Your deploy pipeline must write the current content to it before the new Worker serves traffic. Until a new isolate's first release check (about a minute), and whenever the Deco API can't be reached, reads come from the KV key. So if the key is missing, every new isolate fails its first reads with <code>LOADER_FAILED</code> until it has fetched a release, and keeps failing if it can't reach the API. A first-party <code>kvLoader</code> helper is planned (see the <a href="#roadmap-api--document-and-support-kv-only-content-for-large-workers-sites">Roadmap</a>).</p> - - <h3>3. Match the URL to a page</h3> - <p>This helper is shared by both TanStack guides. <code>T</code> is what your <code>product</code> block returns: <code>ProductDescriptor</code> here, <code>ReactNode</code> in the <a href="#tanstack-rsc">RSC guide</a>, which reuses this file unchanged. The server function hands the incoming request to <code>client()</code>, and <code>draftPointer</code> finds the pointer in the URL or in the cookie from step 6. Each block promise resolves to the value, or to <code>null</code> if the block failed, so no internal error details reach the browser.</p> -<figure class="code-example"><figcaption>src/open-page.server.ts</figcaption><pre><code class="language-typescript">import { notFound, redirect } from '@tanstack/react-router' -import { matchRoute, type Redirect } from '@decocms/blocks' -import { client } from './cms' -import type { SEO, StoredPage } from './model' - -export async function openPage<T>(href: string, request: Request) { - const c = 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 - const blocks = page.sections.map((block, index) => ({ - key: `${href}:${index}`, - value: c.resolve<T>(block).then(([value, blockError]) => { - if (blockError) console.error(blockError) - return value - }), - })) - - // Every block has started before SEO is awaited. - const [seo, seoError] = await c.resolve<SEO>(page.seo) - if (seoError) throw seoError - return { seo, blocks } -}</code></pre></figure> - - <h3>4. Expose it through a server function</h3> - <p>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, your token 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 <code>/</code>.</p> -<figure class="code-example"><figcaption>src/page.functions.ts</figcaption><pre><code class="language-typescript">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 { ProductDescriptor } from './model' - -export const loadPage = createServerFn({ method: 'GET' }) - .inputValidator(z.object({ href: z.string().startsWith('/') })) - .handler(({ data }) => openPage<ProductDescriptor>(data.href, getRequest()))</code></pre></figure> - - <h3>5. Render the catch-all route</h3> -<figure class="code-example"><figcaption>src/routes/$.tsx</figcaption><pre><code class="language-tsx">import { Suspense } from 'react' -import { Await, createFileRoute } from '@tanstack/react-router' -import Product from '../Product' -import { loadPage } from '../page.functions' - -const views = { product: Product } - -export const Route = createFileRoute('/$')({ - loader: ({ location }) => loadPage({ data: { href: location.pathname + location.searchStr } }), - head: ({ loaderData }) => ({ - meta: loaderData - ? [{ 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}> - {(data) => { - if (!data) return <p role="status">This content is temporarily unavailable.</p> - const View = views[data.component] - return <View {...data.props} /> - }} - </Await> - </Suspense> - ))} - </main> - ) -}</code></pre></figure> - <div class="callout"><p><strong>Keep block promises unawaited.</strong> 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 <code>Await</code> renders it. Awaiting them in <code>openPage</code> would make the page wait for its slowest block.</p></div> - <h3>How it works</h3> - <ul> - <li><strong>The server function becomes an RPC stub.</strong> Import it statically from the route, so Start can replace it with a fetch call in the client build. See <a href="https://tanstack.com/start/latest/docs/framework/react/server-functions">Start server functions</a>.</li> - <li><strong>What ships to the browser.</strong> The view registry and its views, and the descriptors as data. The CMS, the block map and the token stay on the server.</li> - <li><strong>Reading the request.</strong> A block that needs headers or cookies calls <code>getRequest()</code> from <code>@tanstack/react-start/server</code>, like any other server code.</li> - <li><strong>Drafts need no authorization code.</strong> The pointer carries a token signed by Studio, and the Deco API checks it (see <a href="#preview--who-may-preview">Who may preview</a>).</li> - </ul> - - <h3>6. Keep preview across navigation</h3> - <p>Studio opens your site with the <a href="#preview">draft pointer</a> in the URL, but only on the first page. Append the draft cookie to the document response so every link the editor clicks stays in preview. Start's request middleware runs around every request (see <a href="#preview--keep-the-draft-across-navigation">Keep the draft across navigation</a>):</p> -<figure class="code-example"><figcaption>src/start.ts</figcaption><pre><code class="language-typescript">import { createMiddleware, createStart } from '@tanstack/react-start' -import { draftCookie } from '@decocms/blocks' - -const draftCookieMiddleware = createMiddleware().server(async ({ request, next }) => { - const result = await next() - const cookie = draftCookie(request) // only set on the request Studio opens; expiring when leaving preview - if (cookie) result.response.headers.append('Set-Cookie', cookie) - return result -}) - -export const startInstance = createStart(() => ({ requestMiddleware: [draftCookieMiddleware] }))</code></pre></figure> - - <h3>7. Configure Cloudflare Workers</h3> -<figure class="code-example"><figcaption>vite.config.ts</figcaption><pre><code class="language-typescript">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()], -})</code></pre></figure> -<figure class="code-example"><figcaption>wrangler.jsonc</figcaption><pre><code class="language-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"] -}</code></pre></figure> - <p><code>nodejs_compat</code> enables the Node APIs Start relies on, such as <code>AsyncLocalStorage</code>. <code>no_handle_cross_request_promise_resolution</code> keeps Workers' older behavior for a promise created in one request and awaited in another, which this setup relies on: the content fetch is shared by every request. See Cloudflare's <a href="https://developers.cloudflare.com/workers/configuration/compatibility-flags/">compatibility flags</a>.</p> - - <h3>Deploying checklist</h3> - <ul> - <li>Run <code>deco schema && deco content</code> before each build (the <code>prebuild</code> script).</li> - <li>Pass <code>token</code> to <code>createCMS</code> so releases arrive without a deploy.</li> - <li>Set the token with <code>npx wrangler secret put DECO_SITE_TOKEN</code>.</li> - <li>Expect each new Worker instance (Cloudflare calls it an isolate) to serve the build's content module until its first check, or the content in your KV key if you pass a KV loader as <code>content</code> (write the key before the deploy). Checks run after responses, about once a minute.</li> - <li>If your content is several megabytes, use a KV loader instead of the bundled content module, and write the KV key before the new Worker takes traffic (see <a href="#tanstack-data--2-create-the-cms">Create the CMS</a>).</li> - <li>Keep both compatibility flags.</li> - <li>The <a href="#nextjs">Next.js guide</a> targets Node, not Workers.</li> - </ul> - <p class="small muted">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.</p> -</section> - -<section id="tanstack-rsc" data-nav="TanStack Start · RSC" class="doc-section"> - <p class="eyebrow">Framework guides</p><h2>TanStack Start with React Server Components</h2> - <div class="callout warning"><p><strong>Experimental.</strong> TanStack Start's RSC support is experimental and needs the extra Vite plugin configured in step 4.</p></div> - <p>This guide reuses the <a href="#tanstack-data">data guide</a>'s content, CMS and <code>openPage</code>, but the <code>product</code> block returns JSX instead of a descriptor. The server renders it as React Server Components and streams the result in React's wire format (Flight). Because the browser receives rendered output, it needs no view registry, and view code used only on the server never ships.</p> - - <h3>1. Swap the product block</h3> - <p>Replace <code>src/blocks.ts</code> with the <a href="#nextjs">Next.js guide</a>'s <code>src/blocks.tsx</code>. Keep <code>src/cms.ts</code>, <code>src/open-page.server.ts</code> and <code>src/start.ts</code> (the preview cookie) from the data guide as they are. <code>deco schema</code> finds <code>src/blocks.tsx</code> on its own, so the scripts don't change.</p> - - <h3>2. Render blocks on the server</h3> - <p>Replace <code>src/page.functions.ts</code> with this file. It's <code>.tsx</code> now, because it contains JSX.</p> -<figure class="code-example"><figcaption>src/page.functions.tsx</figcaption><pre><code class="language-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<ReactNode | null> }) { - return (await value) ?? <p role="status">This content is temporarily unavailable.</p> -} - -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 } - })</code></pre></figure> - - <h3>3. Render the route</h3> -<figure class="code-example"><figcaption>src/routes/$.tsx</figcaption><pre><code class="language-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 - ? [{ 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>, -})</code></pre></figure> - <p><code>renderServerComponent</code> serializes the tree in Flight. Client Components like <code>CartButton</code> travel as references the browser loads and hydrates. That only works with the RSC plugin from step 4; the <code>"use client"</code> 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 <code>structuralSharing: false</code>, because Query would otherwise try to diff the RSC payload as plain data. See <a href="https://tanstack.com/start/latest/docs/framework/react/server-components">Start Server Components</a>.</p> - - <h3>4. Configure Cloudflare Workers</h3> - <p>Server Components build as a separate Vite environment named <code>rsc</code>. Listing it under <code>childEnvironments</code> bundles it into the same Worker as server rendering, so one deploy serves both. Keep <code>wrangler.jsonc</code> from the data guide.</p> -<figure class="code-example"><figcaption>vite.config.ts</figcaption><pre><code class="language-typescript">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' } }, - }, -})</code></pre></figure> - <p class="small muted">Also requires <code>@vitejs/plugin-rsc</code> 0.5.30 and <code>react-server-dom-webpack</code> 19.2.7. If one repository builds both the data and the RSC version, give each its own Vite config: the RSC plugins change how every component is bundled.</p> -</section> - -<section id="releases" data-nav="Releases & deployment" class="doc-section" aria-labelledby="releases-title"> - <p class="eyebrow">Production</p><h2 id="releases-title">Releases and deployment</h2> - <p>Your code ships when you deploy. Your content can ship the same way, or on its own. This page covers how a content change reaches visitors, how each response stays consistent, and what happens when the network fails. It rests on one idea from <a href="#content">Content and loaders</a>: a revision identifies one exact content map. <code>deco content</code>, <code>remoteLoader</code> and the Deco API all compute it as the same hash of the whole map, so two copies of the content are the same exactly when their revisions match, and <code>remoteLoader</code> recognizes the build's content module as the current release and skips a download. A loader you write only has to change its revision whenever content changes (see <a href="#content--write-your-own-loader">Write your own loader</a>).</p> - <div class="table-wrap"><table> - <thead><tr><th>Content from</th><th>How a content change reaches visitors</th></tr></thead> - <tbody> - <tr><td>The content module</td><td>The content ships with the app: <code>.deco/blocks.gen.ts</code>, which <code>deco content</code> generates from your files, is bundled like any other import. On its own, changing content means a deploy (in development, editing a file hot-reloads). With a token, it's the fallback: what a server serves until it has fetched its first release.</td></tr> - <tr><td><code>content</code> plus <code>token</code> (<code>remoteLoader</code>)</td><td>The Deco API serves each commit of the production branch as a release that never changes. The loader keeps the current one in memory, with the content the build shipped with as the fallback. A content change reaches visitors without a deploy.</td></tr> - <tr><td>A <code>Loader</code> you write</td><td>Whatever your store does. Content reaches visitors when the loader returns a new revision; if it can go stale, implement <code>update()</code>, which the CMS calls once a minute at an idle moment (see <a href="#content--write-your-own-loader">Write your own loader</a>).</td></tr> - </tbody> - </table></div> - <p>A revision never changes once it exists: change any entry and you get a new revision. That makes a revision a safe cache key everywhere.</p> - - <h3>Publishing</h3> - <p>With the hosted Deco CMS, publishing is committing. A developer or an agent edits the entry files in your content directory (<code>.deco/blocks</code>, or the folder you give the CLI's <code>--root</code>) and pushes; an editor saves in Studio, which commits to the same repository. (If you load from your own store, see <a href="#content">Content and loaders</a>.) Either way, the production branch (the branch the hosted Deco CMS publishes from, usually <code>main</code>) moves.</p> - <p>Publishing takes seconds: the Deco API serves that commit as the current release as soon as it lands. Each server checks for a new release about once a minute, at an idle moment, so a publish is live everywhere within about a minute. Calling <code>cms.update()</code>, for example from a webhook, 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 (see <a href="#standalone--loaders">Loaders</a>). Requests never wait on this: <code>load()</code> always returns from memory, and the check runs in the background. Step by step: <a href="#loader-internals">under the hood</a>.</p> - <p>Because content and code share one history, a deploy and a content change are the same kind of event: a commit. Rolling content back is reverting one.</p> - <p>Publishing without a deploy means the code that's already running renders the new content. Nothing at runtime checks that the two fit: the Deco API serves each commit as it lands, and <code>remoteLoader</code> doesn't compare schemas. They're checked before anything merges instead; the rules and a CI workflow are in <a href="#schema--keep-the-schema-compatible">Keep the schema compatible</a>.</p> - - <h3>Fallback</h3> - <p>Until a server has fetched its first release (a new server, or one that has never reached the Deco API), <code>remoteLoader</code> serves its fallback's content. If the Deco API becomes unreachable later, the server keeps serving the newest release it already has. With the content module, that's the content the build shipped with, the content this deploy was tested against, so it's always there and always compatible with the code. If your <code>content</code> is a loader you wrote (see <a href="#content--write-your-own-loader">Write your own loader</a>), keeping those guarantees is up to you. <code>remoteLoader</code> always switches between whole revisions, never mixing entries from two of them, because a mix can break references or bring back deleted entries. The loader reports the revision it actually served.</p> - - <h3>One revision per response</h3> - <p>Because new content can arrive in the background at any moment, two reads a few milliseconds apart could see different revisions; a header from one and a footer from the other make an inconsistent page. A client prevents that: its first call picks a revision, and every later <code>list</code> and <code>resolve</code> on it, including concurrent ones, uses the same one. So make one client per request.</p> - <p>Follow-up requests from the browser may land on a newer revision. If data must match the HTML, render it in the same response. (A way to read and pin the served revision is on the <a href="#roadmap-api--add-a-revision-handle">Roadmap</a>.)</p> - <p>A revision only selects content; it isn't a secret and unlocks nothing. Unpublished drafts are protected by the signed token in the draft pointer (see <a href="#preview">Preview</a>).</p> - - <h3>Deploying on Workers</h3> - <p>Workers need nothing special, except on sites with several megabytes of content (see “Large content on Workers” under <a href="#tanstack-data--2-create-the-cms">Create the CMS</a>). The content module is bundled like any other import, and because Workers run no timers between requests, the SDK runs a due release check after the response inside <code>ctx.waitUntil</code> (<code>ctx</code> is the Worker's execution context; <code>waitUntil</code> lets work finish after the response is sent). You don't call <code>cms.update()</code>. The <a href="#tanstack-data--deploying-checklist">deploying checklist</a> in the TanStack Start guide lists every step.</p> -</section> - -<section id="operations" data-nav="Caching & observability" class="doc-section" aria-labelledby="operations-title"> - <p class="eyebrow">Production</p><h2 id="operations-title">Caching and observability</h2> - <p>A request passes through several caches. Deco Blocks manages the first two, the JavaScript runtime the third, and the last two are yours, and getting their keys wrong is how a site shows one customer's cart to another or serves last week's prices.</p> - <div class="table-wrap"><table> - <thead><tr><th>Layer</th><th>Lifetime</th><th>Your responsibility</th></tr></thead> - <tbody> - <tr><td>Content map</td><td>Cached in the CMS: releases by revision, indefinitely; drafts by version. One cache per process, even if the package is loaded twice (see <a href="#standalone--one-instance-per-process">One instance per process</a>). See <a href="#content">Content and loaders</a> and <a href="#loader-internals">how remoteLoader stays current</a>.</td><td>Nothing, unless you write a loader: then key by revision.</td></tr> - <tr><td>Resolved blocks</td><td>For one client. A client runs each block once and reuses the result for the rest of the request.</td><td>Make a client per request and resolve everything the response renders through it.</td></tr> - <tr><td>Dynamically imported modules</td><td>Block functions you load lazily with <code>import()</code> (see <a href="#manifest">Block maps</a>) 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.</td><td>Keep per-request state out of module globals.</td></tr> - <tr><td>Cross-request results</td><td>Anything you cache across requests yourself (rendered HTML at a CDN, a resolved page in KV). Your policy.</td><td>Key by revision, app version (new code can render the same content differently), inputs, and relevant request context. Define TTL and invalidation.</td></tr> - <tr><td>Upstream data</td><td>Responses from the APIs your blocks call (a commerce platform, a search service). Follows the upstream API.</td><td>Include tenant, locale, currency, or customer in the key. Never cache private data as public.</td></tr> - </tbody> - </table></div> - <p>A block type alone is never a cache key. Include every input that can change the result. A cache hit doesn't bypass authorization.</p> - <p>Block functions run whenever content is resolved: for a visitor, in a Studio preview, for an agent, possibly more than once. So they should only read. Put anything that changes state, like adding to a cart, in your own route handlers.</p> - - <h3>Tracing</h3> - <p>If you use <a href="https://opentelemetry.io/">OpenTelemetry</a>, make each request span the parent of block resolution spans and outbound HTTP spans. One way to get block spans is to wrap each function in your block map in a span named after its block type. Record the entry name (when the block came from a saved entry), the block type, duration, errors, and cache outcome, and keep credentials and customer data out of attributes. Recording the served revision needs the client to expose it, which is on the <a href="#roadmap-api--add-a-revision-handle">Roadmap</a>. Export telemetry asynchronously, so an unavailable collector never blocks rendering.</p> -</section> - -<section id="adoption" data-nav="Renames & migrations" class="doc-section" aria-labelledby="adoption-title"> - <p class="eyebrow">Production</p><h2 id="adoption-title">Renaming and migrating</h2> - <p>Saved content refers to your code by name: every block stores a type name in <code>__resolveType</code>, references store entry names, and inputs are stored by field name. Treat these names like database columns: once content uses them, changing one breaks that content. Renaming a type or removing an input affects every block that uses it, and renaming a saved entry breaks every reference to it (<code>NOT_FOUND</code>).</p> - <p>This page covers renaming safely, and bringing in content from a site built on Deco's earlier framework, where type names were file paths such as <code>site/sections/Product.tsx</code>. Moving source files is free; only names matter.</p> - - <h3>Rename a type with an alias</h3> - <p>An alias is another key pointing at the same thing: the same function in the block map, or the same type in <a href="#schema"><code>Content</code></a>. Content that stores the old name keeps working, and routing is unaffected because <code>matchRoute</code> looks at each entry's <code>path</code> field (see <a href="#content-types">Routes and redirects</a>), not its type name. Here, <a href="#content-types"><code>Page</code></a> is the built-in page type from <code>@decocms/blocks</code>; you declare only the legacy name, since <code>page</code> itself is built in:</p> -<figure class="code-example"><figcaption>blocks.ts</figcaption><pre><code class="language-typescript">import type { Blocks, Page } from "@decocms/blocks"; -import productCard from "./product-card"; - -export default { - "product-card": productCard, - "site/sections/Product.tsx": productCard, // the name legacy content stores -} satisfies Blocks; - -export type Content = { - "website/pages/Page.tsx": Page; // the built-in page type, legacy name; entries still route -};</code></pre></figure> - <p>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 <code>name</code>:</p> -<pre class="code-block"><code class="language-typescript">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;</code></pre> - <p><code>deco schema</code> rejects an alias that collides with an entry name.</p> - - <h3>Checklist for migrating existing content</h3> - <ol> - <li><strong>Keep existing names.</strong> If the content stores file-path type names, register them as they are or alias them.</li> - <li><strong>Check inputs.</strong> Regenerate the schema and validate saved content. Review new required fields, narrowed unions, and changed defaults.</li> - <li><strong>Migrate the content in one commit.</strong> A script reads the JSON in <code>.deco/blocks</code>, rewrites it, and writes it back. The commit is a new revision you can revert in one step. Keep fields the script doesn't recognize instead of deleting them, and report references to missing entries (<code>NOT_FOUND</code>) instead of dropping them.</li> - <li><strong>Test the integration.</strong> 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.</li> - <li><strong>Keep a rollback pair.</strong> Keep the previous build plus the content revision it was tested with. Rolling back content alone can't bring back code you removed.</li> - </ol> - <p>Test behavior, not just schemas. A function can keep its input type and still change its output, defaults, or upstream calls.</p> -</section> - -<section id="troubleshooting" data-nav="Troubleshooting" class="doc-section" aria-labelledby="troubleshooting-title"> - <p class="eyebrow">Production</p><h2 id="troubleshooting-title">Troubleshooting</h2> - <p>Most problems come down to three facts: which revision this server is serving, which entry was asked for, and which block type it names. Until the client exposes its revision (see the <a href="#roadmap-api--add-a-revision-handle">Roadmap</a>), build the loader yourself (<code>const loader = remoteLoader(content, { token })</code>), pass it as <code>createCMS</code>'s <code>content</code>, and log <code>(await loader.load()).revision</code>; that's the revision a new client would read right now. To see an entry with references expanded but no function run, call <code>client.resolve(target, { run: false })</code>. To see it exactly as stored, open <code>.deco/blocks/<name>.json</code>.</p> - <div class="table-wrap"><table> - <thead><tr><th>Symptom</th><th>What to check</th></tr></thead> - <tbody> - <tr><td><code>UNKNOWN_BLOCK</code></td><td>Compare <code>__resolveType</code> with your block map's keys, including case. Add an <a href="#adoption">alias</a> for renamed types. If the function does a dynamic <code>import()</code>, confirm the imported module has a default export.</td></tr> - <tr><td><code>NOT_FOUND</code></td><td>A string target is always an entry name, never a block type. Confirm the entry exists in the served revision, that <code>createCMS</code>'s <code>content</code> is the content you expect, and that <code>deco content</code> ran after the file was added (see <a href="#content--configuring-content">Configuring content</a>). To run a type directly, pass an inline block: <code>client.resolve({ __resolveType: "seo", … })</code>.</td></tr> - <tr><td><code>CYCLE</code></td><td>Follow the references in <code>error.path</code> and remove the one that leads back to an entry already being expanded.</td></tr> - <tr><td>My saved entry is ignored</td><td>A block type probably has the same name, and the function wins. Run <code>deco schema</code>, which reports the collision, and rename the entry.</td></tr> - <tr><td>Blocks come back as JSON instead of running</td><td>You called <code>client.list</code> without <code>{ run: true }</code> (it returns saved entries by default), or you passed <code>{ run: false }</code> to <code>resolve</code>. To run listed entries, pass <code>run: true</code>, or resolve the fields you need with <code>client.resolve</code>.</td></tr> - <tr><td>A function runs more than once per request</td><td>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.</td></tr> - <tr><td>Wrong fields in the editor</td><td>Regenerate the schema from the right <code>blocks.ts</code>. Check the first parameter's type and its property JSDoc (see <a href="#schema">Schema generation</a>).</td></tr> - <tr><td>Serialization error when sending a block's result to the browser</td><td>JSON can't carry functions or React elements. Return plain data (descriptors, see <a href="#rendering">Rendering</a>), or use an integration that renders on the server, like <a href="#tanstack-rsc">TanStack RSC</a> or <a href="#nextjs">Next.js</a>.</td></tr> - <tr><td>Hydration mismatch (React's browser render doesn't match the server's HTML)</td><td>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.</td></tr> - <tr><td>Content changes mid-response</td><td>Use one client for the whole response; a client never changes revision. If you wrote the loader, check that it never returns different content under an existing revision.</td></tr> - <tr><td>Stale or fallback content</td><td>Compare the revision your server serves (see above) 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 about a minute after publishing. See <a href="#releases">Releases and deployment</a> and <a href="#loader-internals">how remoteLoader stays current</a>.</td></tr> - <tr><td><code>UNKNOWN_BLOCK</code> right after publishing</td><td>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 <a href="#schema--keep-the-schema-compatible">Keep the schema compatible</a>.</td></tr> - <tr><td>A new content file isn't found in development</td><td>Run <code>deco content</code>, or keep <code>deco content --watch</code> running beside your dev server. Only adding or removing a file needs it; edits to an existing file hot-reload.</td></tr> - <tr><td>Warning about an instance with different options</td><td><code>createCMS</code> (or <code>remoteLoader</code>) ran twice with the same token and content but different options, such as another <code>interval</code>. The first instance is kept. Create the CMS once, at module scope (see <a href="#standalone--one-instance-per-process">One instance per process</a>).</td></tr> - <tr><td>Warning that <code>interval</code> was raised</td><td>The minimum is 60 000 ms (one minute); lower values, from <code>interval</code> or <code>DECO_CONTENT_INTERVAL</code>, are raised to it.</td></tr> - </tbody> - </table></div> -</section> - -</div> -<div class="page" data-page="internals"> -<section id="internals" data-nav="Overview" class="doc-section" aria-labelledby="internals-title"> - <p class="eyebrow">Under the hood</p><h2 id="internals-title">How Deco Blocks works inside</h2> - <p>The <a href="#architecture">developer docs</a> describe how to use Deco Blocks. This part describes how it works inside. It's for contributors, for anyone connecting a new framework or content store, and for the curious:</p> - <ul> - <li><a href="#walkthrough">How resolution works</a>: one saved entry, expanded and run step by step.</li> - <li><a href="#loader-internals">How <code>remoteLoader</code> stays current</a>: new releases arrive without a request ever waiting on the network.</li> - <li><a href="#router-internals">How <code>matchRoute</code> matches</a>: the data structure that maps a URL to an entry.</li> - <li><a href="#studio">Studio compatibility</a>: what Deco Studio reads from your site, and how the CLI keeps today's Studio working.</li> - <li><a href="#decisions">Design decisions</a>: the choices behind all of the above, and why they won.</li> - </ul> - - <h3>Contributing</h3> - <p>The framework lives in one repository, <code>decocms/blocks</code>, as a Bun workspace (Bun is the package manager and test runner). Five core packages make up the framework; the other folders under <code>packages/</code>, such as <code>apps-*</code>, are integrations with commerce and other services built on top of them. The core packages depend on each other in one direction only:</p> -<pre class="code-block"><code class="language-text">packages/ -├── blocks/ @decocms/blocks the SDK: loaders, client, resolution, router. -│ Depends on no other @decocms package. -├── blocks-admin/ @decocms/blocks-admin the HTTP endpoints Studio calls on your site. -│ depends on: @decocms/blocks -├── blocks-cli/ @decocms/blocks-cli deco schema and deco content, plus scripts that -│ move sites off the previous framework. -│ depends on: @decocms/blocks -├── tanstack/ @decocms/tanstack the adapter for TanStack Start on Cloudflare Workers. -│ depends on: @decocms/blocks, @decocms/blocks-admin, @decocms/blocks-cli -└── nextjs/ @decocms/nextjs the adapter for the Next.js App Router. - depends on: @decocms/blocks, @decocms/blocks-admin</code></pre> - <p>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: <code>createCMS</code> and <code>remoteLoader</code> keep their instances on <code>globalThis</code>, so a second copy reuses the first (see <a href="#standalone--one-instance-per-process">One instance per process</a>). <code>blocks</code> never imports from the others, and the two adapters never import from each other; if a feature needs both sides, split it into two functions rather than import in reverse. Apps following these docs need only <code>@decocms/blocks</code> at runtime, plus <code>@decocms/blocks-cli</code> as a dev dependency. The two adapters serve sites on Studio's current protocol; apps written against the API in these docs don't need them.</p> -<pre class="code-block"><code class="language-bash">bun install -bun run check # typecheck + lint -bun run test # vitest, per package</code></pre> - <p>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 one request's layout data from leaking into another's; if one fails, fix the code, not the assertion.</p> -</section> - -<section id="walkthrough" data-nav="How resolution works" class="doc-section"> - <p class="eyebrow">Under the hood</p><h2>How resolution works</h2> - <p>Here's the setup. Your block map registers two functions, <code>product-card</code> and <code>catalog-product</code>. Your content map holds two saved entries: <code>CurrentProduct</code>, which is <code>{ "__resolveType": "catalog-product", "slug": "summer-shirt" }</code>, and <code>SummerCard</code>, shown in step 1, which uses it. As code, <code>SummerCard</code> means <code>productCard({ title: "Summer collection", product: catalogProduct({ slug: "summer-shirt" }) })</code>.</p> - <p>Step through <code>client.resolve("SummerCard")</code> to watch <a href="#model--the-rule">the rule</a> at work, or switch to <code>{ run: false }</code> to see what a read returns. <code>product-card</code> can return a descriptor 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.</p> - <div class="explorer"> - <div class="explorer-top"><strong>SummerCard · one saved entry</strong><div class="segmented" role="group" aria-label="CMS call"><button data-trace-operation="get" aria-pressed="false">resolve("SummerCard", { run: false })</button><button data-trace-operation="resolve" aria-pressed="true">resolve("SummerCard")</button></div></div> - <div class="explorer-top"><span class="small muted">Function output</span><div class="segmented" role="group" aria-label="Output representation"><button data-trace-mode="data" aria-pressed="true">Descriptor</button><button data-trace-mode="rsc" aria-pressed="false">React tree</button></div></div> - <div class="explorer-body"><div class="trace-steps" aria-label="Resolution steps"><button data-trace-step="0" aria-current="step">1 · Stored</button><button data-trace-step="1">2 · CurrentProduct: saved entry</button><button data-trace-step="2">3 · catalog-product: function</button><button data-trace-step="3">4 · product-card: function</button></div><pre aria-label="Resolution example"><code id="trace-code" class="language-json">{ - "__resolveType": "product-card", - "title": "Summer collection", - "product": { "__resolveType": "CurrentProduct" } -}</code></pre><p class="trace-caption" id="trace-caption" aria-live="polite">The saved entry. Its product input names CurrentProduct, another saved entry.</p><div class="explorer-footer"><span class="trace-stat" id="trace-stat">0 function calls</span><button class="primary-button" id="trace-next">Next step →</button></div></div> - </div> -</section> - -<section id="loader-internals" data-nav="Loader internals" class="doc-section" aria-labelledby="loader-internals-title"> - <p class="eyebrow">Under the hood</p><h2 id="loader-internals-title">How <code>remoteLoader</code> stays current</h2> - <p>The rule that matters: <strong>a production request never waits for the network.</strong> <code>load()</code> returns from memory, immediately, every time. Staying current happens beside requests, not in front of them.</p> - <p class="small muted" style="margin-bottom:6px">Request path · synchronous, never touches the network</p> - <div class="flow" aria-label="Request path"> - <div class="flow-node"><strong>Request</strong><small><code>client.list</code> or <code>client.resolve</code></small></div><div class="flow-arrow" aria-hidden="true">→</div> - <div class="flow-node"><strong><code>load()</code> from memory</strong><small>Newest fetched release, or the fallback loader's content</small></div><div class="flow-arrow" aria-hidden="true">→</div> - <div class="flow-node"><strong>Content resolves</strong><small>Same revision for the whole request</small></div> - </div> - <p class="small muted" style="margin:18px 0 6px">Background path · on first use, then about once a minute, when idle</p> - <div class="flow" aria-label="Background refresh"> - <div class="flow-node"><strong>Ask for the hash</strong><small>A few bytes from the Deco API</small></div><div class="flow-arrow" aria-hidden="true">→</div> - <div class="flow-node"><strong>Compare</strong><small>Same as memory: done. Same as the fallback: use it, no download.</small></div><div class="flow-arrow" aria-hidden="true">→</div> - <div class="flow-node"><strong>Fetch, verify, swap</strong><small>Whole revision at once. Any error: keep what's there.</small></div> - </div> - <ol> - <li><strong>Serve from memory.</strong> <code>load()</code> returns the newest release this process has fetched, or, if it hasn't fetched one yet, the fallback's content. A freshly started server (a cold start) serves the content the build shipped with on its very first request.</li> - <li><strong>Ask for the hash.</strong> In the background, on first use and then every interval, <code>update()</code> fetches only the current revision hash from the API. That's a few bytes. The interval is one minute by default; set it with <code>interval</code> or the <code>DECO_CONTENT_INTERVAL</code> 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: - <ul> - <li><code>requestIdleCallback</code> where it exists (browsers, React Native);</li> - <li>otherwise <code>scheduler.postTask({ priority: "background" })</code>;</li> - <li>otherwise <code>setTimeout</code>;</li> - <li>on Node, an unref'd timer (one that doesn't keep the process alive) plus <code>setImmediate</code>, so it never keeps the process running;</li> - <li>on Cloudflare Workers, which run no timers between requests, after the response inside <code>ctx.waitUntil</code> (the Worker's way to finish work after a response is sent), automatically.</li> - </ul> - Updating never competes with rendering, and a request never waits. There's no push signal: servers ask, the API never calls them.</li> - <li><strong>Compare.</strong> If the hash equals what's in memory, there's nothing to do. 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's revision is the CLI's copy of the same hash. A loader you write with its own revision scheme is downloaded once.</li> - <li><strong>Fetch and swap.</strong> Otherwise it fetches that revision, checks that the content hashes to it, and swaps it in whole. Whether that content fits this build was settled before it merged, not here (see <a href="#schema--keep-the-schema-compatible">Keep the schema compatible</a>). Requests in flight keep the revision they started with.</li> - <li><strong>Fail quietly.</strong> Any network or validation error leaves memory as it was, so a server that loses the API keeps serving the newest release it has. Nothing reaches a request.</li> - </ol> - <p>So different servers can briefly serve different releases, by design. A published change reaches each server on its next check, within about a minute (or at once on the one server where you call <code>cms.update()</code>), and each server sends one tiny request per interval, however much traffic it serves. (This is called eventual consistency.)</p> - <p>Drafts are the opposite: a preview must show exactly the version Studio asked for, so <code>load(pointer)</code> fetches that version and waits for it, cached by version afterwards. If the draft can't be fetched, the client serves what a normal request would get: the newest release in memory, or the build's content if none has been fetched yet. It never mixes draft and published entries.</p> - <h3>One instance per process</h3> - <p>A cache and a poller only help if there's one of each. <code>createCMS</code> and <code>remoteLoader</code> store their instances on <code>globalThis</code>, under keys made with <code>Symbol.for("decocms.blocks…")</code>. <code>Symbol.for</code> 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 token and the content's identity: for the content module, the <code>.deco</code> 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 <code>interval</code>, 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. <code>resetForTests()</code> clears every stored instance.</p> - <p>A loader you write that has <code>update()</code> is checked the same way: the CMS calls its <code>update()</code> on this idle schedule and <code>interval</code>. To configure <code>remoteLoader</code>, see <a href="#content--configuring-content">Configuring content</a>; for what a release is, see <a href="#releases">Releases and deployment</a>.</p> - -</section> - -<section id="router-internals" data-nav="Router internals" class="doc-section" aria-labelledby="router-internals-title"> - <p class="eyebrow">Under the hood</p><h2 id="router-internals-title">How <code>matchRoute</code> matches</h2> - <p>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. <code>matchRoute</code> instead compiles the entries into a <strong>segment trie</strong>, once per list, and matches in time proportional to the URL's length, not the catalog's size.</p> - <p>A segment is one piece of a URL path between slashes: <code>/blog/hello</code> has the segments <code>blog</code> and <code>hello</code>. A trie is a tree where each edge is one segment, so paths that start the same share nodes.</p> -<pre class="code-block"><code class="language-text">/summer /:slug/p /blog/:slug /blog/archive - ┌── root ──┐ - │ │ - ┌───────────────────────┼───────────┼──────────────────┐ - "summer" "blog" :slug - │ │ │ - [SummerPage] ┌──────────┴──────────┐ "p" - "archive" :slug │ - │ │ [ProductPage] - [BlogArchive] [Post …]</code></pre> - <p class="small muted">Top: four route paths. Below: the trie built from them. Quoted words are literal segments, <code>:slug</code> is a parameter, and [Brackets] mark the entry stored at that node.</p> - <ol> - <li><strong>Build, once per list.</strong> Every path is split on <code>/</code> and inserted segment by segment. A literal segment becomes a named child; a <code>:param</code> segment becomes the node's single parameter child. The entry is stored at the leaf. Cost is the total number of segments, and the result is cached in a <code>WeakMap</code> 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.</li> - <li><strong>Reject ambiguity while building.</strong> Two entries reaching the same leaf, or two parameter children under one node (<code>/:slug/p</code> and <code>/:id/p</code> have the same shape, so every URL that matches one matches the other), throw with both names in the message. The CLI runs the same build at schema time, so <code>deco schema</code> in CI reports it: a pull request check catches it before merge, and a check on pushes to the production branch reports a Studio commit that went straight there, after it's live (see <a href="#schema--keep-the-schema-compatible">Keep the schema compatible</a>).</li> - <li><strong>Match, one segment at a time.</strong> Walk the URL's segments from the root. At each node try the literal child first, then the parameter child. If a branch dead-ends, go back up and try the parameter child you skipped. Each node has at most one literal match and one parameter, so a lookup visits about as many nodes as the URL has segments, however many entries exist.</li> - <li><strong>Precedence falls out of the walk order.</strong> Trying the literal child before the parameter child at every node is exactly "exact paths win over templates", per segment rather than per path: <code>/blog/archive</code> beats <code>/blog/:slug</code> for <code>/blog/archive</code>, and <code>/blog/:slug</code> still serves <code>/blog/hello-world</code>. No sorting, no regex.</li> - <li><strong>Redirects are a second trie</strong>, checked first, with the same rules. A hit fills the parameters into the redirect's <code>to</code> path and returns <code>{ kind: "redirect", location, status }</code>.</li> - </ol> - <p>Before matching, the URL is normalized: percent-decoded, query and fragment dropped, trailing slash removed except for <code>/</code>. Parameters capture one segment and never contain a slash. That's the whole algorithm; it's the same shape as the routers in Fastify and Hono, and it's about a hundred lines.</p> - -</section> - -<section id="studio" data-nav="Studio compatibility" class="doc-section" aria-labelledby="studio-title"> - <p class="eyebrow">Under the hood</p><h2 id="studio-title">Deco Studio compatibility</h2> - <h3>What Studio reads</h3> - <p>Studio learns about your site from your site. Your app serves a few HTTP endpoints for it. The main one, <code>/live/_meta</code>, returns the schema the CLI wrote; Studio also calls your app to render previews (<code>/live/previews</code>), to run functions for pickers and the Run button (<code>/deco/invoke</code>), and to encrypt secret fields (<code>/live/invoke</code>). In today's packages, these handlers come from <code>@decocms/blocks-admin</code>; how the new API mounts them is on the <a href="#roadmap-studio-new">Roadmap</a>. Studio also reads your content files in <code>.deco/blocks</code>. Which schema Studio uses is up to you: usually the one on your main branch, but you can point it at another. Everything Studio knows about the shape of your content comes from the schema, so it's all inferred from your types; the other endpoints run your code, they don't describe it.</p> - <p>The schema file has two parts. <code>definitions</code> holds one JSON Schema per type: the form Studio shows. <code>manifest</code> lists every key of your block map and your <code>Content</code> type, grouped by kind, so Studio knows which keys render UI, which are rules, and so on. The group names are Studio's, inherited from Deco's previous framework. Each manifest entry points at its definition with a JSON Schema <code>$ref</code>. The CLI picks the group from the type:</p> - <div class="table-wrap"><table> - <thead><tr><th>Manifest group</th><th>What lands there</th><th>Studio uses it for</th></tr></thead> - <tbody> - <tr><td><code>sections</code> (Studio's legacy name)</td><td>Block functions that return JSX or a render descriptor</td><td>Studio's "add" catalog: the blocks an editor can add to a page</td></tr> - <tr><td><code>matchers</code></td><td>Block functions that return <code>boolean</code></td><td>The rule picker on a variant</td></tr> - <tr><td><code>loaders</code></td><td>Every other block function. Studio calls these loaders, after the old framework's name for data functions; they're not the content loaders from <a href="#content">Content and loaders</a>.</td><td>Data fields that can point at a function</td></tr> - <tr><td><code>pages</code></td><td><code>Content</code> types that extend <code>Route</code></td><td>The page list, "new page", the URL field</td></tr> - <tr><td><code>redirects</code></td><td><code>Redirect</code></td><td>The redirects screen</td></tr> - <tr><td><code>content</code></td><td>Every other <code>Content</code> type</td><td>Plain entries: navigation, settings, posts</td></tr> - </tbody> - </table></div> -<figure class="code-example"><figcaption>.deco/schema.json, abridged</figcaption><pre><code class="language-json">{ - "manifest": { - "blocks": { - "sections": { "hero": { "$ref": "#/definitions/hero" } }, - "matchers": { "always": { "$ref": "…" }, "never": { "$ref": "…" }, "date": { "$ref": "…" } }, - "loaders": { "seo": { "$ref": "…" }, "multivariate": { "$ref": "…" } }, - "pages": { "page": { "$ref": "…" } }, - "redirects": { "redirect": { "$ref": "…" } } - } - }, - "definitions": { "hero": { "type": "object", "properties": { "title": { "type": "string", "title": "Heading" } } } } -}</code></pre></figure> - <p>Three conventions follow from this, and they're the ones the rest of the doc already uses. A key is a <strong>block type if it's in the manifest and a saved entry if it isn't</strong>, so short names like <code>hero</code> are fine, as long as no saved entry uses the same name (see <a href="#model">Blocks</a>). A variant is <code>{ "variants": [{ "rule": …, "value": … }, …] }</code>. The first variant whose rule is true wins, so the default, with the <code>always</code> rule, goes last. A redirect is a flat <code>{ from, to, permanent }</code> entry.</p> - - <h3>Studio compatibility today</h3> - <p>Deco's previous framework named every block type by the path of the file that defined it, so a page type was <code>website/pages/Page.tsx</code> and the always-true rule was <code>website/matchers/always.ts</code>. Today's Studio was built for that framework and still expects those names. It decides whether a key is a block type or a saved entry by checking for a file extension, and it finds pages, the <code>always</code> rule (the default variant), the <code>never</code> rule (how it hides a block) and <code>multivariate</code> (<code>website/flags/multivariate.ts</code>) by their old paths.</p> - <p>Until Studio reads the manifest instead, the CLI bridges the gap: for the types Studio looks up by name, it also writes the old path into the manifest as an alias (a second key for the same type). Content Studio saves under an old name resolves through the <a href="#adoption">same alias mechanism</a> your own renames use. Your code uses <code>page</code> and <code>always</code>; the manifest carries both spellings; nothing in the SDK changes.</p> - <p>Two gaps remain. Redirects created in Studio are saved in the old nested shape, not the flat one from <a href="#content-types">Routes and redirects</a>:</p> -<pre class="code-block"><code class="language-json">// Saved by today's Studio -{ "__resolveType": "website/loaders/redirect.ts", - "redirect": { "from": "/campaigns/summer", "to": "/summer", "type": "permanent" } } - -// The shape these docs use -{ "__resolveType": "redirect", "from": "/campaigns/summer", "to": "/summer", "permanent": true }</code></pre> - <p><code>matchRoute</code> accepts both. And Studio's file-extension check must become a manifest lookup before a site can drop the aliases. Both are Studio work, tracked in the <a href="#roadmap-studio-legacy">Roadmap</a>, not something sites do.</p> - -</section> - -<section id="decisions" data-nav="Design decisions" class="doc-section" aria-labelledby="decisions-title"> - <p class="eyebrow">Under the hood</p><h2 id="decisions-title">Design decisions</h2> - <p>Each of these was argued over. The one-line version, and why it won.</p> - <div class="table-wrap"><table> - <thead><tr><th>Decision</th><th>Why</th></tr></thead> - <tbody> - <tr><td><strong>The CMS is pure.</strong> A block function gets its inputs and nothing else.</td><td>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.</td></tr> - <tr><td><strong>For content edited in Studio, Git is the source of truth.</strong> Publishing is committing; the Deco API serves commits as releases. (Any other store is <a href="#content">a loader you write</a>.)</td><td>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.</td></tr> - <tr><td><strong>A block is either a function (code) or saved content (JSON).</strong> One namespace: an entry is merged in and checked again; a function gets its inputs resolved, then runs, and its result is returned as is.</td><td>Saved entries are just named compositions, not a second concept. A function that returns its input, like <code>seo</code>, can't loop, because results are never looked up again. The only recursion is over saved entries, and a cycle check stops it.</td></tr> - <tr><td><strong>The CMS at module scope, a client per request, pinned to one revision.</strong></td><td>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 every cache lives in the loader.</td></tr> - <tr><td><strong><code>content</code> is a snapshot or one <code>Loader</code> interface</strong>: <code>load(pointer?)</code>, plus an optional <code>update()</code>.</td><td>Production, drafts, and staying current are uses of the same interface, so the content module, <code>remoteLoader</code> and a custom loader can replace each other in the <code>content</code> option and <a href="#content--write-your-own-loader">a custom loader</a> is a few lines.</td></tr> - <tr><td><strong>A production request never waits for the network.</strong></td><td>Serve from memory, check for a hash about once a minute, at idle moments, fall back to the content the build shipped with. Eventual consistency for visitors, strict consistency for drafts.</td></tr> - <tr><td><strong>The CMS doesn't route.</strong> <code>matchRoute</code> is a helper over entries you list.</td><td>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.</td></tr> - <tr><td><strong>Data-only types live in <code>Content</code>; <code>page</code> and <code>redirect</code> are built in</strong>, and you can override them or opt out.</td><td>A block map entry means "run this". Pages, posts, and redirects have nothing to run; they need a schema, which is the CLI's job. Almost every website needs pages and redirects, and Studio's page list and redirects screen look for them. Overriding with <code>extends Page</code> keeps them yours, and an app that isn't a website opts out with <code>never</code>. Posts and the rest stay your own types.</td></tr> - <tr><td><strong>Lazy loading belongs to your web framework, not the CMS.</strong></td><td>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.</td></tr> - <tr><td><strong>Three matchers ship; the rest is app code.</strong></td><td><code>always</code>, <code>never</code>, and <code>date</code> depend on nothing but the clock. A device or cookie matcher is an opinion about the request, and the app owns those.</td></tr> - <tr><td><strong>Short type names; aliases for the old file-path names.</strong></td><td>A type name is a column name, not a file path. Content saved under the previous framework's names, like <code>website/pages/Page.tsx</code>, keeps working through aliases, while new code uses names that survive a refactor.</td></tr> - <tr><td><strong>Packages export TypeScript source and depend on each other in one direction.</strong></td><td>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 <code>globalThis</code>, so a module loaded twice still shares one cache and one poller.</td></tr> - <tr><td><strong>The SDK never touches the filesystem.</strong></td><td>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.</td></tr> - <tr><td><strong>Two CLI commands, no dev wrapper.</strong></td><td><code>deco schema</code> and <code>deco content</code> each do one job and run from <code>predev</code> and <code>prebuild</code>. Both take <code>--root</code> and <code>--watch</code>; <code>deco schema</code> also takes <code>--entry</code>, for a block map outside the default locations, and <code>--check</code>, for CI.</td></tr> - <tr><td><strong>Compatibility is a CI check, not a runtime gate.</strong></td><td>Publishing without a deploy exists so code that's already live can render new content; holding content back until a deploy would undo that. <code>deco schema --check</code> (planned) flags a code change that would break saved content before it merges, and Studio offers what the schema you point it at has, usually main's.</td></tr> - <tr><td><strong>Poll a hash once a minute, at idle moments; no push.</strong></td><td>A few bytes per server per minute, no infrastructure to reach every server, and never in front of rendering.</td></tr> - </tbody> - </table></div> -</section> - -</div> - -<script id="syntax-highlighter"> -/*! Prism 1.30.0 — https://prismjs.com/ -MIT LICENSE - -Copyright (c) 2012 Lea Verou - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. -*/ -window.Prism = { manual: true }; -var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(e){var n=/(?:^|\s)lang(?:uage)?-([\w-]+)(?=\s|$)/i,t=0,r={},a={manual:e.Prism&&e.Prism.manual,disableWorkerMessageHandler:e.Prism&&e.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof i?new i(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/</g,"<").replace(/\u00a0/g," ")},type:function(e){return Object.prototype.toString.call(e).slice(8,-1)},objId:function(e){return e.__id||Object.defineProperty(e,"__id",{value:++t}),e.__id},clone:function e(n,t){var r,i;switch(t=t||{},a.util.type(n)){case"Object":if(i=a.util.objId(n),t[i])return t[i];for(var l in r={},t[i]=r,n)n.hasOwnProperty(l)&&(r[l]=e(n[l],t));return r;case"Array":return i=a.util.objId(n),t[i]?t[i]:(r=[],t[i]=r,n.forEach((function(n,a){r[a]=e(n,t)})),r);default:return n}},getLanguage:function(e){for(;e;){var t=n.exec(e.className);if(t)return t[1].toLowerCase();e=e.parentElement}return"none"},setLanguage:function(e,t){e.className=e.className.replace(RegExp(n,"gi"),""),e.classList.add("language-"+t)},currentScript:function(){if("undefined"==typeof document)return null;if(document.currentScript&&"SCRIPT"===document.currentScript.tagName)return document.currentScript;try{throw new Error}catch(r){var e=(/at [^(\r\n]*\((.*):[^:]+:[^:]+\)$/i.exec(r.stack)||[])[1];if(e){var n=document.getElementsByTagName("script");for(var t in n)if(n[t].src==e)return n[t]}return null}},isActive:function(e,n,t){for(var r="no-"+n;e;){var a=e.classList;if(a.contains(n))return!0;if(a.contains(r))return!1;e=e.parentElement}return!!t}},languages:{plain:r,plaintext:r,text:r,txt:r,extend:function(e,n){var t=a.util.clone(a.languages[e]);for(var r in n)t[r]=n[r];return t},insertBefore:function(e,n,t,r){var i=(r=r||a.languages)[e],l={};for(var o in i)if(i.hasOwnProperty(o)){if(o==n)for(var s in t)t.hasOwnProperty(s)&&(l[s]=t[s]);t.hasOwnProperty(o)||(l[o]=i[o])}var u=r[e];return r[e]=l,a.languages.DFS(a.languages,(function(n,t){t===u&&n!=e&&(this[n]=l)})),l},DFS:function e(n,t,r,i){i=i||{};var l=a.util.objId;for(var o in n)if(n.hasOwnProperty(o)){t.call(n,o,n[o],r||o);var s=n[o],u=a.util.type(s);"Object"!==u||i[l(s)]?"Array"!==u||i[l(s)]||(i[l(s)]=!0,e(s,t,o,i)):(i[l(s)]=!0,e(s,t,null,i))}}},plugins:{},highlightAll:function(e,n){a.highlightAllUnder(document,e,n)},highlightAllUnder:function(e,n,t){var r={callback:t,container:e,selector:'code[class*="language-"], [class*="language-"] code, code[class*="lang-"], [class*="lang-"] code'};a.hooks.run("before-highlightall",r),r.elements=Array.prototype.slice.apply(r.container.querySelectorAll(r.selector)),a.hooks.run("before-all-elements-highlight",r);for(var i,l=0;i=r.elements[l++];)a.highlightElement(i,!0===n,r.callback)},highlightElement:function(n,t,r){var i=a.util.getLanguage(n),l=a.languages[i];a.util.setLanguage(n,i);var o=n.parentElement;o&&"pre"===o.nodeName.toLowerCase()&&a.util.setLanguage(o,i);var s={element:n,language:i,grammar:l,code:n.textContent};function u(e){s.highlightedCode=e,a.hooks.run("before-insert",s),s.element.innerHTML=s.highlightedCode,a.hooks.run("after-highlight",s),a.hooks.run("complete",s),r&&r.call(s.element)}if(a.hooks.run("before-sanity-check",s),(o=s.element.parentElement)&&"pre"===o.nodeName.toLowerCase()&&!o.hasAttribute("tabindex")&&o.setAttribute("tabindex","0"),!s.code)return a.hooks.run("complete",s),void(r&&r.call(s.element));if(a.hooks.run("before-highlight",s),s.grammar)if(t&&e.Worker){var c=new Worker(a.filename);c.onmessage=function(e){u(e.data)},c.postMessage(JSON.stringify({language:s.language,code:s.code,immediateClose:!0}))}else u(a.highlight(s.code,s.grammar,s.language));else u(a.util.encode(s.code))},highlight:function(e,n,t){var r={code:e,grammar:n,language:t};if(a.hooks.run("before-tokenize",r),!r.grammar)throw new Error('The language "'+r.language+'" has no grammar.');return r.tokens=a.tokenize(r.code,r.grammar),a.hooks.run("after-tokenize",r),i.stringify(a.util.encode(r.tokens),r.language)},tokenize:function(e,n){var t=n.rest;if(t){for(var r in t)n[r]=t[r];delete n.rest}var a=new s;return u(a,a.head,e),o(e,a,n,a.head,0),function(e){for(var n=[],t=e.head.next;t!==e.tail;)n.push(t.value),t=t.next;return n}(a)},hooks:{all:{},add:function(e,n){var t=a.hooks.all;t[e]=t[e]||[],t[e].push(n)},run:function(e,n){var t=a.hooks.all[e];if(t&&t.length)for(var r,i=0;r=t[i++];)r(n)}},Token:i};function i(e,n,t,r){this.type=e,this.content=n,this.alias=t,this.length=0|(r||"").length}function l(e,n,t,r){e.lastIndex=n;var a=e.exec(t);if(a&&r&&a[1]){var i=a[1].length;a.index+=i,a[0]=a[0].slice(i)}return a}function o(e,n,t,r,s,g){for(var f in t)if(t.hasOwnProperty(f)&&t[f]){var h=t[f];h=Array.isArray(h)?h:[h];for(var d=0;d<h.length;++d){if(g&&g.cause==f+","+d)return;var v=h[d],p=v.inside,m=!!v.lookbehind,y=!!v.greedy,k=v.alias;if(y&&!v.pattern.global){var x=v.pattern.toString().match(/[imsuy]*$/)[0];v.pattern=RegExp(v.pattern.source,x+"g")}for(var b=v.pattern||v,w=r.next,A=s;w!==n.tail&&!(g&&A>=g.reach);A+=w.value.length,w=w.next){var P=w.value;if(n.length>e.length)return;if(!(P instanceof i)){var E,S=1;if(y){if(!(E=l(b,A,e,m))||E.index>=e.length)break;var L=E.index,O=E.index+E[0].length,C=A;for(C+=w.value.length;L>=C;)C+=(w=w.next).value.length;if(A=C-=w.value.length,w.value instanceof i)continue;for(var j=w;j!==n.tail&&(C<O||"string"==typeof j.value);j=j.next)S++,C+=j.value.length;S--,P=e.slice(A,C),E.index-=A}else if(!(E=l(b,0,P,m)))continue;L=E.index;var N=E[0],_=P.slice(0,L),M=P.slice(L+N.length),W=A+P.length;g&&W>g.reach&&(g.reach=W);var I=w.prev;if(_&&(I=u(n,I,_),A+=_.length),c(n,I,S),w=u(n,I,new i(f,p?a.tokenize(N,p):N,k,N)),M&&u(n,w,M),S>1){var T={cause:f+","+d,reach:W};o(e,n,t,w.prev,A,T),g&&T.reach>g.reach&&(g.reach=T.reach)}}}}}}function s(){var e={value:null,prev:null,next:null},n={value:null,prev:e,next:null};e.next=n,this.head=e,this.tail=n,this.length=0}function u(e,n,t){var r=n.next,a={value:t,prev:n,next:r};return n.next=a,r.prev=a,e.length++,a}function c(e,n,t){for(var r=n.next,a=0;a<t&&r!==e.tail;a++)r=r.next;n.next=r,r.prev=n,e.length-=a}if(e.Prism=a,i.stringify=function e(n,t){if("string"==typeof n)return n;if(Array.isArray(n)){var r="";return n.forEach((function(n){r+=e(n,t)})),r}var i={type:n.type,content:e(n.content,t),tag:"span",classes:["token",n.type],attributes:{},language:t},l=n.alias;l&&(Array.isArray(l)?Array.prototype.push.apply(i.classes,l):i.classes.push(l)),a.hooks.run("wrap",i);var o="";for(var s in i.attributes)o+=" "+s+'="'+(i.attributes[s]||"").replace(/"/g,""")+'"';return"<"+i.tag+' class="'+i.classes.join(" ")+'"'+o+">"+i.content+"</"+i.tag+">"},!e.document)return e.addEventListener?(a.disableWorkerMessageHandler||e.addEventListener("message",(function(n){var t=JSON.parse(n.data),r=t.language,i=t.code,l=t.immediateClose;e.postMessage(a.highlight(i,a.languages[r],r)),l&&e.close()}),!1),a):a;var g=a.util.currentScript();function f(){a.manual||a.highlightAll()}if(g&&(a.filename=g.src,g.hasAttribute("data-manual")&&(a.manual=!0)),!a.manual){var h=document.readyState;"loading"===h||"interactive"===h&&g&&g.defer?document.addEventListener("DOMContentLoaded",f):window.requestAnimationFrame?window.requestAnimationFrame(f):window.setTimeout(f,16)}return a}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); -; -Prism.languages.markup={comment:{pattern:/<!--(?:(?!<!--)[\s\S])*?-->/,greedy:!0},prolog:{pattern:/<\?[\s\S]+?\?>/,greedy:!0},doctype:{pattern:/<!DOCTYPE(?:[^>"'[\]]|"[^"]*"|'[^']*')+(?:\[(?:[^<"'\]]|"[^"]*"|'[^']*'|<(?!!--)|<!--(?:[^-]|-(?!->))*-->)*\]\s*)?>/i,greedy:!0,inside:{"internal-subset":{pattern:/(^[^\[]*\[)[\s\S]+(?=\]>$)/,lookbehind:!0,greedy:!0,inside:null},string:{pattern:/"[^"]*"|'[^']*'/,greedy:!0},punctuation:/^<!|>$|[[\]]/,"doctype-tag":/^DOCTYPE/i,name:/[^\s<>'"]+/}},cdata:{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,greedy:!0},tag:{pattern:/<\/?(?!\d)[^\s>\/=$<%]+(?:\s(?:\s*[^\s>\/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+(?=[\s>]))|(?=[\s/>])))+)?\s*\/?>/,greedy:!0,inside:{tag:{pattern:/^<\/?[^\s>\/]+/,inside:{punctuation:/^<\/?/,namespace:/^[^\s>\/:]+:/}},"special-attr":[],"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{punctuation:[{pattern:/^=/,alias:"attr-equals"},{pattern:/^(\s*)["']|["']$/,lookbehind:!0}]}},punctuation:/\/?>/,"attr-name":{pattern:/[^\s>\/]+/,inside:{namespace:/^[^\s>\/:]+:/}}}},entity:[{pattern:/&[\da-z]{1,8};/i,alias:"named-entity"},/&#x?[\da-f]{1,8};/i]},Prism.languages.markup.tag.inside["attr-value"].inside.entity=Prism.languages.markup.entity,Prism.languages.markup.doctype.inside["internal-subset"].inside=Prism.languages.markup,Prism.hooks.add("wrap",(function(a){"entity"===a.type&&(a.attributes.title=a.content.replace(/&/,"&"))})),Object.defineProperty(Prism.languages.markup.tag,"addInlined",{value:function(a,e){var s={};s["language-"+e]={pattern:/(^<!\[CDATA\[)[\s\S]+?(?=\]\]>$)/i,lookbehind:!0,inside:Prism.languages[e]},s.cdata=/^<!\[CDATA\[|\]\]>$/i;var t={"included-cdata":{pattern:/<!\[CDATA\[[\s\S]*?\]\]>/i,inside:s}};t["language-"+e]={pattern:/[\s\S]+/,inside:Prism.languages[e]};var n={};n[a]={pattern:RegExp("(<__[^>]*>)(?:<!\\[CDATA\\[(?:[^\\]]|\\](?!\\]>))*\\]\\]>|(?!<!\\[CDATA\\[)[^])*?(?=</__>)".replace(/__/g,(function(){return a})),"i"),lookbehind:!0,greedy:!0,inside:t},Prism.languages.insertBefore("markup","cdata",n)}}),Object.defineProperty(Prism.languages.markup.tag,"addAttribute",{value:function(a,e){Prism.languages.markup.tag.inside["special-attr"].push({pattern:RegExp("(^|[\"'\\s])(?:"+a+")\\s*=\\s*(?:\"[^\"]*\"|'[^']*'|[^\\s'\">=]+(?=[\\s>]))","i"),lookbehind:!0,inside:{"attr-name":/^[^\s=]+/,"attr-value":{pattern:/=[\s\S]+/,inside:{value:{pattern:/(^=\s*(["']|(?!["'])))\S[\s\S]*(?=\2$)/,lookbehind:!0,alias:[e,"language-"+e],inside:Prism.languages[e]},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}}}})}}),Prism.languages.html=Prism.languages.markup,Prism.languages.mathml=Prism.languages.markup,Prism.languages.svg=Prism.languages.markup,Prism.languages.xml=Prism.languages.extend("markup",{}),Prism.languages.ssml=Prism.languages.xml,Prism.languages.atom=Prism.languages.xml,Prism.languages.rss=Prism.languages.xml; -; -Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0,greedy:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|trait)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:break|catch|continue|do|else|finally|for|function|if|in|instanceof|new|null|return|throw|try|while)\b/,boolean:/\b(?:false|true)\b/,function:/\b\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; -; -Prism.languages.javascript=Prism.languages.extend("clike",{"class-name":[Prism.languages.clike["class-name"],{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$A-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\.(?:constructor|prototype))/,lookbehind:!0}],keyword:[{pattern:/((?:^|\})\s*)catch\b/,lookbehind:!0},{pattern:/(^|[^.]|\.\.\.\s*)\b(?:as|assert(?=\s*\{)|async(?=\s*(?:function\b|\(|[$\w\xA0-\uFFFF]|$))|await|break|case|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally(?=\s*(?:\{|$))|for|from(?=\s*(?:['"]|$))|function|(?:get|set)(?=\s*(?:[#\[$\w\xA0-\uFFFF]|$))|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)\b/,lookbehind:!0}],function:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*(?:\.\s*(?:apply|bind|call)\s*)?\()/,number:{pattern:RegExp("(^|[^\\w$])(?:NaN|Infinity|0[bB][01]+(?:_[01]+)*n?|0[oO][0-7]+(?:_[0-7]+)*n?|0[xX][\\dA-Fa-f]+(?:_[\\dA-Fa-f]+)*n?|\\d+(?:_\\d+)*n|(?:\\d+(?:_\\d+)*(?:\\.(?:\\d+(?:_\\d+)*)?)?|\\.\\d+(?:_\\d+)*)(?:[Ee][+-]?\\d+(?:_\\d+)*)?)(?![\\w$])"),lookbehind:!0},operator:/--|\+\+|\*\*=?|=>|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|\.{3}|\?\?=?|\?\.?|[~:]/}),Prism.languages.javascript["class-name"][0].pattern=/(\b(?:class|extends|implements|instanceof|interface|new)\s+)[\w.\\]+/,Prism.languages.insertBefore("javascript","keyword",{regex:{pattern:RegExp("((?:^|[^$\\w\\xA0-\\uFFFF.\"'\\])\\s]|\\b(?:return|yield))\\s*)/(?:(?:\\[(?:[^\\]\\\\\r\n]|\\\\.)*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}|(?:\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.|\\[(?:[^[\\]\\\\\r\n]|\\\\.)*\\])*\\])*\\]|\\\\.|[^/\\\\\\[\r\n])+/[dgimyus]{0,7}v[dgimyus]{0,7})(?=(?:\\s|/\\*(?:[^*]|\\*(?!/))*\\*/)*(?:$|[\r\n,.;:})\\]]|//))"),lookbehind:!0,greedy:!0,inside:{"regex-source":{pattern:/^(\/)[\s\S]+(?=\/[a-z]*$)/,lookbehind:!0,alias:"language-regex",inside:Prism.languages.regex},"regex-delimiter":/^\/|\/$/,"regex-flags":/^[a-z]+$/}},"function-variable":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*[=:]\s*(?:async\s*)?(?:\bfunction\b|(?:\((?:[^()]|\([^()]*\))*\)|(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)\s*=>))/,alias:"function"},parameter:[{pattern:/(function(?:\s+(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*)?\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\))/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(^|[^$\w\xA0-\uFFFF])(?!\s)[_$a-z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*=>)/i,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/(\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*=>)/,lookbehind:!0,inside:Prism.languages.javascript},{pattern:/((?:\b|\s|^)(?!(?:as|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|get|if|implements|import|in|instanceof|interface|let|new|null|of|package|private|protected|public|return|set|static|super|switch|this|throw|try|typeof|undefined|var|void|while|with|yield)(?![$\w\xA0-\uFFFF]))(?:(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*)\(\s*|\]\s*\(\s*)(?!\s)(?:[^()\s]|\s+(?![\s)])|\([^()]*\))+(?=\s*\)\s*\{)/,lookbehind:!0,inside:Prism.languages.javascript}],constant:/\b[A-Z](?:[A-Z_]|\dx?)*\b/}),Prism.languages.insertBefore("javascript","string",{hashbang:{pattern:/^#!.*/,greedy:!0,alias:"comment"},"template-string":{pattern:/`(?:\\[\s\S]|\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}|(?!\$\{)[^\\`])*`/,greedy:!0,inside:{"template-punctuation":{pattern:/^`|`$/,alias:"string"},interpolation:{pattern:/((?:^|[^\\])(?:\\{2})*)\$\{(?:[^{}]|\{(?:[^{}]|\{[^}]*\})*\})+\}/,lookbehind:!0,inside:{"interpolation-punctuation":{pattern:/^\$\{|\}$/,alias:"punctuation"},rest:Prism.languages.javascript}},string:/[\s\S]+/}},"string-property":{pattern:/((?:^|[,{])[ \t]*)(["'])(?:\\(?:\r\n|[\s\S])|(?!\2)[^\\\r\n])*\2(?=\s*:)/m,lookbehind:!0,greedy:!0,alias:"property"}}),Prism.languages.insertBefore("javascript","operator",{"literal-property":{pattern:/((?:^|[,{])[ \t]*)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?=\s*:)/m,lookbehind:!0,alias:"property"}}),Prism.languages.markup&&(Prism.languages.markup.tag.addInlined("script","javascript"),Prism.languages.markup.tag.addAttribute("on(?:abort|blur|change|click|composition(?:end|start|update)|dblclick|error|focus(?:in|out)?|key(?:down|up)|load|mouse(?:down|enter|leave|move|out|over|up)|reset|resize|scroll|select|slotchange|submit|unload|wheel)","javascript")),Prism.languages.js=Prism.languages.javascript; -; -!function(t){var n=t.util.clone(t.languages.javascript),e="(?:\\{<S>*\\.{3}(?:[^{}]|<BRACES>)*\\})";function a(t,n){return t=t.replace(/<S>/g,(function(){return"(?:\\s|//.*(?!.)|/\\*(?:[^*]|\\*(?!/))\\*/)"})).replace(/<BRACES>/g,(function(){return"(?:\\{(?:\\{(?:\\{[^{}]*\\}|[^{}])*\\}|[^{}])*\\})"})).replace(/<SPREAD>/g,(function(){return e})),RegExp(t,n)}e=a(e).source,t.languages.jsx=t.languages.extend("markup",n),t.languages.jsx.tag.pattern=a("</?(?:[\\w.:-]+(?:<S>+(?:[\\w.:$-]+(?:=(?:\"(?:\\\\[^]|[^\\\\\"])*\"|'(?:\\\\[^]|[^\\\\'])*'|[^\\s{'\"/>=]+|<BRACES>))?|<SPREAD>))*<S>*/?)?>"),t.languages.jsx.tag.inside.tag.pattern=/^<\/?[^\s>\/]*/,t.languages.jsx.tag.inside["attr-value"].pattern=/=(?!\{)(?:"(?:\\[\s\S]|[^\\"])*"|'(?:\\[\s\S]|[^\\'])*'|[^\s'">]+)/,t.languages.jsx.tag.inside.tag.inside["class-name"]=/^[A-Z]\w*(?:\.[A-Z]\w*)*$/,t.languages.jsx.tag.inside.comment=n.comment,t.languages.insertBefore("inside","attr-name",{spread:{pattern:a("<SPREAD>"),inside:t.languages.jsx}},t.languages.jsx.tag),t.languages.insertBefore("inside","special-attr",{script:{pattern:a("=<BRACES>"),alias:"language-javascript",inside:{"script-punctuation":{pattern:/^=(?=\{)/,alias:"punctuation"},rest:t.languages.jsx}}},t.languages.jsx.tag);var s=function(t){return t?"string"==typeof t?t:"string"==typeof t.content?t.content:t.content.map(s).join(""):""},g=function(n){for(var e=[],a=0;a<n.length;a++){var o=n[a],i=!1;if("string"!=typeof o&&("tag"===o.type&&o.content[0]&&"tag"===o.content[0].type?"</"===o.content[0].content[0].content?e.length>0&&e[e.length-1].tagName===s(o.content[0].content[1])&&e.pop():"/>"===o.content[o.content.length-1].content||e.push({tagName:s(o.content[0].content[1]),openedBraces:0}):e.length>0&&"punctuation"===o.type&&"{"===o.content?e[e.length-1].openedBraces++:e.length>0&&e[e.length-1].openedBraces>0&&"punctuation"===o.type&&"}"===o.content?e[e.length-1].openedBraces--:i=!0),(i||"string"==typeof o)&&e.length>0&&0===e[e.length-1].openedBraces){var r=s(o);a<n.length-1&&("string"==typeof n[a+1]||"plain-text"===n[a+1].type)&&(r+=s(n[a+1]),n.splice(a+1,1)),a>0&&("string"==typeof n[a-1]||"plain-text"===n[a-1].type)&&(r=s(n[a-1])+r,n.splice(a-1,1),a--),n[a]=new t.Token("plain-text",r,null,r)}o.content&&"string"!=typeof o.content&&g(o.content)}};t.hooks.add("after-tokenize",(function(t){"jsx"!==t.language&&"tsx"!==t.language||g(t.tokens)}))}(Prism); -; -!function(e){e.languages.typescript=e.languages.extend("javascript",{"class-name":{pattern:/(\b(?:class|extends|implements|instanceof|interface|new|type)\s+)(?!keyof\b)(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*(?:\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>)?/,lookbehind:!0,greedy:!0,inside:null},builtin:/\b(?:Array|Function|Promise|any|boolean|console|never|number|string|symbol|unknown)\b/}),e.languages.typescript.keyword.push(/\b(?:abstract|declare|is|keyof|readonly|require)\b/,/\b(?:asserts|infer|interface|module|namespace|type)\b(?=\s*(?:[{_$a-zA-Z\xA0-\uFFFF]|$))/,/\btype\b(?=\s*(?:[\{*]|$))/),delete e.languages.typescript.parameter,delete e.languages.typescript["literal-property"];var s=e.languages.extend("typescript",{});delete s["class-name"],e.languages.typescript["class-name"].inside=s,e.languages.insertBefore("typescript","function",{decorator:{pattern:/@[$\w\xA0-\uFFFF]+/,inside:{at:{pattern:/^@/,alias:"operator"},function:/^[\s\S]+/}},"generic-function":{pattern:/#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*\s*<(?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*>(?=\s*\()/,greedy:!0,inside:{function:/^#?(?!\s)[_$a-zA-Z\xA0-\uFFFF](?:(?!\s)[$\w\xA0-\uFFFF])*/,generic:{pattern:/<[\s\S]+/,alias:"class-name",inside:s}}}}),e.languages.ts=e.languages.typescript}(Prism); -; -!function(e){var a=e.util.clone(e.languages.typescript);e.languages.tsx=e.languages.extend("jsx",a),delete e.languages.tsx.parameter,delete e.languages.tsx["literal-property"];var t=e.languages.tsx.tag;t.pattern=RegExp("(^|[^\\w$]|(?=</))(?:"+t.pattern.source+")",t.pattern.flags),t.lookbehind=!0}(Prism); -; -!function(a){var e=a.languages.javadoclike={parameter:{pattern:/(^[\t ]*(?:\/{3}|\*|\/\*\*)\s*@(?:arg|arguments|param)\s+)\w+/m,lookbehind:!0},keyword:{pattern:/(^[\t ]*(?:\/{3}|\*|\/\*\*)\s*|\{)@[a-z][a-zA-Z-]+\b/m,lookbehind:!0},punctuation:/[{}]/};Object.defineProperty(e,"addSupport",{value:function(e,n){"string"==typeof e&&(e=[e]),e.forEach((function(e){!function(e,n){var t="doc-comment",r=a.languages[e];if(r){var o=r[t];if(o||(o=(r=a.languages.insertBefore(e,"comment",{"doc-comment":{pattern:/(^|[^\\])\/\*\*[^/][\s\S]*?(?:\*\/|$)/,lookbehind:!0,alias:"comment"}}))[t]),o instanceof RegExp&&(o=r[t]={pattern:o}),Array.isArray(o))for(var i=0,s=o.length;i<s;i++)o[i]instanceof RegExp&&(o[i]={pattern:o[i]}),n(o[i]);else n(o)}}(e,(function(a){a.inside||(a.inside={}),a.inside.rest=n}))}))}}),e.addSupport(["java","javascript","php"],e)}(Prism); -; -!function(e){var a=e.languages.javascript,n="\\{(?:[^{}]|\\{(?:[^{}]|\\{[^{}]*\\})*\\})+\\}",t="(@(?:arg|argument|param|property)\\s+(?:"+n+"\\s+)?)";e.languages.jsdoc=e.languages.extend("javadoclike",{parameter:{pattern:RegExp(t+"(?:(?!\\s)[$\\w\\xA0-\\uFFFF.])+(?=\\s|$)"),lookbehind:!0,inside:{punctuation:/\./}}}),e.languages.insertBefore("jsdoc","keyword",{"optional-parameter":{pattern:RegExp(t+"\\[(?:(?!\\s)[$\\w\\xA0-\\uFFFF.])+(?:=[^[\\]]+)?\\](?=\\s|$)"),lookbehind:!0,inside:{parameter:{pattern:/(^\[)[$\w\xA0-\uFFFF\.]+/,lookbehind:!0,inside:{punctuation:/\./}},code:{pattern:/(=)[\s\S]*(?=\]$)/,lookbehind:!0,inside:a,alias:"language-javascript"},punctuation:/[=[\]]/}},"class-name":[{pattern:RegExp("(@(?:augments|class|extends|interface|memberof!?|template|this|typedef)\\s+(?:<TYPE>\\s+)?)[A-Z]\\w*(?:\\.[A-Z]\\w*)*".replace(/<TYPE>/g,(function(){return n}))),lookbehind:!0,inside:{punctuation:/\./}},{pattern:RegExp("(@[a-z]+\\s+)"+n),lookbehind:!0,inside:{string:a.string,number:a.number,boolean:a.boolean,keyword:e.languages.typescript.keyword,operator:/=>|\.\.\.|[&|?:*]/,punctuation:/[.,;=<>{}()[\]]/}}],example:{pattern:/(@example\s+(?!\s))(?:[^@\s]|\s+(?!\s))+?(?=\s*(?:\*\s*)?(?:@\w|\*\/))/,lookbehind:!0,inside:{code:{pattern:/^([\t ]*(?:\*\s*)?)\S.*$/m,lookbehind:!0,inside:a,alias:"language-javascript"}}}}),e.languages.javadoclike.addSupport("javascript",e.languages.jsdoc)}(Prism); -; -Prism.languages.json={property:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?=\s*:)/,lookbehind:!0,greedy:!0},string:{pattern:/(^|[^\\])"(?:\\.|[^\\"\r\n])*"(?!\s*:)/,lookbehind:!0,greedy:!0},comment:{pattern:/\/\/.*|\/\*[\s\S]*?(?:\*\/|$)/,greedy:!0},number:/-?\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b/i,punctuation:/[{}[\],]/,operator:/:/,boolean:/\b(?:false|true)\b/,null:{pattern:/\bnull\b/,alias:"keyword"}},Prism.languages.webmanifest=Prism.languages.json; -; -!function(e){var t="\\b(?:BASH|BASHOPTS|BASH_ALIASES|BASH_ARGC|BASH_ARGV|BASH_CMDS|BASH_COMPLETION_COMPAT_DIR|BASH_LINENO|BASH_REMATCH|BASH_SOURCE|BASH_VERSINFO|BASH_VERSION|COLORTERM|COLUMNS|COMP_WORDBREAKS|DBUS_SESSION_BUS_ADDRESS|DEFAULTS_PATH|DESKTOP_SESSION|DIRSTACK|DISPLAY|EUID|GDMSESSION|GDM_LANG|GNOME_KEYRING_CONTROL|GNOME_KEYRING_PID|GPG_AGENT_INFO|GROUPS|HISTCONTROL|HISTFILE|HISTFILESIZE|HISTSIZE|HOME|HOSTNAME|HOSTTYPE|IFS|INSTANCE|JOB|LANG|LANGUAGE|LC_ADDRESS|LC_ALL|LC_IDENTIFICATION|LC_MEASUREMENT|LC_MONETARY|LC_NAME|LC_NUMERIC|LC_PAPER|LC_TELEPHONE|LC_TIME|LESSCLOSE|LESSOPEN|LINES|LOGNAME|LS_COLORS|MACHTYPE|MAILCHECK|MANDATORY_PATH|NO_AT_BRIDGE|OLDPWD|OPTERR|OPTIND|ORBIT_SOCKETDIR|OSTYPE|PAPERSIZE|PATH|PIPESTATUS|PPID|PS1|PS2|PS3|PS4|PWD|RANDOM|REPLY|SECONDS|SELINUX_INIT|SESSION|SESSIONTYPE|SESSION_MANAGER|SHELL|SHELLOPTS|SHLVL|SSH_AUTH_SOCK|TERM|UID|UPSTART_EVENTS|UPSTART_INSTANCE|UPSTART_JOB|UPSTART_SESSION|USER|WINDOWID|XAUTHORITY|XDG_CONFIG_DIRS|XDG_CURRENT_DESKTOP|XDG_DATA_DIRS|XDG_GREETER_DATA_DIR|XDG_MENU_PREFIX|XDG_RUNTIME_DIR|XDG_SEAT|XDG_SEAT_PATH|XDG_SESSION_DESKTOP|XDG_SESSION_ID|XDG_SESSION_PATH|XDG_SESSION_TYPE|XDG_VTNR|XMODIFIERS)\\b",a={pattern:/(^(["']?)\w+\2)[ \t]+\S.*/,lookbehind:!0,alias:"punctuation",inside:null},n={bash:a,environment:{pattern:RegExp("\\$"+t),alias:"constant"},variable:[{pattern:/\$?\(\([\s\S]+?\)\)/,greedy:!0,inside:{variable:[{pattern:/(^\$\(\([\s\S]+)\)\)/,lookbehind:!0},/^\$\(\(/],number:/\b0x[\dA-Fa-f]+\b|(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:[Ee]-?\d+)?/,operator:/--|\+\+|\*\*=?|<<=?|>>=?|&&|\|\||[=!+\-*/%<>^&|]=?|[?~:]/,punctuation:/\(\(?|\)\)?|,|;/}},{pattern:/\$\((?:\([^)]+\)|[^()])+\)|`[^`]+`/,greedy:!0,inside:{variable:/^\$\(|^`|\)$|`$/}},{pattern:/\$\{[^}]+\}/,greedy:!0,inside:{operator:/:[-=?+]?|[!\/]|##?|%%?|\^\^?|,,?/,punctuation:/[\[\]]/,environment:{pattern:RegExp("(\\{)"+t),lookbehind:!0,alias:"constant"}}},/\$(?:\w+|[#?*!@$])/],entity:/\\(?:[abceEfnrtv\\"]|O?[0-7]{1,3}|U[0-9a-fA-F]{8}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{1,2})/};e.languages.bash={shebang:{pattern:/^#!\s*\/.*/,alias:"important"},comment:{pattern:/(^|[^"{\\$])#.*/,lookbehind:!0},"function-name":[{pattern:/(\bfunction\s+)[\w-]+(?=(?:\s*\(?:\s*\))?\s*\{)/,lookbehind:!0,alias:"function"},{pattern:/\b[\w-]+(?=\s*\(\s*\)\s*\{)/,alias:"function"}],"for-or-select":{pattern:/(\b(?:for|select)\s+)\w+(?=\s+in\s)/,alias:"variable",lookbehind:!0},"assign-left":{pattern:/(^|[\s;|&]|[<>]\()\w+(?:\.\w+)*(?=\+?=)/,inside:{environment:{pattern:RegExp("(^|[\\s;|&]|[<>]\\()"+t),lookbehind:!0,alias:"constant"}},alias:"variable",lookbehind:!0},parameter:{pattern:/(^|\s)-{1,2}(?:\w+:[+-]?)?\w+(?:\.\w+)*(?=[=\s]|$)/,alias:"variable",lookbehind:!0},string:[{pattern:/((?:^|[^<])<<-?\s*)(\w+)\s[\s\S]*?(?:\r?\n|\r)\2/,lookbehind:!0,greedy:!0,inside:n},{pattern:/((?:^|[^<])<<-?\s*)(["'])(\w+)\2\s[\s\S]*?(?:\r?\n|\r)\3/,lookbehind:!0,greedy:!0,inside:{bash:a}},{pattern:/(^|[^\\](?:\\\\)*)"(?:\\[\s\S]|\$\([^)]+\)|\$(?!\()|`[^`]+`|[^"\\`$])*"/,lookbehind:!0,greedy:!0,inside:n},{pattern:/(^|[^$\\])'[^']*'/,lookbehind:!0,greedy:!0},{pattern:/\$'(?:[^'\\]|\\[\s\S])*'/,greedy:!0,inside:{entity:n.entity}}],environment:{pattern:RegExp("\\$?"+t),alias:"constant"},variable:n.variable,function:{pattern:/(^|[\s;|&]|[<>]\()(?:add|apropos|apt|apt-cache|apt-get|aptitude|aspell|automysqlbackup|awk|basename|bash|bc|bconsole|bg|bzip2|cal|cargo|cat|cfdisk|chgrp|chkconfig|chmod|chown|chroot|cksum|clear|cmp|column|comm|composer|cp|cron|crontab|csplit|curl|cut|date|dc|dd|ddrescue|debootstrap|df|diff|diff3|dig|dir|dircolors|dirname|dirs|dmesg|docker|docker-compose|du|egrep|eject|env|ethtool|expand|expect|expr|fdformat|fdisk|fg|fgrep|file|find|fmt|fold|format|free|fsck|ftp|fuser|gawk|git|gparted|grep|groupadd|groupdel|groupmod|groups|grub-mkconfig|gzip|halt|head|hg|history|host|hostname|htop|iconv|id|ifconfig|ifdown|ifup|import|install|ip|java|jobs|join|kill|killall|less|link|ln|locate|logname|logrotate|look|lpc|lpr|lprint|lprintd|lprintq|lprm|ls|lsof|lynx|make|man|mc|mdadm|mkconfig|mkdir|mke2fs|mkfifo|mkfs|mkisofs|mknod|mkswap|mmv|more|most|mount|mtools|mtr|mutt|mv|nano|nc|netstat|nice|nl|node|nohup|notify-send|npm|nslookup|op|open|parted|passwd|paste|pathchk|ping|pkill|pnpm|podman|podman-compose|popd|pr|printcap|printenv|ps|pushd|pv|quota|quotacheck|quotactl|ram|rar|rcp|reboot|remsync|rename|renice|rev|rm|rmdir|rpm|rsync|scp|screen|sdiff|sed|sendmail|seq|service|sftp|sh|shellcheck|shuf|shutdown|sleep|slocate|sort|split|ssh|stat|strace|su|sudo|sum|suspend|swapon|sync|sysctl|tac|tail|tar|tee|time|timeout|top|touch|tr|traceroute|tsort|tty|umount|uname|unexpand|uniq|units|unrar|unshar|unzip|update-grub|uptime|useradd|userdel|usermod|users|uudecode|uuencode|v|vcpkg|vdir|vi|vim|virsh|vmstat|wait|watch|wc|wget|whereis|which|who|whoami|write|xargs|xdg-open|yarn|yes|zenity|zip|zsh|zypper)(?=$|[)\s;|&])/,lookbehind:!0},keyword:{pattern:/(^|[\s;|&]|[<>]\()(?:case|do|done|elif|else|esac|fi|for|function|if|in|select|then|until|while)(?=$|[)\s;|&])/,lookbehind:!0},builtin:{pattern:/(^|[\s;|&]|[<>]\()(?:\.|:|alias|bind|break|builtin|caller|cd|command|continue|declare|echo|enable|eval|exec|exit|export|getopts|hash|help|let|local|logout|mapfile|printf|pwd|read|readarray|readonly|return|set|shift|shopt|source|test|times|trap|type|typeset|ulimit|umask|unalias|unset)(?=$|[)\s;|&])/,lookbehind:!0,alias:"class-name"},boolean:{pattern:/(^|[\s;|&]|[<>]\()(?:false|true)(?=$|[)\s;|&])/,lookbehind:!0},"file-descriptor":{pattern:/\B&\d\b/,alias:"important"},operator:{pattern:/\d?<>|>\||\+=|=[=~]?|!=?|<<[<-]?|[&\d]?>>|\d[<>]&?|[<>][&=]?|&[>&]?|\|[&|]?/,inside:{"file-descriptor":{pattern:/^\d/,alias:"important"}}},punctuation:/\$?\(\(?|\)\)?|\.\.|[{}[\];\\]/,number:{pattern:/(^|\s)(?:[1-9]\d*|0)(?:[.,]\d+)?\b/,lookbehind:!0}},a.inside=e.languages.bash;for(var s=["comment","function-name","for-or-select","assign-left","parameter","string","environment","function","keyword","builtin","boolean","file-descriptor","operator","punctuation","number"],o=n.variable[1].inside,i=0;i<s.length;i++)o[s[i]]=e.languages.bash[s[i]];e.languages.sh=e.languages.bash,e.languages.shell=e.languages.bash}(Prism); -;Prism.languages.jsonc = Prism.languages.json; -</script> diff --git a/site/src/dark-tokens.css b/site/src/dark-tokens.css deleted file mode 100644 index 9a97de42..00000000 --- a/site/src/dark-tokens.css +++ /dev/null @@ -1,82 +0,0 @@ ---bg: #1A1714; ---bg-subtle: #1E1A17; ---bg-warm: #201C19; ---surface: #221E1B; ---muted: #2A2522; ---hover: #322C28; ---fg: #F4F2EE; ---fg-body: #DCD7D0; ---muted-fg: #B9B4AB; ---faint: #9A948A; ---quiet: #9A948A; ---num-faint: #9A948A; ---ol-num: #9A948A; ---who-fg: #BCD43C; ---mark-bg: #D0EC1A; ---mark-fg: #07401A; ---step-on: #D0EC1A; ---stack-fg: rgba(244, 242, 238, .64); ---footer-bg: #1A1714; ---border: rgba(255, 255, 255, .08); ---border-strong: rgba(255, 255, 255, .16); ---hairline: rgba(255, 255, 255, .07); ---eyebrow: #BCD43C; ---link: #D0EC1A; ---link-line: rgba(208, 236, 26, .42); ---accent-text: #D0EC1A; ---nav-active-bg: #16361D; ---nav-active-fg: #D0EC1A; ---nav-bar: #D0EC1A; ---pill-on-bg: #0B4A20; ---pill-on-fg: #D0EC1A; ---pill-on-ring: rgba(208, 236, 26, .28); ---ring: #D0EC1A; ---indicator-ring: transparent; ---tint: rgba(208, 236, 26, .1); ---tint-fg: #E4F58A; ---code-bg: #211D1A; ---code-head: #26221F; ---code-border: rgba(255, 255, 255, .07); ---code-inline: #2E2926; ---code-fg: #E8E3DC; ---code-sel: rgba(208, 236, 26, .28); ---note-bg: #211D1A; ---warn-bg: #2A2314; ---warn-border: #54451D; ---warn-icon: #F2C14E; ---preview-bg: #212914; ---preview-border: rgba(208, 236, 26, .22); ---del-bg: rgba(255, 110, 90, .12); ---del-fg: #FFA08C; ---add-bg: rgba(208, 236, 26, .12); ---th-bg: #211D1A; ---st-bg: #26221F; ---st-fg: #F4F2EE; ---st-muted: #B9B4AB; ---st-border: rgba(255, 255, 255, .08); ---st-field: #211D1A; ---st-track: #453E38; ---st-fill: #D0EC1A; ---win-bg: #211D1A; ---win-panel: #26221F; ---win-bar: #24201D; ---win-dot: #47413B; ---shadow-sm: 0 1px 2px rgba(0, 0, 0, .3); ---shadow-md: 0 1px 2px rgba(0, 0, 0, .3), 0 12px 32px -16px rgba(0, 0, 0, .6); ---shadow-win: 0 50px 110px -36px rgba(0, 0, 0, .75); ---shadow-lg: 0 0 0 1px rgba(255, 255, 255, .06), 0 24px 64px -16px rgba(0, 0, 0, .7); ---syn-comment: #958E86; ---syn-keyword: #A595FF; ---syn-string: #C9E45A; ---syn-type: #8FB8E8; ---syn-function: #8FB8E8; ---syn-number: #F2C14E; ---syn-property: #E8E3DC; ---syn-punctuation: #9C958D; ---syn-operator: #B8B1A9; ---syn-tag: #FF9A85; ---wm-light: none; ---wm-dark: inline-block; ---icon-sun: block; ---icon-moon: none; -color-scheme: dark; diff --git a/site/src/env.d.ts b/site/src/env.d.ts new file mode 100644 index 00000000..ed266f4f --- /dev/null +++ b/site/src/env.d.ts @@ -0,0 +1,18 @@ +/// <reference types="vite/client" /> + +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/site/src/landing.html b/site/src/landing.html deleted file mode 100644 index 95380c08..00000000 --- a/site/src/landing.html +++ /dev/null @@ -1,352 +0,0 @@ -<section id="home" data-nav="Overview" class="landing" aria-labelledby="home-title"> - - <div class="hero band"> - <svg class="cobogo" aria-hidden="true" focusable="false"><rect class="cb-lg" width="100%" height="100%" fill="url(#cb-lg)"/><rect class="cb-sm" width="100%" height="100%" fill="url(#cb-sm)"/></svg> - <div class="container hero-inner"> - <h1 id="home-title" class="enter" style="--d:60ms">Headless for developers.<br> Editable for humans.<br> <span class="hl">Native for AI.</span></h1> - <p class="lede enter" style="--d:120ms">Deco Blocks is the AI-native headless CMS. Developers write the functions, marketers edit the content and settings those functions use in Deco Studio's visual editor, AI agents edit the same content as files, and every change lands in Git.</p> - <div class="cta enter" style="--d:300ms"> - <a class="btn btn-primary" href="#quickstart">Start building</a> - <a class="btn btn-white" href="#home-how">See how it works</a> - <button class="install" type="button" data-copy="npm install @decocms/blocks" aria-label="Copy install command: npm install @decocms/blocks"><span class="prompt" aria-hidden="true">$</span><span>npm install @decocms/blocks</span><span class="install-icon" aria-hidden="true">{{icon:copy}}{{icon:check}}</span></button> - </div> - </div> - - <div class="container"> - <div class="hero-window win enter" style="--d:420ms"> - <div class="win-bar"><span class="win-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="win-title">Deco Blocks · my-store — Experiments</span></div> - <div class="journey" role="group" aria-label="From a TypeScript type to a resolved value"> - <div class="jp jp-type"> - <div class="jp-head"><span class="jp-step">01</span><span class="jp-title">Type it</span><span class="jp-meta">experiments.ts</span></div> -<pre class="jp-pre has-ln"><code><span class="l"><span class="tk-k">export</span> <span class="tk-k">interface</span> <span class="tk-t">Experiments</span> {</span><span class="l"> <span class="tk-c">/** <span class="tk-ck">@title</span> New checkout flow</span></span><span class="l"><span class="tk-c"> * <span class="tk-ck">@minimum</span> 0 <span class="tk-ck">@maximum</span> 100 */</span></span><span class="l"> <span class="tk-p">newCheckout</span>: <span class="tk-t">number</span>;</span><span class="l"> <span class="tk-c">/** <span class="tk-ck">@title</span> Sticky header … */</span></span><span class="l"> <span class="tk-p">stickyHeader</span>: <span class="tk-t">number</span>;</span><span class="l"> <span class="tk-c">/** <span class="tk-ck">@title</span> Free shipping … */</span></span><span class="l"> <span class="tk-p">freeShippingBanner</span>: <span class="tk-t">number</span>;</span><span class="l">}</span></code></pre> - <div class="jp-foot"><span class="mono">npx deco schema</span><span class="jp-foot-sep" aria-hidden="true">→</span><span class="mono">.deco/schema.json</span></div> - <span class="jp-arrow" aria-hidden="true">{{icon:chevron-right}}</span> - </div> - - <div class="jp jp-form"> - <div class="jp-head"><span class="jp-step">02</span><span class="jp-title">Edit it</span><span class="jp-meta">Deco Studio</span></div> - <div class="studio"> - <div class="studio-bar"><span class="studio-block">{{symbol}}Experiments</span><span class="studio-type">experiments</span></div> - <div class="studio-field"> - <div class="studio-row"><label for="exp-newCheckout">New checkout flow</label><output id="out-newCheckout" for="exp-newCheckout">25</output></div> - <input type="range" id="exp-newCheckout" min="0" max="100" value="25" data-exp="newCheckout" style="--val:25%"> - </div> - <div class="studio-field"> - <div class="studio-row"><label for="exp-stickyHeader">Sticky header</label><output id="out-stickyHeader" for="exp-stickyHeader">50</output></div> - <input type="range" id="exp-stickyHeader" min="0" max="100" value="50" data-exp="stickyHeader" style="--val:50%"> - </div> - <div class="studio-field"> - <div class="studio-row"><label for="exp-freeShippingBanner">Free shipping banner</label><output id="out-freeShippingBanner" for="exp-freeShippingBanner">0</output></div> - <input type="range" id="exp-freeShippingBanner" min="0" max="100" value="0" data-exp="freeShippingBanner" style="--val:0%"> - </div> - <div class="studio-actions"><span class="studio-hint">0–100, from the JSDoc tags</span><button type="button" class="studio-save" id="studio-save">Save</button></div> - </div> - <span class="jp-arrow" aria-hidden="true">{{icon:chevron-right}}</span> - </div> - - <div class="jp jp-json"> - <div class="jp-head"><span class="jp-step">03</span><span class="jp-title">Commit it</span><span class="jp-meta" title=".deco/blocks/Experiments.json"><span class="jm-dir">.deco/blocks/</span>Experiments.json</span></div> - <div class="jp-src"><span class="src src-studio"><i aria-hidden="true"></i>Saved in Studio</span><span class="src-or">or</span><span class="src src-agent"><i aria-hidden="true"></i>Edited by an agent</span></div> -<pre class="jp-pre jp-diff" aria-label="Diff of .deco/blocks/Experiments.json"><code id="exp-json"><span class="dl"><span class="gut"> </span>{</span><span class="dl"><span class="gut"> </span> <span class="tk-p">"__resolveType"</span>: <span class="tk-s">"experiments"</span>,</span><span class="dl del"><span class="gut">-</span> <span class="tk-p">"newCheckout"</span>: <span class="tk-n">10</span>,</span><span class="dl add"><span class="gut">+</span> <span class="tk-p">"newCheckout"</span>: <span class="tk-n">25</span>,</span><span class="dl"><span class="gut"> </span> <span class="tk-p">"stickyHeader"</span>: <span class="tk-n">50</span>,</span><span class="dl"><span class="gut"> </span> <span class="tk-p">"freeShippingBanner"</span>: <span class="tk-n">0</span></span><span class="dl"><span class="gut"> </span>}</span></code></pre> - <div class="jp-foot jp-commit" id="exp-foot" data-state="dirty" aria-live="polite"><span class="jc-dot" aria-hidden="true"></span>{{icon:check}}<span class="jc-status" id="exp-status">1 change · not committed yet</span><span class="mono jc-msg" id="exp-commit">ramp new checkout to 25%</span></div> - <span class="jp-arrow" aria-hidden="true">{{icon:chevron-right}}</span> - </div> - - <div class="jp jp-call"> - <div class="jp-head"><span class="jp-step">04</span><span class="jp-title">Resolve it</span><span class="jp-meta">checkout.ts</span></div> -<pre class="jp-pre has-ln"><code><span class="l"><span class="tk-k">import</span> { createCMS } <span class="tk-k">from</span></span><span class="l"> <span class="tk-s">"@decocms/blocks"</span>;</span><span class="l"><span class="tk-k">import</span> experiments <span class="tk-k">from</span></span><span class="l"> <span class="tk-s">"./experiments"</span>;</span><span class="l"><span class="tk-k">import</span> content <span class="tk-k">from</span></span><span class="l"> <span class="tk-s">"./.deco/blocks.gen"</span>;</span><span class="l"><span class="tk-k">const</span> cms = <span class="tk-f">createCMS</span>({</span><span class="l"> blocks: { experiments },</span><span class="l"> content,</span><span class="l">});</span><span class="l"><span class="tk-k">const</span> [flags, error] = <span class="tk-k">await</span> cms</span><span class="l"> .<span class="tk-f">forRelease</span>()</span><span class="l"> .<span class="tk-f">resolve</span>(<span class="tk-s">"Experiments"</span>);</span><span class="l"><span class="tk-k">if</span> (error) <span class="tk-k">throw</span> error;</span><span class="l"><span class="tk-c">// true on ~<span id="exp-odds">25</span>% of calls</span></span></code></pre> - <div class="jp-foot">{{icon:check}}<span>Same result as the direct call, same type</span></div> - </div> - </div> - </div> - <div class="journey-nav enter" style="--d:480ms" role="group" aria-label="Steps in the product window"> - <button type="button" data-jp="0" aria-current="step"><span aria-hidden="true">01</span>Type</button> - <button type="button" data-jp="1"><span aria-hidden="true">02</span>Edit</button> - <button type="button" data-jp="2"><span aria-hidden="true">03</span>Commit</button> - <button type="button" data-jp="3"><span aria-hidden="true">04</span>Resolve</button> - </div> - <div class="journey-caption enter" style="--d:520ms"> - <p>The Quickstart, end to end. Studio saves it, or an agent edits it: either way it's a commit, and the function never changes. <span class="try">Drag a slider, then Save.</span></p> - <a class="text-link" href="#quickstart">Follow the quickstart{{icon:arrow-right}}</a> - </div> - </div> - </div> - - <div class="lsec stack-sec"> - <div class="container"> - <p class="kicker is-center reveal">Runs on your stack</p> - <ul class="stack reveal" style="--d:80ms" aria-label="Runtimes"> - <li>{{mark:nextjs}}Next.js</li> - <li>{{mark:tanstack}}TanStack Start</li> - <li>{{mark:cloudflare}}Cloudflare Workers</li> - <li>{{mark:react}}React Native</li> - <li>{{mark:node}}Node.js</li> - <li>{{mark:git}}Any Git repo</li> - </ul> - </div> - </div> - - <div class="lsec"> - <div class="container section"> - <div class="section-head"> - <p class="kicker reveal">One content model</p> - <h2 class="reveal" style="--d:60ms">Every change is a commit, <span class="dim">whoever makes it.</span></h2> - <p class="section-lede 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.</p> - </div> - <div class="contract-list reveal" style="--d:160ms"> - <div class="contract-item"> - <div class="ci-visual ci-code win" aria-hidden="true"> - <div class="win-bar"><span class="win-dots"><i></i><i></i><i></i></span><span class="win-title">blocks.ts</span></div> -<pre class="mini-pre"><code><span class="sy-k">export default</span> { - <span class="sy-p">experiments</span>, <span class="sy-c">// returns A/B flags</span> - <span class="sy-p">hero</span>, <span class="sy-c">// returns JSX</span> - <span class="sy-p">seo</span>, <span class="sy-c">// returns page metadata</span> -} <span class="sy-k">satisfies</span> <span class="sy-t">Blocks</span>;</code></pre> - </div> - <span class="num">01</span> - <h3>Headless for developers</h3> - <p>Write a function, type its inputs, and add it to your block map. Content can now call it. Deco Blocks 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 class="contract-item"> - <div class="ci-visual ci-form win" aria-hidden="true"> - <div class="win-bar"><span class="win-dots"><i></i><i></i><i></i></span><span class="win-title">Studio — Summer campaign</span></div> - <div class="mf-body"> - <div class="mf-row"><span class="mf-label">Name</span><span class="mf-input">Summer campaign</span></div> - <div class="mf-row"><span class="mf-label">Path</span><span class="mf-input mono is-focus">/summer-sale<span class="caret"></span></span></div> - <div class="mf-row"><span class="mf-label">Blocks</span><span class="mf-chips"><span>hero</span><span>product</span><span class="add">+ Add</span></span></div> - </div> - </div> - <span class="num">02</span> - <h3>Editable for humans</h3> - <p>Deco Studio, the visual editor in the hosted Deco CMS, turns your types into forms and live previews. Marketers and editors change pages, campaigns, and settings without a developer, and every change is a commit you can review and roll back.</p> - </div> - <div class="contract-item"> - <div class="ci-visual ci-agent" aria-hidden="true"> - <div class="bubble"><span class="bubble-who">You</span>Ramp the new checkout to 25%</div> - <div class="agent-edit"> - <div class="ae-file">{{icon:sparkle}}<span>Edited .deco/blocks/Experiments.json</span></div> - <code class="ae-line del">- "newCheckout": 10,</code> - <code class="ae-line add">+ "newCheckout": 25,</code> - <div class="ae-ok">{{icon:check}}<span>Valid against <b>.deco/schema.json</b></span></div> - </div> - </div> - <span class="num">03</span> - <h3>Native for AI</h3> - <p>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> - - <div class="lsec prod-sec"> - <div class="container"> - <div class="prod rely"> - <svg class="cobogo cobogo-right" aria-hidden="true" focusable="false"><rect class="cb-lg" width="100%" height="100%" fill="url(#cb-lg)"/><rect class="cb-sm" width="100%" height="100%" fill="url(#cb-sm)"/></svg> - <div class="section-head"> - <p class="kicker reveal">Built for stability</p> - <h2 class="reveal" style="--d:60ms">Your site ships with its content. <span class="dim">Nothing stands between it and your visitors.</span></h2> - <p class="section-lede 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.</p> - </div> - <div class="rely-grid"> - <div class="status win reveal" style="--d:160ms" role="group" aria-labelledby="status-title"> - <div class="win-bar"><span class="win-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="win-title" id="status-title">store.example.com — status</span></div> - <div class="status-body"> - <div class="pkg"> - <p class="pkg-label">Every deploy ships</p> - <div class="pkg-tiles"> - <div class="pkg-tile">{{icon:code}}<strong>Your code</strong><small>Design, features</small></div> - <span class="pkg-plus" aria-hidden="true">+</span> - <div class="pkg-tile is-content">{{icon:file}}<strong>Your content</strong><small>Pages, banners, settings</small></div> - </div> - <p class="pkg-cap">Packed together, tested together.</p> - </div> - <ul class="st-rows" role="list"> - <li><span class="st-k">Your site</span><span class="st-pill is-serving"><i aria-hidden="true"></i>Online</span></li> - <li><span class="st-k">Content</span><span class="st-pill is-ok">{{icon:check}}Inside this deploy</span></li> - <li><span class="st-k">Page loads</span><span class="st-pill is-ok">{{icon:check}}No extra round trip</span></li> - </ul> - </div> - </div> - <ul class="rely-points reveal" style="--d:200ms" role="list"> - <li>{{icon:check}}<span class="rp-body"><strong>No content servers to run</strong><span>No content database or content server to host, scale or keep online.</span></span></li> - <li>{{icon:check}}<span class="rp-body"><strong>Fast on every page</strong><span>Every page is built from content your site already holds, so it loads as fast as your own code.</span></span></li> - <li>{{icon:check}}<span class="rp-body"><strong>Stable by design</strong><span>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.</span></span></li> - </ul> - <a class="text-link dev-link reveal" href="#releases"><span class="dev-k">For developers:</span> how content <span class="dl-end">loads{{icon:arrow-right}}</span></a> - </div> - </div> - </div> - </div> - - <div class="lsec pub-sec"> - <div class="container section"> - <div class="section-head"> - <p class="kicker reveal">Hosted Deco CMS · optional</p> - <h2 class="reveal" style="--d:60ms">Publish in seconds, <span class="dim">not on the next deploy.</span></h2> - <p class="section-lede reveal" style="--d:120ms">The hosted Deco CMS adds Deco Studio, the visual editor, and publishing without a redeploy: each change reaches every visitor in seconds, and editors preview drafts on your real pages first. Without it, developers and agents edit the files, and content goes live with your next deploy.</p> - </div> - <div class="pub win reveal" style="--d:160ms" aria-hidden="true"> - <div class="win-bar"><span class="win-dots"><i></i><i></i><i></i></span><span class="win-title">Publishing a change — store.example.com</span></div> - <div class="pub-body"> - <div class="pub-lane is-base"> - <p class="pub-label"><b>Without it</b><span class="pub-sep">·</span><span class="pub-sub">content ships with your next deploy</span></p> - <ol class="pub-steps"> - <li style="--c:1">Edit</li> - <li style="--c:3" class="is-far">Commit</li> - <li style="--c:4">Build</li> - <li style="--c:5">Deploy</li> - <li style="--c:6" class="is-end">Live</li> - </ol> - <div class="pub-notes"><span class="pub-bracket">code release</span><span class="pub-cap">at your next deploy</span></div> - </div> - <div class="pub-lane is-hosted"> - <p class="pub-label"><b>With the hosted Deco CMS</b><span class="pub-sep">·</span><span class="pub-sub">each publish is a commit</span></p> - <ol class="pub-steps"> - <li style="--c:1">Edit</li> - <li style="--c:2" class="has-badge">Preview<span class="pub-badge"><i></i>Previewing a draft</span></li> - <li style="--c:3">Publish</li> - <li style="--c:4" class="is-skip">Build</li> - <li style="--c:5" class="is-skip">Deploy</li> - <li class="is-skip is-merged">No build · no deploy</li> - <li style="--c:6" class="is-live">Live everywhere</li> - </ol> - <div class="pub-notes"><span class="pub-bracket is-skip">not needed</span><span class="pub-cap is-live">in seconds</span></div> - </div> - </div> - </div> - <ul class="pub-points reveal" style="--d:200ms" role="list"> - <li><strong>Not tied to code releases</strong><span>Content needs no build and no deploy, so editors publish when a campaign is ready, not when the next code release goes out.</span></li> - <li><strong>Preview on the real site</strong><span>Editors open drafts on your actual pages before anything goes live, and only people Studio lets in can see them.</span></li> - <li><strong>Undo any change</strong><span>Every publish is saved in your repository's history, with who changed what and when, so going back is one step.</span></li> - </ul> - <a class="text-link dev-link reveal" href="#releases"><span class="dev-k">For developers:</span> publishing and <span class="dl-end">releases{{icon:arrow-right}}</span></a> - </div> - </div> - - <div class="lsec" id="home-how"> - <div class="container section"> - <p class="kicker reveal">Three steps, no ticket</p> - <div class="stepper"> - <div class="stepper-copy"> - <h2 class="reveal" style="--d:60ms">Your function stays. <span class="dim">Its inputs move into a file.</span></h2> - <p class="section-lede reveal" style="--d:120ms">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.</p> - <div class="steps reveal" style="--d:160ms" role="tablist" aria-label="Three steps" aria-orientation="vertical"> - <button class="step" type="button" role="tab" id="step-tab-1" aria-controls="step-pane-1" aria-selected="true" data-step="1"><span class="num" aria-hidden="true">01</span><span class="step-body"><span class="step-title">Write a function</span><span class="step-text"><span>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.</span></span></span></button> - <button class="step" type="button" role="tab" id="step-tab-2" aria-controls="step-pane-2" aria-selected="false" tabindex="-1" data-step="2"><span class="num" aria-hidden="true">02</span><span class="step-body"><span class="step-title">It becomes editable</span><span class="step-text"><span>One command turns the function's types into a form. With the hosted Deco CMS, that's the form editors use in Deco Studio, so marketing can change copy, images and campaigns without waiting on a developer.</span></span></span></button> - <button class="step" type="button" role="tab" id="step-tab-3" aria-controls="step-pane-3" aria-selected="false" tabindex="-1" data-step="3"><span class="num" aria-hidden="true">03</span><span class="step-body"><span class="step-title">Ship from Git</span><span class="step-text"><span>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.</span></span></span></button> - </div> - </div> - <div class="sw win reveal" style="--d:140ms"> - <div class="win-bar"><span class="win-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="win-title">Deco Blocks · my-store — setup</span></div> - <div class="sw-tabs" aria-hidden="true"><span class="sw-tab is-on" data-step="1">{{icon:file}}blocks.ts</span><span class="sw-tab" data-step="2">{{icon:terminal}}Terminal</span><span class="sw-tab" data-step="3">{{icon:file}}cms.ts</span></div> - <div class="sw-panes"> - <div class="sw-pane is-on" id="step-pane-1" role="tabpanel" aria-labelledby="step-tab-1"> -<pre><code class="language-typescript">// experiments.ts: the function you already have -export default function experiments(input: Experiments) { - return { newCheckout: roll(input.newCheckout), /* … */ }; -} - -// blocks.ts: give it a name in a block map -import type { Blocks } from "@decocms/blocks"; -import experiments from "./experiments"; - -export default { experiments } satisfies Blocks;</code></pre> - <p class="sw-result">{{icon:check}}<span>The key <code>experiments</code> is now a block type content can refer to.</span></p> - </div> - <div class="sw-pane" id="step-pane-2" role="tabpanel" aria-labelledby="step-tab-2"> - <div class="sw-term"><span class="sw-prompt" aria-hidden="true">$</span><span>npx deco schema && npx deco content</span></div> - <div class="sw-form" aria-label="The form Studio builds from the schema"> - <div class="swf-head">{{symbol}}<span>Experiments</span><span class="swf-type">experiments</span></div> - <div class="swf-row"><span>New checkout flow</span><span class="swf-num">10</span><span class="swf-range">0–100</span></div> - <div class="swf-row"><span>Sticky header</span><span class="swf-num">50</span><span class="swf-range">0–100</span></div> - <div class="swf-row"><span>Free shipping banner</span><span class="swf-num">0</span><span class="swf-range">0–100</span></div> - </div> - <p class="sw-result">{{icon:check}}<span>Studio shows each experiment as a number field clamped to 0–100.</span></p> - </div> - <div class="sw-pane" id="step-pane-3" role="tabpanel" aria-labelledby="step-tab-3"> -<pre><code class="language-typescript">import { createCMS } from "@decocms/blocks"; -import content from "./.deco/blocks.gen"; -import blocks from "./blocks"; - -// .deco/blocks.gen is generated by `deco content`. -// With a token, production reads the hosted Deco CMS; -// without one, it reads that generated module. -export const cms = createCMS({ - blocks, - content, - // your site's API key for the hosted Deco CMS - token: process.env.DECO_SITE_TOKEN, -});</code></pre> - <p class="sw-result">{{icon:check}}<span>With the hosted Deco CMS (the token above), a commit is live everywhere in seconds, no deploy. Without it, content ships with each deploy.</span></p> - </div> - </div> - </div> - </div> - </div> - </div> - - <div class="lsec hood-sec"> - <div class="container section hood-grid"> - <div class="hood-left"> - <p class="kicker reveal">Yours to keep</p> - <h2 class="reveal" style="--d:60ms">Small on purpose. <span class="dim">Nothing hidden.</span></h2> - <p class="section-lede reveal" style="--d:120ms">Deco Blocks 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.</p> - <div class="own reveal" style="--d:160ms"> - <p class="own-title" id="own-title">Where your site lives</p> - <dl class="own-map" aria-labelledby="own-title"> - <div class="own-row"><dt>Content</dt><dd class="own-where">Your Git repository</dd><dd class="own-tag"><span class="tag tag-yours">Yours</span></dd></div> - <div class="own-row"><dt>Code</dt><dd class="own-where">Your Git repository</dd><dd class="own-tag"><span class="tag tag-yours">Yours</span></dd></div> - <div class="own-row"><dt>Hosting</dt><dd class="own-where">Any JavaScript runtime you choose: Node, Cloudflare Workers, Deno or Bun</dd><dd class="own-tag"><span class="tag tag-yours">Yours</span></dd></div> - <div class="own-row"><dt>Visual editor & publishing without a redeploy</dt><dd class="own-where">Hosted Deco CMS</dd><dd class="own-tag"><span class="tag tag-opt">Optional</span></dd></div> - </dl> - </div> - </div> - <div class="hood-right"> - <div class="hood-links reveal" style="--d:120ms"> - <a href="#model"><span class="hl-body"><strong>Nothing to export</strong><span>Content is plain, documented files you already hold: open them in any editor, search them, or generate them with a script.</span><span class="hl-k">Read: How a page is built from content</span></span>{{icon:arrow-right}}</a> - <a href="#walkthrough"><span class="hl-body"><strong>Small enough to read</strong><span>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.</span><span class="hl-k">Read: How resolution works</span></span>{{icon:arrow-right}}</a> - <a href="#content"><span class="hl-body"><strong>Swap any piece</strong><span>Where content comes from is one option you can change: files in your repository, the hosted Deco CMS, or your own storage, such as a key-value store or a database. Nothing else in your site changes.</span><span class="hl-k">Read: Content & loaders</span></span>{{icon:arrow-right}}</a> - <a href="#decisions"><span class="hl-body"><strong>Openly documented</strong><span>How each part works, and why it was built that way, is written down in these docs, and the source is public on GitHub.</span><span class="hl-k">Read: Design decisions</span></span>{{icon:arrow-right}}</a> - </div> - <a class="text-link hood-more reveal" href="#internals">How it works as a whole{{icon:arrow-right}}</a> - </div> - </div> - </div> - - <div class="final" aria-labelledby="final-title"> - <div class="container final-inner"> - <h2 id="final-title" class="reveal">Start with one function. <span class="dim">Ship it from Git.</span></h2> - <div class="cta reveal" style="--d:120ms"> - <a class="btn btn-primary" href="#quickstart">Read the quickstart →</a> - <a class="btn btn-white" href="#architecture">How it works</a> - </div> - <p class="final-note reveal" style="--d:120ms">The quickstart runs in plain Node, no framework. Or read the <a href="#standalone">API reference</a> · <a href="#internals">Under the hood</a>.</p> - </div> - </div> - - <footer class="site-footer"> - <div class="footer-panel"> - <div class="container"> - <div class="footer-grid"> - <nav class="footer-col" aria-label="Docs"> - <p>Docs</p> - <a href="#architecture">How it works</a><a href="#quickstart">Quickstart</a><a href="#model">Blocks</a><a href="#content">Content & loaders</a><a href="#preview">Preview</a><a href="#standalone">API reference</a> - </nav> - <nav class="footer-col" aria-label="Guides"> - <p>Guides</p> - <a href="#nextjs">Next.js App Router</a><a href="#tanstack-data">TanStack Start</a><a href="#routing">Routing</a><a href="#releases">Releases & deployment</a><a href="#troubleshooting">Troubleshooting</a> - </nav> - <nav class="footer-col" aria-label="Under the hood"> - <p>Under the hood</p> - <a href="#internals">Overview</a><a href="#walkthrough">How resolution works</a><a href="#loader-internals">Loader internals</a><a href="#router-internals">Router internals</a><a href="#decisions">Design decisions</a> - </nav> - <nav class="footer-col" aria-label="Project"> - <p>Project</p> - <a href="https://github.com/decocms/blocks" rel="noopener">GitHub</a><a href="#internals--contributing">Contributing</a><a href="#adoption">Renaming & migrating</a><a href="#roadmap">Roadmap</a> - </nav> - </div> - </div> - <div class="footer-mark" aria-hidden="true">{{wordmark_dark}}</div> - </div> - </footer> -</section> diff --git a/site/src/layout/DocPage.tsx b/site/src/layout/DocPage.tsx new file mode 100644 index 00000000..75778e84 --- /dev/null +++ b/site/src/layout/DocPage.tsx @@ -0,0 +1,29 @@ +import { useEffect } from 'react' +import type { ManifestPage } from '~/build/manifest' +import { 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 <details> 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 ( + <DocsShell nav={sidebarFor(page.version, page.kind, page)} crumbs={breadcrumbFor(page)} pager={pagerFor(page)} rail={railFor(mod.headings)}> + <article className="doc-section doc-page" data-pagefind-body={indexable ? '' : undefined} data-version={page.version} key={page.file}> + <p className="eyebrow">{page.eyebrow}</p> + <Content components={mdxComponents} /> + </article> + </DocsShell> + ) +} diff --git a/site/src/layout/DocsShell.tsx b/site/src/layout/DocsShell.tsx new file mode 100644 index 00000000..d9ef0e57 --- /dev/null +++ b/site/src/layout/DocsShell.tsx @@ -0,0 +1,132 @@ +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 { 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 <TocInline/>. */ + rail: RailItem[] + /** The article, normally <article className="doc-section doc-page">…</article>. */ + children: ReactNode +} + +/** + * The three-column docs layout: sidebar · main (breadcrumb, page tools, article, pager, footer) + * · "On this page" rail. Used by the doc pages and meant for the Roadmap too. Expects the page's + * route chrome to be `layout: 'docs'` (body[data-layout="docs"] switches the CSS grid on). + */ +export function DocsShell({ nav, crumbs, pager, rail, children }: DocsShellProps) { + return ( + <RailContext.Provider value={rail}> + <div className="shell" id="shell"> + <Sidebar groups={nav} /> + <main id="main" tabIndex={-1}> + <div className="doc-top"> + <div className="doc-top-row"> + <Breadcrumb crumbs={crumbs} /> + <PageTools /> + </div> + </div> + {children} + <Pager {...pager} /> + <footer className="doc-footer"> + <span>Deco Blocks</span> + <button + type="button" + className="back-top" + id="back-to-top" + onClick={() => { + window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }) + document.querySelector<HTMLElement>('#main h1')?.focus({ preventScroll: true }) + }} + > + Back to top + <Icon name="arrow-up" /> + </button> + </footer> + </main> + <Rail items={rail} /> + </div> + </RailContext.Provider> + ) +} + +/** + * 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 ( + <> + <div className="shell" id="shell"> + <Sidebar groups={nav} /> + <main id="main" tabIndex={-1}> + {children} + </main> + </div> + {footer} + </> + ) +} + +export function Breadcrumb({ crumbs }: { crumbs: Crumb[] }) { + return ( + <nav className="breadcrumb" aria-label="Breadcrumb"> + <ol id="breadcrumb"> + {crumbs.map((c, i) => { + const last = i === crumbs.length - 1 + return ( + <li key={`${i}-${c.label}`}> + {c.to && !last ? ( + // PlainLink: no aria-current="page" when the crumb's target is the current page + // (the Roadmap's overview, the first Docs page), which would style it as current. + <PlainLink to={c.to}>{c.label}</PlainLink> + ) : ( + <span aria-current={last ? 'page' : undefined}>{c.label}</span> + )} + </li> + ) + })} + </ol> + </nav> + ) +} + +export function Pager({ prev, next }: { prev?: PagerLink; next?: PagerLink }) { + if (!prev && !next) return <nav className="pager" id="pager" aria-label="Previous and next page" hidden /> + return ( + <nav className="pager" id="pager" aria-label="Previous and next page"> + {prev && ( + <Link to={prev.to} hash={prev.hash} className="pg-prev"> + <span className="pg-dir"> + <Icon name="arrow-left" /> + <span>Previous</span> + </span> + <span className="pg-title">{prev.title}</span> + <span className="pg-sub">{prev.sub}</span> + </Link> + )} + {next && ( + <Link to={next.to} hash={next.hash} className="pg-next"> + <span className="pg-dir"> + <span>Next</span> + <Icon name="arrow-right" /> + </span> + <span className="pg-title">{next.title}</span> + <span className="pg-sub">{next.sub}</span> + </Link> + )} + </nav> + ) +} diff --git a/site/src/layout/GlobalUi.tsx b/site/src/layout/GlobalUi.tsx new file mode 100644 index 00000000..03dac198 --- /dev/null +++ b/site/src/layout/GlobalUi.tsx @@ -0,0 +1,99 @@ +import { useEffect, useRef, useState, type ComponentType } from 'react' +import { useRouter } from '@tanstack/react-router' +import { ANNOUNCE_EVENT, openSearch, TOAST_EVENT } from '~/src/lib/ui' + +/** + * The search dialog is owned by components/search/. It's picked up here if that folder exports a + * default component from index.tsx; until then the ⌘K button does nothing visible. + */ +const searchModules = import.meta.glob<{ default: ComponentType }>('/components/search/index.tsx', { eager: true }) +const SearchDialog = Object.values(searchModules)[0]?.default + +/** + * Toast, screen-reader live region, global keyboard shortcuts (⌘K / Ctrl+K / "/"), the search + * dialog, and route-change focus: after a client-side navigation to another page, focus moves to + * the new page's h1 (as the old page did when switching sections) and its title is announced, so + * keyboard and screen-reader users land on the new content instead of the link they left. + */ +export function GlobalUi() { + const router = useRouter() + const [message, setMessage] = useState<string | null>(null) + const [live, setLive] = useState('') + const toastTimer = useRef<ReturnType<typeof setTimeout>>(undefined) + const liveTimer = useRef<ReturnType<typeof setTimeout>>(undefined) + + useEffect(() => { + const announce = (text: string) => { + // Cleared first and set a beat later, so a repeat of the same message is still read out. + setLive('') + clearTimeout(liveTimer.current) + liveTimer.current = setTimeout(() => setLive(text), 60) + } + const onToast = (e: Event) => { + const text = String((e as CustomEvent).detail ?? '') + setMessage(text) + announce(text) + clearTimeout(toastTimer.current) + toastTimer.current = setTimeout(() => setMessage(null), 2200) + } + const onAnnounce = (e: Event) => announce(String((e as CustomEvent).detail ?? '')) + const onKey = (event: KeyboardEvent) => { + const typing = /^(INPUT|TEXTAREA|SELECT)$/.test((document.activeElement as HTMLElement | null)?.tagName ?? '') + const dialogOpen = !!document.querySelector('dialog[open]') + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault() + openSearch() + } else if (event.key === '/' && !typing && !dialogOpen) { + event.preventDefault() + openSearch() + } + } + const offRendered = router.subscribe('onRendered', (event) => { + // Not on the first load, and not for a #hash change on the same page. + if (!event.fromLocation || !event.pathChanged) return + requestAnimationFrame(() => { + // A link to a heading scrolls there; keep focus off the h1 so nothing scrolls it back. + if (!event.toLocation.hash) { + const h1 = document.querySelector<HTMLElement>('#main h1') + if (h1) { + if (!h1.hasAttribute('tabindex')) h1.tabIndex = -1 + h1.focus({ preventScroll: true }) + } + } + announce(document.title.replace(/ — Deco Blocks$/, '')) + }) + }) + window.addEventListener(TOAST_EVENT, onToast) + window.addEventListener(ANNOUNCE_EVENT, onAnnounce) + document.addEventListener('keydown', onKey) + // Print: open every <details> while printing, then restore. + let printed: [HTMLDetailsElement, boolean][] = [] + const before = () => { + printed = [...document.querySelectorAll('details')].map((d) => [d, d.open]) + printed.forEach(([d]) => (d.open = true)) + } + const after = () => printed.forEach(([d, open]) => (d.open = open)) + window.addEventListener('beforeprint', before) + window.addEventListener('afterprint', after) + return () => { + offRendered() + window.removeEventListener(TOAST_EVENT, onToast) + window.removeEventListener(ANNOUNCE_EVENT, onAnnounce) + document.removeEventListener('keydown', onKey) + window.removeEventListener('beforeprint', before) + window.removeEventListener('afterprint', after) + } + }, [router]) + + return ( + <> + {SearchDialog && <SearchDialog />} + <div className="toast" id="toast" aria-hidden="true" hidden={!message}> + {message} + </div> + <p className="sr-only" id="live" role="status" aria-live="polite"> + {live} + </p> + </> + ) +} diff --git a/site/src/layout/Header.tsx b/site/src/layout/Header.tsx new file mode 100644 index 00000000..44b220de --- /dev/null +++ b/site/src/layout/Header.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef, useState } from 'react' +import { Link, useNavigate } from '@tanstack/react-router' +import { PlainLink } from './PlainLink' +import { Icon } from '~/components/ui/Icon' +import { Wordmark } from '~/components/ui/Brand' +import { useChrome, type Tab } from '~/src/lib/chrome' +import { equivalentPath, findPage, kindEntry, versionEntry } from '~/src/lib/content' +import { DEFAULT_VERSION, VERSIONS } from '~/src/lib/versions' +import { openSearch } from '~/src/lib/ui' +import { ThemeToggle } from './ThemeToggle' +import { useMenu } from './Menu' + +export const GITHUB_URL = 'https://github.com/decocms/blocks' + +/** The four site tabs, in the header and (on small screens) in the drawer. */ +export function SiteTabs({ className, label }: { className: string; label: string }) { + const { tab, version } = useChrome() + const docs = kindEntry(version, 'docs')?.path ?? versionEntry(version)?.path ?? '/' + const internals = kindEntry(version, 'internals')?.path ?? docs + const tabs: { id: Tab; label: string; to: string }[] = [ + { id: 'home', label: 'Home', to: '/' }, + { id: 'docs', label: 'Docs', to: docs }, + { id: 'internals', label: 'Under the hood', to: internals }, + { id: 'roadmap', label: 'Roadmap', to: '/roadmap/' }, + ] + return ( + <nav className={className} aria-label={label}> + {tabs.map((t) => ( + <PlainLink key={t.id} to={t.to} data-tab={t.id} aria-current={tab === t.id ? 'page' : undefined}> + {t.label} + </PlainLink> + ))} + </nav> + ) +} + +/** + * Switches docs version: the same page in the other version if it has one, else its index. + * + * A mouse or touch pick navigates at once. Arrow keys on a closed select change its value on + * Windows and Linux, so a keyboard change only takes effect on Enter or when focus leaves the + * select (WCAG 3.2.2): browsing the options never navigates by itself. + */ +export function VersionSelect({ id }: { id: string }) { + const { version, slug } = useChrome() + const navigate = useNavigate() + const [pending, setPending] = useState<string | null>(null) + const fromKeyboard = useRef(false) + useEffect(() => setPending(null), [version]) + const go = (to: string) => { + setPending(null) + if (to !== version) void navigate({ to: equivalentPath(slug, to) }) + } + return ( + <label className="version-select"> + <span className="sr-only">Documentation version</span> + <select + id={id} + value={pending ?? version} + title={VERSIONS.find((v) => v.id === version)?.description} + onKeyDown={(e) => { + if (e.key === 'Enter' && pending !== null) { + e.preventDefault() + go(pending) + } else if (e.key !== 'Tab' && e.key !== 'Enter' && e.key !== 'Escape') { + // A change caused by this key fires before the timeout; a later pick in an open + // popup (macOS) doesn't, and navigates at once like a mouse pick. + fromKeyboard.current = true + setTimeout(() => (fromKeyboard.current = false), 0) + } + }} + onChange={(e) => { + if (fromKeyboard.current) setPending(e.target.value) + else go(e.target.value) + }} + onBlur={() => pending !== null && go(pending)} + > + {VERSIONS.map((v) => ( + <option key={v.id} value={v.id}> + {v.label} + </option> + ))} + </select> + <Icon name="chevron-down" /> + </label> + ) +} + +export function Header() { + const { layout } = useChrome() + const menu = useMenu() + const [scrolled, setScrolled] = useState(false) + useEffect(() => { + let raf = 0 + const on = () => { + if (raf) return + raf = requestAnimationFrame(() => { + raf = 0 + setScrolled(window.scrollY > 4) + }) + } + on() + window.addEventListener('scroll', on, { passive: true }) + return () => window.removeEventListener('scroll', on) + }, []) + // "Get started" goes to the default version's Quickstart, like the old site's #quickstart. + const getStarted: string = findPage(DEFAULT_VERSION, 'quickstart')?.path ?? kindEntry(DEFAULT_VERSION, 'docs')?.path ?? '/' + return ( + <header className={`site-header${layout === 'landing' ? ' on-band' : ''}${scrolled ? ' is-scrolled' : ''}`} id="site-header"> + <div className="hdr-inner"> + <Link className="logo" to="/" aria-label="Deco Blocks home"> + <Wordmark /> + <svg className="slash" viewBox="0 0 12 24" aria-hidden="true" focusable="false"> + <path d="M8.5 4 3.5 20" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" /> + </svg> + <span className="product">Blocks</span> + </Link> + <SiteTabs className="tabs" label="Site sections" /> + <VersionSelect id="version-select" /> + <div className="hdr-actions"> + <button className="search-trigger" type="button" data-search-open onClick={openSearch} aria-label="Search docs (⌘K)" title="Search docs (⌘K)"> + <Icon name="search" /> + <span className="st-label">Search docs…</span> + <kbd>⌘K</kbd> + </button> + <ThemeToggle className="icon-btn theme-btn" id="theme-button" /> + <a className="icon-btn gh-link" href={GITHUB_URL} rel="noopener" aria-label="Deco Blocks on GitHub"> + <Icon name="github" /> + <span className="gh-label" aria-hidden="true"> + GitHub + </span> + </a> + <Link className="hdr-cta" to={getStarted}> + Get started + </Link> + </div> + <button + className="icon-btn menu-btn" + type="button" + id="menu-button" + ref={menu.buttonRef} + aria-controls="sidebar" + aria-expanded={menu.open} + aria-label="Open navigation" + onClick={() => (menu.open ? menu.close(true) : menu.openMenu())} + > + <Icon name="menu" /> + </button> + </div> + </header> + ) +} diff --git a/site/src/layout/Menu.tsx b/site/src/layout/Menu.tsx new file mode 100644 index 00000000..acbd520a --- /dev/null +++ b/site/src/layout/Menu.tsx @@ -0,0 +1,74 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react' +import { useRouterState } from '@tanstack/react-router' +import { MENU_CLOSE_EVENT } from '~/src/lib/ui' + +/** + * The mobile navigation drawer (below 900px the sidebar slides in from the right). While open it is + * modal: body.nav-open, the sidebar becomes role="dialog", and the header, #main and the site footer are inert. + */ +interface MenuState { + open: boolean + openMenu: () => void + close: (restoreFocus?: boolean) => void + buttonRef: RefObject<HTMLButtonElement | null> +} + +const MenuContext = createContext<MenuState | null>(null) + +export function MenuProvider({ children }: { children: ReactNode }) { + const [open, setOpen] = useState(false) + const buttonRef = useRef<HTMLButtonElement | null>(null) + const openMenu = useCallback(() => setOpen(true), []) + const close = useCallback((restoreFocus?: boolean) => { + setOpen((was) => { + if (was && restoreFocus) setTimeout(() => buttonRef.current?.focus(), 0) + return false + }) + }, []) + const href = useRouterState({ select: (s) => s.location.href }) + + useEffect(() => close(false), [href, close]) + + useEffect(() => { + document.body.classList.toggle('nav-open', open) + const behind = [document.getElementById('site-header'), document.getElementById('main'), document.querySelector<HTMLElement>('.site-footer')].filter(Boolean) as HTMLElement[] + behind.forEach((el) => (el.inert = open)) + if (open) { + const t = setTimeout(() => { + const first = + document.querySelector<HTMLElement>('#toc a.active') ?? + document.querySelector<HTMLElement>('.drawer-tabs a') ?? + document.getElementById('menu-close') + first?.focus({ preventScroll: true }) + }, 50) + return () => clearTimeout(t) + } + }, [open]) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') close(true) + } + const onResize = () => { + if (window.innerWidth >= 900) close(false) + } + const onClose = () => close(false) + document.addEventListener('keydown', onKey) + window.addEventListener('resize', onResize) + window.addEventListener(MENU_CLOSE_EVENT, onClose) + return () => { + document.removeEventListener('keydown', onKey) + window.removeEventListener('resize', onResize) + window.removeEventListener(MENU_CLOSE_EVENT, onClose) + } + }, [close]) + + const value = useMemo(() => ({ open, openMenu, close, buttonRef }), [open, openMenu, close]) + return <MenuContext.Provider value={value}>{children}</MenuContext.Provider> +} + +export function useMenu(): MenuState { + const ctx = useContext(MenuContext) + if (!ctx) throw new Error('useMenu outside <MenuProvider>') + return ctx +} diff --git a/site/src/layout/NotFound.tsx b/site/src/layout/NotFound.tsx new file mode 100644 index 00000000..40ae7e6b --- /dev/null +++ b/site/src/layout/NotFound.tsx @@ -0,0 +1,36 @@ +import { useEffect } from 'react' +import { Link } from '@tanstack/react-router' +import { versionEntry } from '~/src/lib/content' +import { DEFAULT_VERSION } from '~/src/lib/versions' +import { sidebarFor } from '~/src/lib/nav' +import { DocsShell } from './DocsShell' + +/** The 404 page (also prerendered to /404.html, which GitHub Pages serves for unknown paths). */ +export function NotFound() { + const docs = versionEntry(DEFAULT_VERSION) + useEffect(() => { + document.title = 'Page not found — Deco Blocks' + }, []) + return ( + <DocsShell nav={sidebarFor(DEFAULT_VERSION, 'docs')} crumbs={[{ label: 'Page not found' }]} rail={[]}> + <article className="doc-section doc-page" data-pagefind-ignore="all"> + <p className="eyebrow">404</p> + <h1 tabIndex={-1}>Page not found</h1> + <p>There's no page at this address. It may have moved when the docs were split into versions.</p> + <ul> + <li> + <Link to="/">Home</Link> + </li> + {docs && ( + <li> + <Link to={docs.path}>Documentation</Link> + </li> + )} + <li> + <Link to="/roadmap/">Roadmap</Link> + </li> + </ul> + </article> + </DocsShell> + ) +} diff --git a/site/src/layout/PageTools.tsx b/site/src/layout/PageTools.tsx new file mode 100644 index 00000000..6cec585c --- /dev/null +++ b/site/src/layout/PageTools.tsx @@ -0,0 +1,45 @@ +import { Icon } from '~/components/ui/Icon' +import { copyText, toast } from '~/src/lib/ui' +import { GITHUB_URL } from './Header' + +async function copyPageLink() { + const url = location.origin + location.pathname + const ok = await copyText(url) + toast(ok ? 'Link copied' : 'Copy the address bar to share this page.') +} + +/** Compact "Copy link" / "Print" pills above the article (shown below 1200px, where the rail is hidden). */ +export function PageTools() { + return ( + <div className="page-tools"> + <button type="button" className="tool-btn" data-copy-link onClick={copyPageLink}> + <Icon name="link" /> + <span>Copy link</span> + </button> + <button type="button" className="tool-btn" data-print onClick={() => window.print()}> + <Icon name="printer" /> + <span>Print</span> + </button> + </div> + ) +} + +/** The rail's page actions. */ +export function PageActions() { + return ( + <> + <button type="button" className="rail-action" data-copy-link onClick={copyPageLink}> + <Icon name="link" /> + Copy link to this page + </button> + <button type="button" className="rail-action" data-print onClick={() => window.print()}> + <Icon name="printer" /> + Print or save as PDF + </button> + <a className="rail-action" href={GITHUB_URL} rel="noopener"> + <Icon name="github" /> + View on GitHub + </a> + </> + ) +} diff --git a/site/src/layout/PlainLink.tsx b/site/src/layout/PlainLink.tsx new file mode 100644 index 00000000..4cc343a8 --- /dev/null +++ b/site/src/layout/PlainLink.tsx @@ -0,0 +1,20 @@ +import type { AnchorHTMLAttributes, MouseEvent } from 'react' +import { useRouter } from '@tanstack/react-router' + +/** + * A router link that leaves `aria-current` to the caller. TanStack's <Link> sets + * aria-current="page" whenever its target is the current URL, which is wrong for the header tabs + * (two tabs can point at the same page while a version is incomplete; the tab comes from the + * route's chrome instead). + */ +export function PlainLink({ to, hash, onClick, ...rest }: { to: string; hash?: string } & AnchorHTMLAttributes<HTMLAnchorElement>) { + const router = useRouter() + const href = router.buildLocation({ to, hash } as never).href + const go = (event: MouseEvent<HTMLAnchorElement>) => { + onClick?.(event) + if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return + event.preventDefault() + router.navigate({ to, hash } as never) + } + return <a href={href} onClick={go} {...rest} /> +} diff --git a/site/src/layout/Rail.tsx b/site/src/layout/Rail.tsx new file mode 100644 index 00000000..7a81681e --- /dev/null +++ b/site/src/layout/Rail.tsx @@ -0,0 +1,148 @@ +import { createContext, useContext, useEffect, useRef, useState, type MouseEvent } from 'react' +import { Icon } from '~/components/ui/Icon' +import type { RailItem } from '~/src/lib/nav' +import { prefersReducedMotion } from '~/src/lib/ui' +import { PageActions } from './PageTools' + +/** The current page's outline, shared by the right rail and the inline outline (TocInline). */ +export const RailContext = createContext<RailItem[]>([]) +export const useRailItems = () => useContext(RailContext) + +const hrefOf = (item: RailItem) => (item.id ? `#${item.id}` : '#main') + +/** "Overview" goes to the top of the article, like the old site, not to the h1's scroll offset. */ +export function onRailClick(item: RailItem, event: MouseEvent<HTMLAnchorElement>) { + if (item.id) return + event.preventDefault() + window.scrollTo({ top: 0, behavior: prefersReducedMotion() ? 'auto' : 'smooth' }) + history.replaceState(history.state, '', location.pathname + location.search) +} + +/** + * Scroll-spy: the last heading whose top has passed a line just under the header is current; at + * the very bottom of the page, the last heading on screen wins (the last ones can't reach the line). + */ +function useScrollSpy(items: RailItem[]): number { + const [current, setCurrent] = useState(0) + useEffect(() => { + let raf = 0 + const update = () => { + raf = 0 + const header = document.getElementById('site-header') + const line = (header?.offsetHeight ?? 64) + 40 + let cur = 0 + for (let i = 1; i < items.length; i++) { + const el = items[i].id ? document.getElementById(items[i].id!) : null + if (el && el.getBoundingClientRect().top <= line) cur = i + } + const doc = document.documentElement + if (scrollY > 0 && doc.scrollHeight > innerHeight + 8 && innerHeight + scrollY >= doc.scrollHeight - 2) { + for (let i = items.length - 1; i > 0; i--) { + const el = items[i].id ? document.getElementById(items[i].id!) : null + if (el && el.getBoundingClientRect().top < innerHeight) { + cur = i + break + } + } + } + setCurrent(cur) + } + const schedule = () => { + if (!raf) raf = requestAnimationFrame(update) + } + update() + window.addEventListener('scroll', schedule, { passive: true }) + window.addEventListener('resize', schedule) + return () => { + cancelAnimationFrame(raf) + window.removeEventListener('scroll', schedule) + window.removeEventListener('resize', schedule) + } + }, [items]) + return current +} + +/** The right-hand "On this page" column (≥ 1200px): outline with scroll-spy, then page actions. */ +export function Rail({ items }: { items: RailItem[] }) { + const current = useScrollSpy(items) + const nav = useRef<HTMLElement>(null) + const list = useRef<HTMLUListElement>(null) + const [indicator, setIndicator] = useState<{ top: number; height: number } | null>(null) + + useEffect(() => { + const a = list.current?.querySelectorAll<HTMLAnchorElement>('a')[current] + if (!a || !a.offsetParent) return + setIndicator({ top: a.offsetTop, height: a.offsetHeight }) + // A long outline scrolls inside the rail; keep the current entry in view, clear of the edges. + const n = nav.current + if (n && n.scrollHeight > n.clientHeight + 1) { + const box = n.getBoundingClientRect() + const r = a.getBoundingClientRect() + if (r.top < box.top + 40 || r.bottom > box.bottom - 24) n.scrollTop += r.top - box.top - box.height / 3 + } + }, [current, items]) + + return ( + <aside className="rail" id="rail" aria-label="On this page"> + <nav className="rail-nav" id="rail-nav" ref={nav} aria-labelledby="rail-title" hidden={items.length < 2}> + <p className="rail-title" id="rail-title"> + <Icon name="list" /> + On this page + </p> + <div className="rail-track"> + <span + className={`rail-indicator${indicator ? ' on' : ''}`} + id="rail-indicator" + aria-hidden="true" + style={indicator ? { transform: `translateY(${indicator.top}px)`, height: indicator.height } : undefined} + /> + <ul id="rail-list" ref={list}> + {items.map((item, i) => ( + <li key={item.id ?? '_top'} className={item.depth === 3 ? 'is-sub' : undefined}> + <a + href={hrefOf(item)} + className={i === current ? 'active' : undefined} + aria-current={i === current ? 'location' : undefined} + onClick={(e) => onRailClick(item, e)} + dangerouslySetInnerHTML={{ __html: item.html }} + /> + </li> + ))} + </ul> + </div> + </nav> + <div className="rail-foot"> + <PageActions /> + </div> + </aside> + ) +} + +/** The collapsible outline under the title and lede, shown below 1200px instead of the rail. */ +export function TocInlineView({ items }: { items: RailItem[] }) { + const ref = useRef<HTMLDetailsElement>(null) + if (items.length < 2) return null + return ( + <details className="toc-inline" id="toc-inline" ref={ref}> + <summary> + <Icon name="list" /> + <span>On this page</span> + <Icon name="chevron-down" /> + </summary> + <ul id="toc-inline-list"> + {items.map((item) => ( + <li key={item.id ?? '_top'} className={item.depth === 1 ? 'is-top' : item.depth === 3 ? 'is-sub' : undefined}> + <a + href={hrefOf(item)} + onClick={(e) => { + if (ref.current) ref.current.open = false + onRailClick(item, e) + }} + dangerouslySetInnerHTML={{ __html: item.html }} + /> + </li> + ))} + </ul> + </details> + ) +} diff --git a/site/src/layout/Sidebar.tsx b/site/src/layout/Sidebar.tsx new file mode 100644 index 00000000..6f0669a6 --- /dev/null +++ b/site/src/layout/Sidebar.tsx @@ -0,0 +1,110 @@ +import { useEffect, useId, useRef, type KeyboardEvent } from 'react' +import { Link } from '@tanstack/react-router' +import { Icon } from '~/components/ui/Icon' +import type { NavGroup } from '~/src/lib/nav' +import { openSearch } from '~/src/lib/ui' +import { GITHUB_URL, SiteTabs, VersionSelect } from './Header' +import { ThemeToggle } from './ThemeToggle' +import { useMenu } from './Menu' + +/** + * The left sidebar (≥ 900px) and mobile drawer (< 900px): drawer head, the site tabs and tools + * (drawer only), then the navigation groups. + */ +export function Sidebar({ groups, label = 'Documentation navigation' }: { groups: NavGroup[]; label?: string }) { + const menu = useMenu() + const toc = useRef<HTMLElement>(null) + const uid = useId() + + // Edge fades where the list continues past the top or bottom, and the current link kept in view. + useEffect(() => { + const el = toc.current + if (!el) return + const fade = () => { + const max = el.scrollHeight - el.clientHeight + el.classList.toggle('more-above', el.scrollTop > 1) + el.classList.toggle('more-below', max > 1 && el.scrollTop < max - 1) + } + const active = el.querySelector<HTMLElement>('a.active') + if (active) { + const box = el.getBoundingClientRect() + const r = active.getBoundingClientRect() + if ((r.top < box.top + 28 && el.scrollTop > 0) || r.bottom > box.bottom - 48) el.scrollTop += r.top - box.top - box.height / 3 + } + fade() + el.addEventListener('scroll', fade, { passive: true }) + window.addEventListener('resize', fade) + return () => { + el.removeEventListener('scroll', fade) + window.removeEventListener('resize', fade) + } + }, [groups]) + + // Focus stays inside the drawer while it's open. + const trap = (event: KeyboardEvent<HTMLElement>) => { + if (event.key !== 'Tab' || !menu.open) return + const focusables = [...event.currentTarget.querySelectorAll<HTMLElement>('a[href], button, select')].filter((el) => el.offsetParent !== null) + const first = focusables[0] + const last = focusables[focusables.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first.focus() + } + } + + return ( + // A <div> with an explicit role, not an <aside>: it's a complementary landmark normally and a + // modal dialog while it's the open drawer, and role="dialog" isn't allowed on <aside>. + <div className="sidebar" id="sidebar" aria-label={label} role={menu.open ? 'dialog' : 'complementary'} aria-modal={menu.open ? true : undefined} onKeyDown={trap}> + <div className="drawer-head"> + <span className="drawer-title">Menu</span> + <button className="icon-btn" type="button" id="menu-close" aria-label="Close navigation" onClick={() => menu.close(true)}> + <Icon name="x" /> + </button> + </div> + <SiteTabs className="drawer-tabs" label="Site sections (menu)" /> + <div className="drawer-tools"> + <button className="drawer-search" type="button" data-search-open onClick={openSearch}> + <Icon name="search" /> + <span>Search docs…</span> + </button> + <VersionSelect id="version-select-drawer" /> + <div className="drawer-row"> + <a className="drawer-link" href={GITHUB_URL} rel="noopener"> + <Icon name="github" /> + GitHub + </a> + <ThemeToggle className="drawer-link theme-btn" withLabel /> + </div> + </div> + <nav id="toc" ref={toc} aria-label="Documentation sections"> + {groups.map((g, gi) => ( + <div className="nav-group" key={g.title}> + <p className="nav-label" id={`nav-group-${uid}-${gi}`}> + {g.title} + </p> + <ul className="nav-list" aria-labelledby={`nav-group-${uid}-${gi}`}> + {g.items.map((item) => ( + <li key={item.to + (item.hash ?? '')}> + <Link + to={item.to} + hash={item.hash} + className={item.active ? 'active' : undefined} + aria-current={item.active ? 'page' : undefined} + activeOptions={{ exact: true, includeHash: true }} + activeProps={{}} + > + {item.label} + </Link> + </li> + ))} + </ul> + </div> + ))} + </nav> + </div> + ) +} diff --git a/site/src/layout/ThemeToggle.tsx b/site/src/layout/ThemeToggle.tsx new file mode 100644 index 00000000..8e79ed2f --- /dev/null +++ b/site/src/layout/ThemeToggle.tsx @@ -0,0 +1,30 @@ +import { useEffect, useState } from 'react' +import { Icon } from '~/components/ui/Icon' +import { effectiveTheme, setTheme, THEME_CHANGE_EVENT, type Theme } from '~/src/lib/theme' + +/** + * The theme toggle. Both icons render; CSS (--icon-sun / --icon-moon) shows the right one for the + * active theme, so the server HTML is correct before hydration. `withLabel` adds the drawer's text. + */ +export function ThemeToggle({ className, id, withLabel }: { className: string; id?: string; withLabel?: boolean }) { + const [next, setNext] = useState<Theme>('dark') + useEffect(() => { + const sync = () => setNext(effectiveTheme() === 'dark' ? 'light' : 'dark') + sync() + const mq = window.matchMedia?.('(prefers-color-scheme: dark)') + mq?.addEventListener?.('change', sync) + window.addEventListener(THEME_CHANGE_EVENT, sync) + return () => { + mq?.removeEventListener?.('change', sync) + window.removeEventListener(THEME_CHANGE_EVENT, sync) + } + }, []) + const label = `Switch to ${next} mode` + return ( + <button className={className} id={id} type="button" data-theme-toggle aria-label={label} title={label} onClick={() => setTheme(effectiveTheme() === 'dark' ? 'light' : 'dark')}> + <Icon name="sun" /> + <Icon name="moon" /> + {withLabel && <span className="theme-label">{next === 'dark' ? 'Dark mode' : 'Light mode'}</span>} + </button> + ) +} diff --git a/site/src/lib/chrome.ts b/site/src/lib/chrome.ts new file mode 100644 index 00000000..7e675153 --- /dev/null +++ b/site/src/lib/chrome.ts @@ -0,0 +1,44 @@ +/** + * "Chrome" = what the root layout needs to know about the current page before rendering it: + * the layout (landing or docs), which header tab is current, and the docs version (for the version + * select). A route declares it in `staticData.chrome`, or returns it from its loader as + * `{ chrome }` when it depends on the URL (the doc pages do). The root reads the deepest match. + */ +import { useRouterState } from '@tanstack/react-router' +import { DEFAULT_VERSION } from './versions' + +export type Tab = 'home' | 'docs' | 'internals' | 'roadmap' | 'none' + +export interface Chrome { + /** `landing`: full-bleed page, floating header over a dark band. `docs`: three-column docs shell. */ + layout: 'landing' | 'docs' + /** The header tab marked current. */ + tab: Tab + /** Docs version shown in the version select (defaults to DEFAULT_VERSION). */ + version?: string + /** Current page slug inside the version, so switching versions can keep the page. */ + slug?: string +} + +declare module '@tanstack/react-router' { + interface StaticDataRouteOption { + chrome?: Chrome + } +} + +const FALLBACK: Chrome = { layout: 'docs', tab: 'none' } + +export function useChrome(): Chrome & { version: string } { + const chrome = useRouterState({ + select: (s) => { + for (let i = s.matches.length - 1; i >= 0; i--) { + const m = s.matches[i] + const fromLoader = (m.loaderData as { chrome?: Chrome } | undefined)?.chrome + if (fromLoader) return fromLoader + if (m.staticData?.chrome) return m.staticData.chrome + } + return FALLBACK + }, + }) + return { ...chrome, version: chrome.version ?? DEFAULT_VERSION } +} diff --git a/site/src/lib/content.ts b/site/src/lib/content.ts new file mode 100644 index 00000000..34531cf3 --- /dev/null +++ b/site/src/lib/content.ts @@ -0,0 +1,103 @@ +/** + * Content at runtime: the manifest (from frontmatter, built by build/manifest.ts) and the MDX page + * modules, loaded on demand (one JS chunk per page). + * + * A page module must be loaded before its route renders, so rendering never suspends: + * - the $version/$slug route's loader awaits `loadPage()` (server render and client navigation); + * - the router's `hydrate` hook (src/router.tsx) awaits `preloadForPath()` before hydrating. + * Then `getLoadedPage()` returns it synchronously. + */ +import type { ComponentType } from 'react' +import type { MDXComponents } from 'mdx/types' +import manifest from 'virtual:content-manifest' +import type { ManifestPage, VersionManifest } from '~/build/manifest' +import type { DocHeading } from '~/build/rehype-docs' +import { DEFAULT_VERSION, VERSIONS } from './versions' + +export { manifest } +export type { ManifestPage, VersionManifest, DocHeading } + +export interface PageModule { + default: ComponentType<{ components?: MDXComponents }> + headings: DocHeading[] +} + +const modules = import.meta.glob<PageModule>('/content/*/*.mdx') +const loaded = new Map<string, PageModule>() + +export const getVersion = (version: string): VersionManifest | undefined => manifest.versions[version] + +export function findPage(version: string, slug: string): ManifestPage | undefined { + return manifest.versions[version]?.pages.find((p) => p.slug === slug) +} + +export async function loadPage(page: ManifestPage): Promise<PageModule> { + const hit = loaded.get(page.file) + if (hit) return hit + const load = modules[`/${page.file}`] + if (!load) throw new Error(`No module for ${page.file}`) + const mod = await load() + loaded.set(page.file, mod) + return mod +} + +export function getLoadedPage(page: ManifestPage): PageModule { + const mod = loaded.get(page.file) + if (!mod) throw new Error(`${page.file} was rendered before it was loaded`) + return mod +} + +/** Strips the router base path ("/blocks/") from a pathname. */ +export function stripBase(pathname: string): string { + const base = import.meta.env.BASE_URL.replace(/\/$/, '') + return base && pathname.startsWith(base) ? pathname.slice(base.length) || '/' : pathname +} + +/** + * A URL slug as written -> the page slug: GitHub Pages also serves `/next/quickstart` as + * `/next/quickstart.html` (and a version index as `/next/index.html`), so both name the same page. + */ +export const normalizeSlug = (slug: string): string => slug.replace(/\.html$/, '').replace(/^index$/, '') + +/** The page a version's index (`/next/`) shows: its index.mdx, else its first page. */ +export function versionEntry(version: string): ManifestPage | undefined { + const v = manifest.versions[version] + return v?.pages.find((p) => p.slug === '') ?? v?.pages[0] +} + +/** The page a route shows for (version, slug): the same resolution as the $version routes' loader. */ +export function resolvePage(version: string, slug: string): ManifestPage | undefined { + const s = normalizeSlug(slug) + return s === '' ? versionEntry(version) : findPage(version, s) +} + +/** + * Finds the content page for a router path (`/next/quickstart`, `/next/quickstart.html`, `/v7/`, + * `/v7`), if any. + */ +export function pageForPath(pathname: string): ManifestPage | undefined { + const m = /^\/([^/]+)(?:\/([^/]*))?\/?$/.exec(pathname) + if (!m) return undefined + return resolvePage(m[1], decodeURIComponent(m[2] ?? '')) +} + +export async function preloadForPath(pathname: string): Promise<void> { + const page = pageForPath(stripBase(pathname)) + if (page) await loadPage(page) +} + +/** The first page of a kind ("docs" / "internals") in a version, falling back to the default version. */ +export function kindEntry(version: string, kind: 'docs' | 'internals'): ManifestPage | undefined { + const inVersion = manifest.versions[version]?.pages.find((p) => p.kind === kind && p.slug !== '') + if (inVersion) return inVersion + if (kind === 'docs') return versionEntry(version) + return manifest.versions[DEFAULT_VERSION]?.pages.find((p) => p.kind === kind) +} + +/** Where the version select goes: the same page in the other version if it exists, else its index. */ +export function equivalentPath(slug: string | undefined, toVersion: string): string { + const same = slug !== undefined ? findPage(toVersion, slug) : undefined + return (same ?? versionEntry(toVersion))?.path ?? `/${toVersion}/` +} + +export const versionIds = VERSIONS.map((v) => v.id) diff --git a/site/src/lib/docRoute.ts b/site/src/lib/docRoute.ts new file mode 100644 index 00000000..3364df81 --- /dev/null +++ b/site/src/lib/docRoute.ts @@ -0,0 +1,25 @@ +import { notFound } from '@tanstack/react-router' +import type { Chrome } from './chrome' +import { loadPage, resolvePage } from './content' +import { documentTitle } from './nav' +import { isVersion } from './versions' + +/** Shared loader for /$version/$slug and /$version/ (slug ''). Throws notFound() for unknown pages. */ +export async function loadDocRoute(version: string, slug: string) { + if (!isVersion(version)) throw notFound() + const page = resolvePage(version, slug) + if (!page) throw notFound() + await loadPage(page) + const chrome: Chrome = { layout: 'docs', tab: page.kind, version, slug: page.slug } + // The page's own URL: /next/ shows /next/architecture, and /next/quickstart.html is /next/quickstart. + const canonical = `${import.meta.env.BASE_URL}${page.path.replace(/^\//, '')}` + return { file: page.file, version, slug: page.slug, chrome, title: documentTitle(page), description: page.description, canonical } +} + +export function docHead(data: Awaited<ReturnType<typeof loadDocRoute>> | undefined) { + if (!data) return {} + return { + meta: [{ title: data.title }, ...(data.description ? [{ name: 'description', content: data.description }] : [])], + links: [{ rel: 'canonical', href: data.canonical }], + } +} diff --git a/site/src/lib/nav.ts b/site/src/lib/nav.ts new file mode 100644 index 00000000..d8f1e30e --- /dev/null +++ b/site/src/lib/nav.ts @@ -0,0 +1,96 @@ +/** + * Navigation models shared by every docs-style page (doc pages, the Roadmap): + * sidebar groups, breadcrumb, pager and the "On this page" rail. The shell components in + * src/layout render these; pages build them (doc pages from the manifest, the Roadmap from its data). + */ +import type { ManifestPage } from '~/build/manifest' +import type { DocHeading } from '~/build/rehype-docs' +import { getVersion, kindEntry } from './content' + +/** `to` is a router path without the base path (TanStack's <Link> adds it). */ +export interface NavItem { + label: string + to: string + hash?: string + /** Marks the current page (bold, dot, aria-current="page"). */ + active?: boolean +} +export interface NavGroup { + title: string + items: NavItem[] +} +export interface Crumb { + label: string + to?: string +} +export interface PagerLink { + to: string + hash?: string + title: string + /** The small line under the title, e.g. "Docs › Core concepts". */ + sub: string +} +export interface RailItem { + /** Element id to scroll to and spy on. `null` = the top of the article ("Overview"). */ + id: string | null + /** Label markup (may contain <code>). */ + html: string + depth: 1 | 2 | 3 +} + +export const KIND_LABELS = { docs: 'Docs', internals: 'Under the hood' } as const + +export const ROADMAP_PAGER: PagerLink = { to: '/roadmap/', title: 'Roadmap to the next major', sub: 'Roadmap' } + +export function sidebarFor(version: string, kind: 'docs' | 'internals', current?: ManifestPage): NavGroup[] { + const v = getVersion(version) + if (!v) return [] + return v.groups + .filter((g) => g.kind === kind) + .map((g) => ({ + title: g.title, + items: g.pages + .map((slug) => v.pages.find((p) => p.slug === slug)!) + .map((p) => ({ label: p.nav, to: p.path, active: current?.file === p.file })), + })) +} + +export function breadcrumbFor(page: ManifestPage): Crumb[] { + const kindLabel = KIND_LABELS[page.kind] + const entry = kindEntry(page.version, page.kind) + const crumbs: Crumb[] = [{ label: kindLabel, to: entry?.path }] + if (page.group !== kindLabel && page.group !== page.nav) crumbs.push({ label: page.group }) + crumbs.push({ label: page.nav }) + return crumbs +} + +export function pagerCard(p: ManifestPage): PagerLink { + const kindLabel = KIND_LABELS[p.kind] + // A page alone in a group of its own name goes by its title, where the label alone says little. + const title = p.group === p.nav ? p.title : p.nav + const sub = p.group !== kindLabel && p.group !== p.nav ? `${kindLabel} › ${p.group}` : kindLabel + return { to: p.path, title, sub } +} + +/** Reading order: Docs pages, then Under the hood; the next major's last page leads to the Roadmap. */ +export function pagerFor(page: ManifestPage): { prev?: PagerLink; next?: PagerLink } { + const pages = getVersion(page.version)?.pages ?? [] + const i = pages.findIndex((p) => p.file === page.file) + const prev = i > 0 ? pagerCard(pages[i - 1]) : undefined + let next = i >= 0 && i < pages.length - 1 ? pagerCard(pages[i + 1]) : undefined + if (!next && page.version === 'next') next = ROADMAP_PAGER + return { prev, next } +} + +export function railFor(headings: DocHeading[]): RailItem[] { + return [{ id: null, html: 'Overview', depth: 1 as const }, ...headings.map((h) => ({ id: h.id, html: h.html, depth: h.depth }))] +} + +/** + * The document title the old site used: "<nav> — Deco Blocks", or the tab name for an "Overview". + * A version's own index.mdx (e.g. "Deco Blocks v7") goes by its title, which names the version. + */ +export function documentTitle(page: ManifestPage): string { + if (page.slug === '') return `${page.title} — Documentation` + return `${page.nav === 'Overview' ? KIND_LABELS[page.kind] : page.nav} — Deco Blocks` +} diff --git a/site/src/lib/theme.ts b/site/src/lib/theme.ts new file mode 100644 index 00000000..bb9844b6 --- /dev/null +++ b/site/src/lib/theme.ts @@ -0,0 +1,29 @@ +/** + * Light/dark theme. No attribute = follow the OS (prefers-color-scheme); data-theme="light|dark" + * on <html> = the reader's choice, remembered in localStorage. `?theme=dark|light` in the URL + * overrides both (handy for screenshots), without being saved. + */ +export const THEME_KEY = 'deco-blocks-docs-theme' + +/** Runs in <head> before first paint (inlined by __root.tsx), so there's no flash of the wrong theme. */ +export const THEME_INIT_SCRIPT = `(function(){var d=document.documentElement,t=null;d.classList.add('js');try{var q=new URLSearchParams(location.search).get('theme');t=(q==='dark'||q==='light')?q:localStorage.getItem('${THEME_KEY}');}catch(e){}if(t==='dark'||t==='light')d.setAttribute('data-theme',t);})();` + +export type Theme = 'light' | 'dark' + +export function effectiveTheme(): Theme { + const t = document.documentElement.dataset.theme + if (t === 'dark' || t === 'light') return t + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +export const THEME_CHANGE_EVENT = 'docs:theme-change' + +export function setTheme(theme: Theme) { + document.documentElement.dataset.theme = theme + try { + localStorage.setItem(THEME_KEY, theme) + } catch { + /* private mode */ + } + window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: theme })) +} diff --git a/site/src/lib/ui.ts b/site/src/lib/ui.ts new file mode 100644 index 00000000..a706448b --- /dev/null +++ b/site/src/lib/ui.ts @@ -0,0 +1,52 @@ +/** + * Small browser-side helpers shared by the shell and by components in other folders. + * + * toast('Link copied') the bottom toast (also announced to screen readers) + * announce('Copied') screen-reader-only announcement (the #live region) + * copyText(text) clipboard with a textarea fallback; resolves to success + * openSearch() asks the search dialog to open (dispatches SEARCH_OPEN_EVENT) + * closeMenu() closes the mobile drawer if open + * + * They're DOM events, so the shell (src/layout/*) and feature folders (components/search, …) + * don't import each other. + */ +export const TOAST_EVENT = 'docs:toast' +export const ANNOUNCE_EVENT = 'docs:announce' +/** Fired by the ⌘K button, the drawer's search button, and the keyboard shortcuts (⌘K, Ctrl+K, /). */ +export const SEARCH_OPEN_EVENT = 'docs:search-open' +export const MENU_CLOSE_EVENT = 'docs:menu-close' + +const fire = (name: string, detail?: unknown) => { + if (typeof window !== 'undefined') window.dispatchEvent(new CustomEvent(name, { detail })) +} + +export const toast = (message: string) => fire(TOAST_EVENT, message) +export const announce = (message: string) => fire(ANNOUNCE_EVENT, message) +export const openSearch = () => fire(SEARCH_OPEN_EVENT) +export const closeMenu = () => fire(MENU_CLOSE_EVENT) + +export async function copyText(text: string): Promise<boolean> { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + const field = document.createElement('textarea') + field.value = text + field.setAttribute('readonly', '') + field.style.position = 'fixed' + field.style.opacity = '0' + document.body.append(field) + field.select() + let ok = false + try { + ok = document.execCommand('copy') + } catch { + ok = false + } + field.remove() + return ok + } +} + +export const prefersReducedMotion = () => + typeof window !== 'undefined' && !!window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches diff --git a/site/src/lib/versions.ts b/site/src/lib/versions.ts new file mode 100644 index 00000000..665805f2 --- /dev/null +++ b/site/src/lib/versions.ts @@ -0,0 +1,23 @@ +/** + * Documentation versions. Each one is a folder under content/ and a URL prefix (/next/…, /v7/…). + * The header's version select lists them in this order. Adding a version = adding an entry here + * and a content/<id>/ folder (with at least an index.mdx). + */ +export interface DocsVersion { + /** URL segment and content folder name. */ + id: string + /** What the version select shows. */ + label: string + /** Shown as a note under the select's label for screen readers and as its title. */ + description: string +} + +export const VERSIONS: readonly DocsVersion[] = [ + { id: 'next', label: 'Next major', description: 'The proposed API for the next major version (unreleased)' }, + { id: 'v7', label: 'v7', description: 'The current released version' }, +] + +/** The version the Home page, the header tabs and "Get started" point at. */ +export const DEFAULT_VERSION = 'next' + +export const isVersion = (id: string): boolean => VERSIONS.some((v) => v.id === id) diff --git a/site/src/routeTree.gen.ts b/site/src/routeTree.gen.ts new file mode 100644 index 00000000..5adc2fa6 --- /dev/null +++ b/site/src/routeTree.gen.ts @@ -0,0 +1,147 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as IndexRouteImport } from './routes/index' +import { Route as VersionIndexRouteImport } from './routes/$version/index' +import { Route as VersionSlugRouteImport } from './routes/$version/$slug' +import { Route as RoadmapIndexRouteImport } from './routes/roadmap/index' +import { Route as RoadmapSectionRouteImport } from './routes/roadmap/$section' + +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const VersionIndexRoute = VersionIndexRouteImport.update({ + id: '/$version/', + path: '/$version/', + getParentRoute: () => rootRouteImport, +} as any) +const VersionSlugRoute = VersionSlugRouteImport.update({ + id: '/$version/$slug', + path: '/$version/$slug', + getParentRoute: () => rootRouteImport, +} as any) +const RoadmapIndexRoute = RoadmapIndexRouteImport.update({ + id: '/roadmap/', + path: '/roadmap/', + getParentRoute: () => rootRouteImport, +} as any) +const RoadmapSectionRoute = RoadmapSectionRouteImport.update({ + id: '/roadmap/$section', + path: '/roadmap/$section', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/$version/$slug': typeof VersionSlugRoute + '/roadmap/$section': typeof RoadmapSectionRoute + '/$version/': typeof VersionIndexRoute + '/roadmap/': typeof RoadmapIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/$version/$slug': typeof VersionSlugRoute + '/roadmap/$section': typeof RoadmapSectionRoute + '/$version': typeof VersionIndexRoute + '/roadmap': typeof RoadmapIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/$version/$slug': typeof VersionSlugRoute + '/roadmap/$section': typeof RoadmapSectionRoute + '/$version/': typeof VersionIndexRoute + '/roadmap/': typeof RoadmapIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + '/' | '/$version/$slug' | '/roadmap/$section' | '/$version/' | '/roadmap/' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/$version/$slug' | '/roadmap/$section' | '/$version' | '/roadmap' + id: + | '__root__' + | '/' + | '/$version/$slug' + | '/roadmap/$section' + | '/$version/' + | '/roadmap/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + VersionSlugRoute: typeof VersionSlugRoute + RoadmapSectionRoute: typeof RoadmapSectionRoute + VersionIndexRoute: typeof VersionIndexRoute + RoadmapIndexRoute: typeof RoadmapIndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/$version/': { + id: '/$version/' + path: '/$version' + fullPath: '/$version/' + preLoaderRoute: typeof VersionIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/$version/$slug': { + id: '/$version/$slug' + path: '/$version/$slug' + fullPath: '/$version/$slug' + preLoaderRoute: typeof VersionSlugRouteImport + parentRoute: typeof rootRouteImport + } + '/roadmap/': { + id: '/roadmap/' + path: '/roadmap' + fullPath: '/roadmap/' + preLoaderRoute: typeof RoadmapIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/roadmap/$section': { + id: '/roadmap/$section' + path: '/roadmap/$section' + fullPath: '/roadmap/$section' + preLoaderRoute: typeof RoadmapSectionRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + VersionSlugRoute: VersionSlugRoute, + RoadmapSectionRoute: RoadmapSectionRoute, + VersionIndexRoute: VersionIndexRoute, + RoadmapIndexRoute: RoadmapIndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes<FileRouteTypes>() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited<ReturnType<typeof getRouter>> + } +} diff --git a/site/src/router.tsx b/site/src/router.tsx new file mode 100644 index 00000000..8d25e9c2 --- /dev/null +++ b/site/src/router.tsx @@ -0,0 +1,39 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import { preloadForPath } from './lib/content' +import { NotFound } from './layout/NotFound' + +/** + * GitHub Pages also serves a page under its file name (/next/quickstart.html, /next/index.html). + * Before the router reads the URL, show the page's own URL instead, which is also the URL its + * HTML was prerendered for, so hydration matches it. + */ +function canonicalizeFileUrl() { + if (typeof window === 'undefined') return + const { pathname, search, hash } = window.location + const clean = pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '') + if (clean !== pathname) window.history.replaceState(window.history.state, '', clean + search + hash) +} + +export function getRouter() { + canonicalizeFileUrl() + return createRouter({ + routeTree, + basepath: import.meta.env.BASE_URL, + trailingSlash: 'preserve', + scrollRestoration: true, + defaultPreload: 'intent', + defaultNotFoundComponent: NotFound, + // Load the current page's MDX chunk before hydrating, so the first client render matches the + // prerendered HTML without suspending (see src/lib/content.ts). + hydrate: async () => { + await preloadForPath(window.location.pathname) + }, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType<typeof getRouter> + } +} diff --git a/site/src/routes/$version/$slug.tsx b/site/src/routes/$version/$slug.tsx new file mode 100644 index 00000000..2c4922e6 --- /dev/null +++ b/site/src/routes/$version/$slug.tsx @@ -0,0 +1,16 @@ +import { createFileRoute } from '@tanstack/react-router' +import { findPage } from '~/src/lib/content' +import { docHead, loadDocRoute } from '~/src/lib/docRoute' +import { DocPage } from '~/src/layout/DocPage' + +/** A doc page: /next/quickstart → content/next/quickstart.mdx. */ +export const Route = createFileRoute('/$version/$slug')({ + loader: ({ params }) => loadDocRoute(params.version, params.slug), + head: ({ loaderData }) => docHead(loaderData), + component: DocRoute, +}) + +function DocRoute() { + const { version, slug } = Route.useLoaderData() + return <DocPage page={findPage(version, slug)!} /> +} diff --git a/site/src/routes/$version/index.tsx b/site/src/routes/$version/index.tsx new file mode 100644 index 00000000..3ce598e8 --- /dev/null +++ b/site/src/routes/$version/index.tsx @@ -0,0 +1,20 @@ +import { createFileRoute } from '@tanstack/react-router' +import { versionEntry } from '~/src/lib/content' +import { docHead, loadDocRoute } from '~/src/lib/docRoute' +import { DocPage } from '~/src/layout/DocPage' + +/** + * A version's index, /next/ or /v7/: its content/<version>/index.mdx, or, without one, its first + * page (rendered here too, so the URL works as a stable entry point for the version select). + */ +export const Route = createFileRoute('/$version/')({ + loader: ({ params }) => loadDocRoute(params.version, ''), + head: ({ loaderData }) => docHead(loaderData), + component: VersionIndex, +}) + +function VersionIndex() { + const { version } = Route.useLoaderData() + const page = versionEntry(version)! + return <DocPage page={page} indexable={page.slug === ''} /> +} diff --git a/site/src/routes/__root.tsx b/site/src/routes/__root.tsx new file mode 100644 index 00000000..65eb9195 --- /dev/null +++ b/site/src/routes/__root.tsx @@ -0,0 +1,85 @@ +/// <reference types="vite/client" /> +import type { ReactNode } from 'react' +import { preconnect } from 'react-dom' +import { HeadContent, Outlet, ScriptOnce, Scripts, createRootRoute } from '@tanstack/react-router' +import appCss from '~/src/styles/app.css?url' +import { THEME_INIT_SCRIPT } from '~/src/lib/theme' +import { useChrome } from '~/src/lib/chrome' +import { Header } from '~/src/layout/Header' +import { MenuProvider, useMenu } from '~/src/layout/Menu' +import { GlobalUi } from '~/src/layout/GlobalUi' +import { NotFound } from '~/src/layout/NotFound' + +const BASE = import.meta.env.BASE_URL + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { name: 'color-scheme', content: 'light dark' }, + { name: 'theme-color', content: '#07401A' }, + { title: 'Deco Blocks — Documentation' }, + { + name: 'description', + content: + 'Deco Blocks: headless for developers, editable for humans, native for AI. Quickstart, blocks, schema generation, preview, SDK reference, routing, and Next.js and TanStack Start guides.', + }, + ], + links: [ + { rel: 'icon', href: `${BASE}favicon.svg`, type: 'image/svg+xml' }, + { rel: 'stylesheet', href: 'https://api.fontshare.com/v2/css?f[]=switzer@1,2&display=swap' }, + { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400..600&display=swap' }, + { rel: 'stylesheet', href: appCss }, + ], + }), + shellComponent: RootDocument, + component: () => <Outlet />, + notFoundComponent: NotFound, +}) + +function RootDocument({ children }: { children: ReactNode }) { + // Through React's resource API, not <link> tags in head(): React writes these at the very top + // of <head>, before the (render-blocking) font stylesheets it hoists, so the connections start first. + preconnect('https://api.fontshare.com', { crossOrigin: 'anonymous' }) + preconnect('https://cdn.fontshare.com', { crossOrigin: 'anonymous' }) + preconnect('https://fonts.googleapis.com') + preconnect('https://fonts.gstatic.com', { crossOrigin: 'anonymous' }) + return ( + <html lang="en" suppressHydrationWarning> + <head> + {/* Runs before first paint (no theme flash); removes itself, so hydration never sees it. */} + <ScriptOnce>{THEME_INIT_SCRIPT}</ScriptOnce> + <HeadContent /> + </head> + <MenuProvider> + <Body>{children}</Body> + </MenuProvider> + </html> + ) +} + +function Body({ children }: { children: ReactNode }) { + const chrome = useChrome() + const menu = useMenu() + return ( + <body data-layout={chrome.layout} data-page={chrome.tab}> + <a + className="skip" + href="#main" + onClick={(e) => { + e.preventDefault() + const target = document.querySelector<HTMLElement>('#main h1') ?? document.getElementById('main') + target?.focus() + }} + > + Skip to content + </a> + <Header /> + <div className="mobile-overlay" id="mobile-overlay" aria-hidden="true" onClick={() => menu.close(true)} /> + {children} + <GlobalUi /> + <Scripts /> + </body> + ) +} diff --git a/site/src/routes/index.tsx b/site/src/routes/index.tsx new file mode 100644 index 00000000..85d7ca93 --- /dev/null +++ b/site/src/routes/index.tsx @@ -0,0 +1,33 @@ +import type { ComponentType } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { LandingShell } from '~/src/layout/DocsShell' +import { sidebarFor } from '~/src/lib/nav' +import { DEFAULT_VERSION } from '~/src/lib/versions' + +/** + * Home. The page itself is owned by components/home/: its index.tsx default export renders + * everything inside <main> plus the site footer (see ARCHITECTURE.md › Home). Until it exists, a + * placeholder shows. + */ +const homeModules = import.meta.glob<{ default: ComponentType }>('/components/home/index.tsx', { eager: true }) +const Home = Object.values(homeModules)[0]?.default + +export const Route = createFileRoute('/')({ + staticData: { chrome: { layout: 'landing', tab: 'home' } }, + head: () => ({ meta: [{ title: 'Deco Blocks — The AI-native headless CMS' }] }), + component: HomeRoute, +}) + +function HomeRoute() { + if (Home) return <Home /> + return ( + <LandingShell nav={sidebarFor(DEFAULT_VERSION, 'docs')}> + <section className="hero band" style={{ minHeight: '60vh' }}> + <div className="container"> + <h1 style={{ color: '#fff', fontWeight: 400 }}>Deco Blocks</h1> + <p style={{ color: 'rgba(255,255,255,.7)' }}>The home page is being ported (components/home/).</p> + </div> + </section> + </LandingShell> + ) +} diff --git a/site/src/routes/roadmap/$section.tsx b/site/src/routes/roadmap/$section.tsx new file mode 100644 index 00000000..38df9698 --- /dev/null +++ b/site/src/routes/roadmap/$section.tsx @@ -0,0 +1,24 @@ +import { createFileRoute, notFound } from '@tanstack/react-router' +import RoadmapPage from '~/components/roadmap/RoadmapPage' +import { roadmapDocumentTitle, sectionForSlug } from '~/components/roadmap/sections' +import roadmapCss from '~/components/roadmap/roadmap.css?url' + +/** /roadmap/blockers, /roadmap/api, …: one Roadmap section per page (components/roadmap/sections.ts). */ +export const Route = createFileRoute('/roadmap/$section')({ + staticData: { chrome: { layout: 'docs', tab: 'roadmap', version: 'next' } }, + loader: ({ params }) => { + const id = sectionForSlug(params.section) + if (!id) throw notFound() + return { id } + }, + head: ({ loaderData }) => ({ + meta: loaderData ? [{ title: roadmapDocumentTitle(loaderData.id) }] : [], + links: [{ rel: 'stylesheet', href: roadmapCss }], + }), + component: RoadmapSection, +}) + +function RoadmapSection() { + const { id } = Route.useLoaderData() + return <RoadmapPage section={id} /> +} diff --git a/site/src/routes/roadmap/index.tsx b/site/src/routes/roadmap/index.tsx new file mode 100644 index 00000000..c274ed5f --- /dev/null +++ b/site/src/routes/roadmap/index.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' +import RoadmapPage from '~/components/roadmap/RoadmapPage' +import { roadmapDocumentTitle } from '~/components/roadmap/sections' +import roadmapCss from '~/components/roadmap/roadmap.css?url' + +/** /roadmap/: the Roadmap's overview (version-less: it's about the next major). */ +export const Route = createFileRoute('/roadmap/')({ + staticData: { chrome: { layout: 'docs', tab: 'roadmap', version: 'next' } }, + head: () => ({ + meta: [{ title: roadmapDocumentTitle('roadmap') }], + links: [{ rel: 'stylesheet', href: roadmapCss }], + }), + component: () => <RoadmapPage section="roadmap" />, +}) diff --git a/site/src/shell.html b/site/src/shell.html deleted file mode 100644 index f22cbe79..00000000 --- a/site/src/shell.html +++ /dev/null @@ -1,140 +0,0 @@ -<!doctype html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <meta name="description" content="Deco Blocks: headless for developers, editable for humans, native for AI. Quickstart, blocks, schema generation, preview, SDK reference, routing, and Next.js and TanStack Start guides."> - <meta name="color-scheme" content="light dark"> - <meta name="theme-color" content="#07401A"> - <title>Deco Blocks — Documentation - - - - - - - - - - - - - {{cobogo}} - - - - - -
    - - -
    -
    -

    Deco Blocks documentation

    -
    - -
    - - -
    -
    -
    - {{icon:list}}On this page{{icon:chevron-down}} -
      -
      -
      -
      -{{landing}} -
      -
      -{{docs}} -
      -
      -{{internals}} -
      -
      -{{roadmap}} -
      - -
      - Deco Blocks - -
      -
      - - -
      - - {{site_footer}} - - -
      {{icon:search}}
      -
      -

      -
      ↑↓ to move↵ to openSearches all documentation, including code examples.
      -
      - -

      - -{{prism}} - - - diff --git a/site/src/styles/app.css b/site/src/styles/app.css new file mode 100644 index 00000000..3cd2b488 --- /dev/null +++ b/site/src/styles/app.css @@ -0,0 +1,68 @@ +/* The stylesheet entry (imported by src/routes/__root.tsx). + Tailwind v4 without its preflight: the ported legacy styles assume browser defaults. Layers, in + increasing precedence: theme < legacy (the old site's CSS) < components < utilities. */ +@layer theme, base, legacy, components, utilities; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); +@import "./tokens.css" layer(base); +@import "./legacy.css" layer(legacy); +@import "./prose.css" layer(legacy); + +@source "../../components"; +@source "../../content"; +/* Legacy class names that are also Tailwind utilities: `.container` (legacy max-width + var(--container)) would get Tailwind's breakpoint max-widths, and `.text-link` (the --color-link + token's text utility) would override `.band .text-link`; the utilities layer wins over legacy. */ +@source not inline("{container,text-link}"); + +/* Dark mode follows the same rule as the tokens: an explicit data-theme wins, otherwise the OS. */ +@custom-variant dark { + &:where([data-theme="dark"], [data-theme="dark"] *) { @slot; } + @media (prefers-color-scheme: dark) { + &:where(:root:not([data-theme="light"]), :root:not([data-theme="light"]) *) { @slot; } + } +} + +/* The site's tokens as Tailwind theme values: bg-bg, text-fg, text-muted-fg, border-border, + bg-forest, text-lime, font-sans, font-mono, … Each resolves to the CSS variable, so it follows + the theme toggle. */ +@theme inline { + --font-sans: "Switzer", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --color-lime: var(--lime); + --color-lime-2: var(--lime-2); + --color-forest: var(--forest); + --color-olive: var(--olive); + --color-olive-ink: var(--olive-ink); + --color-brand: var(--brand); + --color-brand-ink: var(--brand-ink); + --color-bg: var(--bg); + --color-bg-subtle: var(--bg-subtle); + --color-bg-warm: var(--bg-warm); + --color-surface: var(--surface); + --color-muted: var(--muted); + --color-hover: var(--hover); + --color-fg: var(--fg); + --color-fg-body: var(--fg-body); + --color-muted-fg: var(--muted-fg); + --color-faint: var(--faint); + --color-quiet: var(--quiet); + --color-border: var(--border); + --color-border-strong: var(--border-strong); + --color-hairline: var(--hairline); + --color-eyebrow: var(--eyebrow); + --color-link: var(--link); + --color-tint: var(--tint); + --color-tint-fg: var(--tint-fg); + --color-code-bg: var(--code-bg); + --color-code-fg: var(--code-fg); + --color-band-fg: var(--band-fg); + --color-band-muted: var(--band-muted); + --radius-panel: var(--radius); + --shadow-deco-sm: var(--shadow-sm); + --shadow-deco-md: var(--shadow-md); + --shadow-deco-lg: var(--shadow-lg); + --shadow-deco-win: var(--shadow-win); + --ease-deco: var(--ease); + --ease-deco-slow: var(--ease-slow); +} diff --git a/site/src/style.css b/site/src/styles/legacy.css similarity index 96% rename from site/src/style.css rename to site/src/styles/legacy.css index 856f8057..42c8dfb9 100644 --- a/site/src/style.css +++ b/site/src/styles/legacy.css @@ -1,141 +1,9 @@ /* ========================================================================== - Deco Blocks docs + landing, in the decocms.com visual language: - Switzer (light display, tight tracking), forest + lime, warm paper and - hairlines, pill buttons, olive uppercase eyebrows, cobogó texture, - browser-window mocks, gentle entrance motion. + Ported verbatim from the old single-page site (the former Python build's style.css), minus the design tokens + (now in tokens.css) and the rules that switched hash-routed pages. It is the source of the + site's look; new code may use Tailwind utilities (with the same tokens) instead. + Doc pages put their title in an h1 and sections in h2/h3: see prose.css for that mapping. ========================================================================== */ -:root { - --font-sans: "Switzer", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; - --ease: cubic-bezier(.25, 1, .5, 1); - --ease-slow: cubic-bezier(.16, 1, .3, 1); - - /* decocms.com brand */ - --lime: #D0EC1A; - --lime-2: #DCEF63; - --lime-3: #C3DD1F; - --forest: #07401A; - --olive: #5E7500; - --olive-ink: #455600; - --olive-ring: #8CAA25; - --brand: #D0EC1A; - --brand-hover: #DCEF63; - --brand-ink: #07401A; - --band-fg: #FFFFFF; - --band-muted: rgba(255, 255, 255, .76); - --band-subtle: rgba(255, 255, 255, .62); - --band-line: rgba(255, 255, 255, .14); - --band-fill: rgba(255, 255, 255, .07); - --purple: #A595FF; - --yellow: #FFC116; - - /* Paper, ink, hairlines */ - --bg: #FFFFFF; - --bg-subtle: #FAFAF9; - --bg-warm: #F9F6F2; - --surface: #FFFFFF; - --muted: #F4F2EF; - --hover: #EFEAE2; - --fg: #282524; - --fg-body: #3B3633; - --muted-fg: #6E6863; - --faint: #8C8680; - --quiet: #78726E; - --num-faint: #78726E; /* the site's faint index, lifted to 4.7:1 so it reads as text */ - --ol-num: #6E6863; /* docs step numbers: readable (5.3:1), still quieter than body */ - --who-fg: #455600; /* speaker tag in the agent chat mock */ - --mark-bg: rgba(208, 236, 26, .6); /* search-result match highlight */ - --mark-fg: #282524; - --hdr-pill: rgba(24, 21, 19, .8); /* the landing's scrolled header pill */ - --step-on: #07401A; - --stack-fg: rgba(40, 37, 36, .64); - --footer-bg: #FAF9F7; - --border: rgba(40, 37, 36, .10); - --border-strong: rgba(40, 37, 36, .18); - --hairline: rgba(40, 37, 36, .09); - --eyebrow: #5E7500; - --link: #07401A; - --link-line: #8CAA25; - --accent-text: #5E7500; - --nav-active-bg: #EFF6CC; - --nav-active-fg: #07401A; - --nav-bar: #07401A; - --pill-on-bg: #07401A; - --pill-on-fg: #D0EC1A; - --pill-on-ring: transparent; - --ring: #5E7500; - --indicator-ring: rgba(7, 64, 26, .35); - --tint: #EFF6CC; - --tint-fg: #07401A; - - --code-bg: #FAFAF9; - --code-inline: #F2F0ED; - --code-head: #F5F3F0; - --code-border: rgba(40, 37, 36, .09); - --code-fg: #2E2A27; - --code-sel: rgba(208, 236, 26, .5); - - --note-bg: #FAFAF9; - --warn-bg: #FFF8E6; - --warn-border: #F1DC9E; - --warn-icon: #8F6600; - --preview-bg: #EFF6CC; - --preview-border: rgba(140, 170, 37, .38); - --del-bg: rgba(214, 64, 40, .08); - --del-fg: #A3341F; - --add-bg: #EFF6CC; - --th-bg: #FAFAF9; - - --st-bg: #FFFFFF; - --st-fg: #282524; - --st-muted: #6E6863; - --st-border: rgba(40, 37, 36, .09); - --st-field: #FAFAF9; - --st-track: #E7E5E4; - --st-fill: #07401A; - - --win-bg: #F9F6F2; - --win-panel: #FFFFFF; - --win-bar: #FAFAF9; - --win-dot: #E7E5E4; - - --shadow-sm: 0 1px 2px rgba(40, 37, 36, .05); - --shadow-md: 0 1px 2px rgba(40, 37, 36, .04), 0 12px 32px -16px rgba(40, 37, 36, .2); - --shadow-win: 0 50px 110px -36px rgba(14, 26, 16, .35); - --shadow-lg: 0 0 0 1px rgba(40, 37, 36, .06), 0 24px 64px -16px rgba(40, 37, 36, .32); - - /* The site's tri-colour numerals (olive, purple, blue) on warm ink */ - --syn-comment: #78726E; - --syn-keyword: #5A4AC7; - --syn-string: #5E7500; - --syn-type: #31699F; - --syn-function: #31699F; - --syn-number: #9A5B00; - --syn-property: #282524; - --syn-punctuation: #78726E; - --syn-operator: #78726E; - --syn-tag: #A3341F; - - --wm-light: inline-block; - --wm-dark: none; - --icon-sun: none; - --icon-moon: block; - - --header-h: 64px; - --hdr-max: 1440px; - --radius: 12px; - color-scheme: light; -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { -{{dark_tokens}} - } -} -:root[data-theme="dark"] { -{{dark_tokens}} -} - /* ---------- Base ---------- */ .sr-only { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } *, *::before, *::after { box-sizing: border-box; } @@ -300,10 +168,6 @@ body[data-layout="docs"] main { padding: 40px 56px 96px; } /* One column for everything in an article: prose, code, tables, callouts and diagrams share the same left and right edges. Code lines longer than the column scroll inside their panel. */ body[data-layout="docs"] main > * { max-width: 720px; margin-left: auto; margin-right: auto; } -.js .page { display: none; } -.js .page.active { display: block; } -.js .page > section:not(.is-current) { display: none; } -body[data-layout="docs"] .page > section.is-current { animation: article-in .6s var(--ease-slow) both; } @keyframes article-in { from { opacity: 0; transform: translateY(10px); } } /* Jumping to a whole section always lands at the top of the article (breadcrumb visible). */ main .page > section { scroll-margin-top: 400vh; } @@ -658,9 +522,9 @@ tbody tr:hover td { background: color-mix(in oklab, var(--bg-subtle) 70%, transp .trace-stat { display: inline-flex; align-items: center; gap: 8px; font: 400 12px/16px var(--font-mono); color: var(--muted-fg); } .trace-stat::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--brand); box-shadow: 0 0 0 1px var(--indicator-ring); flex: none; } .primary-button { height: 40px; padding: 0 20px; display: inline-flex; align-items: center; gap: 8px; border: 0; border-radius: 999px; background: var(--brand); color: var(--brand-ink); font-size: 14px; font-weight: 500; line-height: 20px; transition: background-color .25s ease, transform .25s var(--ease); text-decoration: none; } -.primary-button:hover:not(:disabled) { background: var(--brand-hover); } -.primary-button:active:not(:disabled) { transform: scale(.97); } -.primary-button:disabled { opacity: .45; cursor: default; } +.primary-button:hover:not(:disabled, [aria-disabled="true"]) { background: var(--brand-hover); } +.primary-button:active:not(:disabled, [aria-disabled="true"]) { transform: scale(.97); } +.primary-button:disabled, .primary-button[aria-disabled="true"] { opacity: .45; cursor: default; } .quiet-button { height: 34px; padding: 0 14px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface); color: var(--fg); font-size: 13px; font-weight: 400; text-decoration: none; } .quiet-button:hover { border-color: var(--border-strong); } @@ -1296,7 +1160,113 @@ dialog::backdrop { background: rgba(14, 26, 16, .3); -webkit-backdrop-filter: bl } @media print { :root, :root:not([data-theme="light"]), :root[data-theme="dark"] { -{{light_tokens}} + --font-sans: "Switzer", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --ease: cubic-bezier(.25, 1, .5, 1); + --ease-slow: cubic-bezier(.16, 1, .3, 1); + --lime: #D0EC1A; + --lime-2: #DCEF63; + --lime-3: #C3DD1F; + --forest: #07401A; + --olive: #5E7500; + --olive-ink: #455600; + --olive-ring: #8CAA25; + --brand: #D0EC1A; + --brand-hover: #DCEF63; + --brand-ink: #07401A; + --band-fg: #FFFFFF; + --band-muted: rgba(255, 255, 255, .76); + --band-subtle: rgba(255, 255, 255, .62); + --band-line: rgba(255, 255, 255, .14); + --band-fill: rgba(255, 255, 255, .07); + --purple: #A595FF; + --yellow: #FFC116; + --bg: #FFFFFF; + --bg-subtle: #FAFAF9; + --bg-warm: #F9F6F2; + --surface: #FFFFFF; + --muted: #F4F2EF; + --hover: #EFEAE2; + --fg: #282524; + --fg-body: #3B3633; + --muted-fg: #6E6863; + --faint: #8C8680; + --quiet: #78726E; + --num-faint: #78726E; + --ol-num: #6E6863; + --who-fg: #455600; + --mark-bg: rgba(208, 236, 26, .6); + --mark-fg: #282524; + --hdr-pill: rgba(24, 21, 19, .8); + --step-on: #07401A; + --stack-fg: rgba(40, 37, 36, .64); + --footer-bg: #FAF9F7; + --border: rgba(40, 37, 36, .10); + --border-strong: rgba(40, 37, 36, .18); + --hairline: rgba(40, 37, 36, .09); + --eyebrow: #5E7500; + --link: #07401A; + --link-line: #8CAA25; + --accent-text: #5E7500; + --nav-active-bg: #EFF6CC; + --nav-active-fg: #07401A; + --nav-bar: #07401A; + --pill-on-bg: #07401A; + --pill-on-fg: #D0EC1A; + --pill-on-ring: transparent; + --ring: #5E7500; + --indicator-ring: rgba(7, 64, 26, .35); + --tint: #EFF6CC; + --tint-fg: #07401A; + --code-bg: #FAFAF9; + --code-inline: #F2F0ED; + --code-head: #F5F3F0; + --code-border: rgba(40, 37, 36, .09); + --code-fg: #2E2A27; + --code-sel: rgba(208, 236, 26, .5); + --note-bg: #FAFAF9; + --warn-bg: #FFF8E6; + --warn-border: #F1DC9E; + --warn-icon: #8F6600; + --preview-bg: #EFF6CC; + --preview-border: rgba(140, 170, 37, .38); + --del-bg: rgba(214, 64, 40, .08); + --del-fg: #A3341F; + --add-bg: #EFF6CC; + --th-bg: #FAFAF9; + --st-bg: #FFFFFF; + --st-fg: #282524; + --st-muted: #6E6863; + --st-border: rgba(40, 37, 36, .09); + --st-field: #FAFAF9; + --st-track: #E7E5E4; + --st-fill: #07401A; + --win-bg: #F9F6F2; + --win-panel: #FFFFFF; + --win-bar: #FAFAF9; + --win-dot: #E7E5E4; + --shadow-sm: 0 1px 2px rgba(40, 37, 36, .05); + --shadow-md: 0 1px 2px rgba(40, 37, 36, .04), 0 12px 32px -16px rgba(40, 37, 36, .2); + --shadow-win: 0 50px 110px -36px rgba(14, 26, 16, .35); + --shadow-lg: 0 0 0 1px rgba(40, 37, 36, .06), 0 24px 64px -16px rgba(40, 37, 36, .32); + --syn-comment: #78726E; + --syn-keyword: #5A4AC7; + --syn-string: #5E7500; + --syn-type: #31699F; + --syn-function: #31699F; + --syn-number: #9A5B00; + --syn-property: #282524; + --syn-punctuation: #78726E; + --syn-operator: #78726E; + --syn-tag: #A3341F; + --wm-light: inline-block; + --wm-dark: none; + --icon-sun: none; + --icon-moon: block; + --header-h: 64px; + --hdr-max: 1440px; + --radius: 12px; + color-scheme: light; --bg: #fff; --bg-subtle: #f6f5f3; --surface: #fff; --muted: #f1f0ee; --fg: #1c1917; --fg-body: #292524; --muted-fg: #57534e; --border: #e2e0dc; --border-strong: #d6d3d1; --hairline: #e7e5e4; --code-bg: #f8f7f5; --code-head: #f1f0ee; --code-fg: #292524; --eyebrow: #5E7500; --link: #07401A; diff --git a/site/src/styles/prose.css b/site/src/styles/prose.css new file mode 100644 index 00000000..d85ae247 --- /dev/null +++ b/site/src/styles/prose.css @@ -0,0 +1,67 @@ +/* ========================================================================== + Doc pages (article.doc-section.doc-page). The old site put each page's title in an h2 and its + sections in h3s; pages now have one h1 and h2/h3 sections, styled exactly like before. The + old `.doc-section h2/h3/h4` rules stay in legacy.css for any markup that still uses them. + ========================================================================== */ +.doc-section.doc-page h1 { + position: relative; margin: 0 0 20px; color: var(--fg); + font-size: clamp(34px, 3.6vw, 48px); line-height: 1.14; font-weight: 400; letter-spacing: -.02em; text-wrap: balance; +} +.doc-section.doc-page h1:focus { outline: none; } +.doc-section.doc-page > h1 + p { font-size: 19px; line-height: 1.6; color: var(--muted-fg); letter-spacing: -.005em; margin: 0 0 28px; } +.doc-section.doc-page > h1 + p strong { color: var(--fg); } +.doc-section.doc-page h2 { + position: relative; margin: 64px 0 14px; padding-top: 0; color: var(--fg); + font-size: 27px; line-height: 1.22; font-weight: 400; letter-spacing: -.015em; text-wrap: balance; +} +.doc-section.doc-page h3 { margin: 32px 0 8px; font-size: 17px; line-height: 24px; font-weight: 500; letter-spacing: -.01em; position: relative; } +.doc-section.doc-page :is(h1, h2, h3) code { font-size: .86em; padding: 0; background: none; border: 0; color: inherit; font-weight: 400; letter-spacing: -.03em; } +h1:hover > .heading-anchor { opacity: 1; } +/* legacy.css styles the paragraph after an h2 as the page's lede; on doc pages that's the h1's job. */ +.doc-section.doc-page > h2 + p { font-size: 16px; line-height: 1.72; color: var(--fg-body); letter-spacing: normal; margin: 16px 0; } +.doc-section.doc-page > h2 + p strong { color: var(--fg); } + +/* The rail and the inline outline: h3 entries sit under their h2. */ +#rail-list li.is-sub a { padding-left: 30px; } +.doc-section .toc-inline li.is-sub a { padding-left: 16px; } + +@media (max-width: 559px) { + .doc-section.doc-page h1 { font-size: 34px; line-height: 1.15; } + .doc-section.doc-page > h1 + p { font-size: 17px; } + .doc-section.doc-page h2 { font-size: 23px; margin-top: 48px; } + .doc-section.doc-page > h2 + p { font-size: 16px; line-height: 1.7; } +} +@media print { + .doc-section.doc-page h1 { font-size: 26pt; line-height: 1.2; } + .doc-section.doc-page h2 { font-size: 16pt; line-height: 1.3; break-after: avoid; margin-top: 24pt; } + .doc-section.doc-page > h1 + p { font-size: 12pt; line-height: 1.5; } + .doc-section.doc-page > h2 + p { font-size: 10.5pt; line-height: 1.55; } + .doc-section.doc-page { break-before: auto; } +} + +/* ---------- Version select (header + drawer) ---------- */ +.version-select { position: relative; display: inline-flex; align-items: center; flex: none; } +.version-select select { + appearance: none; -webkit-appearance: none; height: 32px; padding: 0 30px 0 12px; margin: 0; + border: 1px solid var(--border); border-radius: 999px; background: var(--bg-subtle); color: var(--fg); + font: 400 13px/16px var(--font-sans); letter-spacing: -.005em; cursor: pointer; + transition: border-color .3s ease, background-color .3s ease; +} +.version-select select:hover { border-color: var(--border-strong); } +.version-select select:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } +.version-select .icon { position: absolute; right: 10px; width: 14px; height: 14px; pointer-events: none; color: var(--muted-fg); } +.hdr-inner > .version-select { margin-left: 12px; } +body[data-layout="landing"] .hdr-inner > .version-select { display: none; } +.drawer-tools .version-select select { width: 100%; height: 40px; font-size: 14px; } +.drawer-tools .version-select { width: 100%; } +/* Below 900px the select moves into the drawer. On doc pages the drawer's tools row shows only it + (search, theme and GitHub stay in the header there, as before). */ +@media (max-width: 899px) { + .hdr-inner > .version-select { display: none; } + body[data-layout="docs"] .drawer-tools { display: grid; } + body[data-layout="docs"] .drawer-tools > :not(.version-select) { display: none; } +} +@media print { .version-select { display: none !important; } } + +/* Version index redirect stub */ +.version-redirect { padding: 48px 0; color: var(--muted-fg); } diff --git a/site/src/styles/tokens.css b/site/src/styles/tokens.css new file mode 100644 index 00000000..2325f1ab --- /dev/null +++ b/site/src/styles/tokens.css @@ -0,0 +1,300 @@ +/* ========================================================================== + Deco Blocks docs + landing, in the decocms.com visual language: + Switzer (light display, tight tracking), forest + lime, warm paper and + hairlines, pill buttons, olive uppercase eyebrows, cobogó texture, + browser-window mocks, gentle entrance motion. + ========================================================================== */ +:root { + --font-sans: "Switzer", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + --ease: cubic-bezier(.25, 1, .5, 1); + --ease-slow: cubic-bezier(.16, 1, .3, 1); + + /* decocms.com brand */ + --lime: #D0EC1A; + --lime-2: #DCEF63; + --lime-3: #C3DD1F; + --forest: #07401A; + --olive: #5E7500; + --olive-ink: #455600; + --olive-ring: #8CAA25; + --brand: #D0EC1A; + --brand-hover: #DCEF63; + --brand-ink: #07401A; + --band-fg: #FFFFFF; + --band-muted: rgba(255, 255, 255, .76); + --band-subtle: rgba(255, 255, 255, .62); + --band-line: rgba(255, 255, 255, .14); + --band-fill: rgba(255, 255, 255, .07); + --purple: #A595FF; + --yellow: #FFC116; + + /* Paper, ink, hairlines */ + --bg: #FFFFFF; + --bg-subtle: #FAFAF9; + --bg-warm: #F9F6F2; + --surface: #FFFFFF; + --muted: #F4F2EF; + --hover: #EFEAE2; + --fg: #282524; + --fg-body: #3B3633; + --muted-fg: #6E6863; + --faint: #8C8680; + --quiet: #78726E; + --num-faint: #78726E; /* the site's faint index, lifted to 4.7:1 so it reads as text */ + --ol-num: #6E6863; /* docs step numbers: readable (5.3:1), still quieter than body */ + --who-fg: #455600; /* speaker tag in the agent chat mock */ + --mark-bg: rgba(208, 236, 26, .6); /* search-result match highlight */ + --mark-fg: #282524; + --hdr-pill: rgba(24, 21, 19, .8); /* the landing's scrolled header pill */ + --step-on: #07401A; + --stack-fg: rgba(40, 37, 36, .64); + --footer-bg: #FAF9F7; + --border: rgba(40, 37, 36, .10); + --border-strong: rgba(40, 37, 36, .18); + --hairline: rgba(40, 37, 36, .09); + --eyebrow: #5E7500; + --link: #07401A; + --link-line: #8CAA25; + --accent-text: #5E7500; + --nav-active-bg: #EFF6CC; + --nav-active-fg: #07401A; + --nav-bar: #07401A; + --pill-on-bg: #07401A; + --pill-on-fg: #D0EC1A; + --pill-on-ring: transparent; + --ring: #5E7500; + --indicator-ring: rgba(7, 64, 26, .35); + --tint: #EFF6CC; + --tint-fg: #07401A; + + --code-bg: #FAFAF9; + --code-inline: #F2F0ED; + --code-head: #F5F3F0; + --code-border: rgba(40, 37, 36, .09); + --code-fg: #2E2A27; + --code-sel: rgba(208, 236, 26, .5); + + --note-bg: #FAFAF9; + --warn-bg: #FFF8E6; + --warn-border: #F1DC9E; + --warn-icon: #8F6600; + --preview-bg: #EFF6CC; + --preview-border: rgba(140, 170, 37, .38); + --del-bg: rgba(214, 64, 40, .08); + --del-fg: #A3341F; + --add-bg: #EFF6CC; + --th-bg: #FAFAF9; + + --st-bg: #FFFFFF; + --st-fg: #282524; + --st-muted: #6E6863; + --st-border: rgba(40, 37, 36, .09); + --st-field: #FAFAF9; + --st-track: #E7E5E4; + --st-fill: #07401A; + + --win-bg: #F9F6F2; + --win-panel: #FFFFFF; + --win-bar: #FAFAF9; + --win-dot: #E7E5E4; + + --shadow-sm: 0 1px 2px rgba(40, 37, 36, .05); + --shadow-md: 0 1px 2px rgba(40, 37, 36, .04), 0 12px 32px -16px rgba(40, 37, 36, .2); + --shadow-win: 0 50px 110px -36px rgba(14, 26, 16, .35); + --shadow-lg: 0 0 0 1px rgba(40, 37, 36, .06), 0 24px 64px -16px rgba(40, 37, 36, .32); + + /* The site's tri-colour numerals (olive, purple, blue) on warm ink */ + --syn-comment: #78726E; + --syn-keyword: #5A4AC7; + --syn-string: #5E7500; + --syn-type: #31699F; + --syn-function: #31699F; + --syn-number: #9A5B00; + --syn-property: #282524; + --syn-punctuation: #78726E; + --syn-operator: #78726E; + --syn-tag: #A3341F; + + --wm-light: inline-block; + --wm-dark: none; + --icon-sun: none; + --icon-moon: block; + + --header-h: 64px; + --hdr-max: 1440px; + --radius: 12px; + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #1A1714; + --bg-subtle: #1E1A17; + --bg-warm: #201C19; + --surface: #221E1B; + --muted: #2A2522; + --hover: #322C28; + --fg: #F4F2EE; + --fg-body: #DCD7D0; + --muted-fg: #B9B4AB; + --faint: #9A948A; + --quiet: #9A948A; + --num-faint: #9A948A; + --ol-num: #9A948A; + --who-fg: #BCD43C; + --mark-bg: #D0EC1A; + --mark-fg: #07401A; + --step-on: #D0EC1A; + --stack-fg: rgba(244, 242, 238, .64); + --footer-bg: #1A1714; + --border: rgba(255, 255, 255, .08); + --border-strong: rgba(255, 255, 255, .16); + --hairline: rgba(255, 255, 255, .07); + --eyebrow: #BCD43C; + --link: #D0EC1A; + --link-line: rgba(208, 236, 26, .42); + --accent-text: #D0EC1A; + --nav-active-bg: #16361D; + --nav-active-fg: #D0EC1A; + --nav-bar: #D0EC1A; + --pill-on-bg: #0B4A20; + --pill-on-fg: #D0EC1A; + --pill-on-ring: rgba(208, 236, 26, .28); + --ring: #D0EC1A; + --indicator-ring: transparent; + --tint: rgba(208, 236, 26, .1); + --tint-fg: #E4F58A; + --code-bg: #211D1A; + --code-head: #26221F; + --code-border: rgba(255, 255, 255, .07); + --code-inline: #2E2926; + --code-fg: #E8E3DC; + --code-sel: rgba(208, 236, 26, .28); + --note-bg: #211D1A; + --warn-bg: #2A2314; + --warn-border: #54451D; + --warn-icon: #F2C14E; + --preview-bg: #212914; + --preview-border: rgba(208, 236, 26, .22); + --del-bg: rgba(255, 110, 90, .12); + --del-fg: #FFA08C; + --add-bg: rgba(208, 236, 26, .12); + --th-bg: #211D1A; + --st-bg: #26221F; + --st-fg: #F4F2EE; + --st-muted: #B9B4AB; + --st-border: rgba(255, 255, 255, .08); + --st-field: #211D1A; + --st-track: #453E38; + --st-fill: #D0EC1A; + --win-bg: #211D1A; + --win-panel: #26221F; + --win-bar: #24201D; + --win-dot: #47413B; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, .3); + --shadow-md: 0 1px 2px rgba(0, 0, 0, .3), 0 12px 32px -16px rgba(0, 0, 0, .6); + --shadow-win: 0 50px 110px -36px rgba(0, 0, 0, .75); + --shadow-lg: 0 0 0 1px rgba(255, 255, 255, .06), 0 24px 64px -16px rgba(0, 0, 0, .7); + --syn-comment: #958E86; + --syn-keyword: #A595FF; + --syn-string: #C9E45A; + --syn-type: #8FB8E8; + --syn-function: #8FB8E8; + --syn-number: #F2C14E; + --syn-property: #E8E3DC; + --syn-punctuation: #9C958D; + --syn-operator: #B8B1A9; + --syn-tag: #FF9A85; + --wm-light: none; + --wm-dark: inline-block; + --icon-sun: block; + --icon-moon: none; + color-scheme: dark; + } +} +:root[data-theme="dark"] { + --bg: #1A1714; + --bg-subtle: #1E1A17; + --bg-warm: #201C19; + --surface: #221E1B; + --muted: #2A2522; + --hover: #322C28; + --fg: #F4F2EE; + --fg-body: #DCD7D0; + --muted-fg: #B9B4AB; + --faint: #9A948A; + --quiet: #9A948A; + --num-faint: #9A948A; + --ol-num: #9A948A; + --who-fg: #BCD43C; + --mark-bg: #D0EC1A; + --mark-fg: #07401A; + --step-on: #D0EC1A; + --stack-fg: rgba(244, 242, 238, .64); + --footer-bg: #1A1714; + --border: rgba(255, 255, 255, .08); + --border-strong: rgba(255, 255, 255, .16); + --hairline: rgba(255, 255, 255, .07); + --eyebrow: #BCD43C; + --link: #D0EC1A; + --link-line: rgba(208, 236, 26, .42); + --accent-text: #D0EC1A; + --nav-active-bg: #16361D; + --nav-active-fg: #D0EC1A; + --nav-bar: #D0EC1A; + --pill-on-bg: #0B4A20; + --pill-on-fg: #D0EC1A; + --pill-on-ring: rgba(208, 236, 26, .28); + --ring: #D0EC1A; + --indicator-ring: transparent; + --tint: rgba(208, 236, 26, .1); + --tint-fg: #E4F58A; + --code-bg: #211D1A; + --code-head: #26221F; + --code-border: rgba(255, 255, 255, .07); + --code-inline: #2E2926; + --code-fg: #E8E3DC; + --code-sel: rgba(208, 236, 26, .28); + --note-bg: #211D1A; + --warn-bg: #2A2314; + --warn-border: #54451D; + --warn-icon: #F2C14E; + --preview-bg: #212914; + --preview-border: rgba(208, 236, 26, .22); + --del-bg: rgba(255, 110, 90, .12); + --del-fg: #FFA08C; + --add-bg: rgba(208, 236, 26, .12); + --th-bg: #211D1A; + --st-bg: #26221F; + --st-fg: #F4F2EE; + --st-muted: #B9B4AB; + --st-border: rgba(255, 255, 255, .08); + --st-field: #211D1A; + --st-track: #453E38; + --st-fill: #D0EC1A; + --win-bg: #211D1A; + --win-panel: #26221F; + --win-bar: #24201D; + --win-dot: #47413B; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, .3); + --shadow-md: 0 1px 2px rgba(0, 0, 0, .3), 0 12px 32px -16px rgba(0, 0, 0, .6); + --shadow-win: 0 50px 110px -36px rgba(0, 0, 0, .75); + --shadow-lg: 0 0 0 1px rgba(255, 255, 255, .06), 0 24px 64px -16px rgba(0, 0, 0, .7); + --syn-comment: #958E86; + --syn-keyword: #A595FF; + --syn-string: #C9E45A; + --syn-type: #8FB8E8; + --syn-function: #8FB8E8; + --syn-number: #F2C14E; + --syn-property: #E8E3DC; + --syn-punctuation: #9C958D; + --syn-operator: #B8B1A9; + --syn-tag: #FF9A85; + --wm-light: none; + --wm-dark: inline-block; + --icon-sun: block; + --icon-moon: none; + color-scheme: dark; +} + diff --git a/site/tsconfig.json b/site/tsconfig.json new file mode 100644 index 00000000..732df721 --- /dev/null +++ b/site/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "types": ["vite/client", "node", "bun"], + "baseUrl": ".", + "paths": { "~/*": ["./*"] } + }, + "include": ["src", "components", "build", "scripts", "vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/site/vite.config.ts b/site/vite.config.ts new file mode 100644 index 00000000..f571923b --- /dev/null +++ b/site/vite.config.ts @@ -0,0 +1,69 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import viteReact from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import mdx from '@mdx-js/rollup' +import remarkGfm from 'remark-gfm' +import remarkFrontmatter from 'remark-frontmatter' +import remarkMdxFrontmatter from 'remark-mdx-frontmatter' +import rehypeDocs from './build/rehype-docs.ts' +import { contentManifestPlugin, loadManifest, manifestPaths } from './build/manifest.ts' +import { ROADMAP_PATHS } from './components/roadmap/sections.ts' + +const root = path.dirname(fileURLToPath(import.meta.url)) + +/** + * Base path the site is served under. GitHub Pages serves this repo's site under /blocks/, so CI + * builds with BASE_PATH=/blocks/. Defaults to "/". Always starts and ends with "/". + */ +const base = `/${(process.env.BASE_PATH ?? '/').replace(/^\/+|\/+$/g, '')}/`.replace(/^\/\/$/, '/') + +export default defineConfig(() => { + const manifest = loadManifest(root) + // Every page is listed explicitly (the content ones from the manifest), so a page no link + // reaches is still built, and no crawling is needed. + const pages = [ + { path: '/' }, + // The Roadmap: /roadmap/ (overview) and one page per section (/roadmap/blockers, …). + ...ROADMAP_PATHS.map((p) => ({ path: p })), + // Each version's index (/next/, /v7/), whether or not it has an index.mdx. + ...[...new Set([...Object.keys(manifest.versions).map((v) => `/${v}/`), ...manifestPaths(manifest)])].map((p) => ({ path: p })), + ] + return { + base, + resolve: { alias: { '~': root } }, + server: { port: 3000 }, + plugins: [ + contentManifestPlugin(root), + { + enforce: 'pre' as const, + ...mdx({ + remarkPlugins: [remarkGfm, remarkFrontmatter, [remarkMdxFrontmatter, { name: 'frontmatter' }]], + rehypePlugins: [rehypeDocs], + }), + }, + tailwindcss(), + tanstackStart({ + prerender: { + enabled: true, + // Every page is listed above, so crawling would only re-request them (and `/x#hash` variants). + // Broken links are reported by scripts/postbuild.ts instead. + crawlLinks: false, + // /next/quickstart -> next/quickstart.html (GitHub Pages serves it without a redirect); + // /v7/ -> v7/index.html. + autoSubfolderIndex: false, + failOnError: true, + // The prerenderer fetches each page from its own localhost server with a 10s connect + // timeout; on a loaded machine that can time out. Fewer requests at once, and a retry. + concurrency: 4, + retryCount: 3, + retryDelay: 1000, + }, + pages, + }), + viteReact({ include: /\.(mdx|[jt]sx?)$/ }), + ], + } +}) From 1b583ac98aa0785e87998f3f5df75fb50d9aa1ca Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 1 Oct 2026 23:26:10 -0300 Subject: [PATCH 018/132] docs(site): move styling to Tailwind, full-width docs layout - legacy.css and roadmap.css are gone: design tokens live in the Tailwind theme (colors via light-dark() variables, fonts, type sizes, radii, shadows, easing), components use utilities, and what remains is tokens, a base layer, the MDX prose layer and a few scoped component rules (CSS 154 KB -> 46 KB of source). Pixel-identical to the previous build apart from the layout below. - The docs shell is full width: sidebar on the left edge, "On this page" on the right, the centre column takes the rest; the article keeps a reading width (720px, up to 800px from 1920px wide) and the whole shell caps at 1920px on very large screens. - The build fails if a page's HTML exceeds 48 KB gzipped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- site/ARCHITECTURE.md | 86 +- site/README.md | 2 +- site/assets/cobogo-defs.svg | 2 +- site/components/home/Footer.tsx | 55 +- site/components/home/Hero.tsx | 62 +- site/components/home/Journey.tsx | 253 ++-- site/components/home/Sections.tsx | 579 +++++---- site/components/home/Stepper.tsx | 144 ++- site/components/home/index.tsx | 14 +- site/components/home/tokens.tsx | 48 +- site/components/home/ui.tsx | 107 ++ site/components/mdx/Callout.tsx | 29 +- site/components/mdx/CodeBlock.tsx | 90 +- site/components/mdx/Flow.tsx | 21 +- site/components/mdx/Heading.tsx | 26 +- site/components/mdx/Kbd.tsx | 2 +- site/components/mdx/README.md | 19 +- site/components/mdx/Small.tsx | 12 +- site/components/mdx/Table.tsx | 14 +- site/components/mdx/Terms.tsx | 8 +- site/components/mdx/TocInline.tsx | 47 +- site/components/roadmap/SectionViews.tsx | 449 ++++--- site/components/roadmap/model.ts | 4 +- site/components/roadmap/roadmap.css | 328 ----- site/components/search/index.tsx | 61 +- site/components/ui/Brand.tsx | 25 +- site/components/widgets/Walkthrough.tsx | 64 +- site/scripts/check-roadmap.ts | 20 +- site/scripts/postbuild.ts | 26 +- site/src/layout/DocPage.tsx | 4 +- site/src/layout/DocsShell.tsx | 91 +- site/src/layout/GlobalUi.tsx | 10 +- site/src/layout/Header.tsx | 167 ++- site/src/layout/NotFound.tsx | 3 +- site/src/layout/PageTools.tsx | 33 +- site/src/layout/Rail.tsx | 71 +- site/src/layout/Sidebar.tsx | 92 +- site/src/layout/ThemeToggle.tsx | 14 +- site/src/lib/ui.ts | 4 + site/src/routes/__root.tsx | 14 +- site/src/routes/index.tsx | 8 +- site/src/routes/roadmap/$section.tsx | 2 - site/src/routes/roadmap/index.tsx | 2 - site/src/styles/app.css | 82 +- site/src/styles/base.css | 51 + site/src/styles/components/docs.css | 111 ++ site/src/styles/components/home.css | 37 + site/src/styles/legacy.css | 1492 ---------------------- site/src/styles/prose.css | 154 ++- site/src/styles/theme.css | 315 +++++ site/src/styles/tokens.css | 411 ++---- 51 files changed, 2672 insertions(+), 3093 deletions(-) create mode 100644 site/components/home/ui.tsx delete mode 100644 site/components/roadmap/roadmap.css create mode 100644 site/src/styles/base.css create mode 100644 site/src/styles/components/docs.css create mode 100644 site/src/styles/components/home.css delete mode 100644 site/src/styles/legacy.css create mode 100644 site/src/styles/theme.css diff --git a/site/ARCHITECTURE.md b/site/ARCHITECTURE.md index f358c6d8..1e218048 100644 --- a/site/ARCHITECTURE.md +++ b/site/ARCHITECTURE.md @@ -62,7 +62,7 @@ site/ │ │ └── $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) → tokens.css, legacy.css, prose.css +│ └── 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) @@ -128,34 +128,69 @@ The root layout needs to know, before rendering, whether the page is the full-bl (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). `__root.tsx` reads the deepest match and sets -``, which the CSS keys off, exactly as the old single page did. +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 inline outline, the mobile drawer (modal, focus-trapped, closes on +- 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/`. + `src/layout/`; the inline outline (below 1200px) is `components/mdx/TocInline.tsx`. ## Styles -`src/styles/app.css` is the entry: Tailwind v4 **without preflight** (the ported CSS expects -browser defaults), in layers `theme < base < legacy < components < utilities`. - -- `tokens.css`: the design tokens (`--forest`, `--lime`, warm neutrals, `--syn-*` syntax colours, - …) for light, dark (`prefers-color-scheme`, overridden by `data-theme`) and print. -- `legacy.css`: the old `style.css`, verbatim apart from the tokens and the rules that switched - hash-routed pages. It is the source of the look (header, sidebar, rail, prose, code panels, - tables, callouts, flows, landing, search dialog, responsive, print). -- `prose.css`: doc pages put the title in an h1 and sections in h2/h3, where the old page had - h2/h3/h4; this maps those levels to the old styles (`article.doc-section.doc-page`), plus the - version select. - -The tokens are also Tailwind theme values (`bg-bg`, `text-fg`, `text-muted-fg`, `border-hairline`, -`bg-forest`, `text-lime`, `font-mono`, `shadow-deco-win`, …), and the `dark:` variant follows the -same rule as the tokens. New code can use utilities or the existing classes. +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(, )`. 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 + `
      ` (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 ``) applies `?theme=dark|light` or the saved choice (`localStorage['deco-blocks-docs-theme']`) as `data-theme` before first paint. No attribute @@ -181,9 +216,10 @@ The routes pick these up with `import.meta.glob`, so each folder can be built in - **Home**: `components/home/index.tsx` default export renders the whole page, inside `}>…` - (the drawer shows the Docs navigation). Its classes are in `legacy.css`; the cobogó defs are - `assets/cobogo-defs.svg` (imported `?raw`), brand marks in `components/ui/Brand.tsx`, icons in - `components/ui/Icon.tsx`. + (the drawer shows the Docs navigation). 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 @@ -193,8 +229,8 @@ The routes pick these up with `import.meta.glob`, so each folder can be built in 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. Its stylesheet (`roadmap.css`, in the - `legacy` layer) is linked from the routes' `head`. The route's chrome is + 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#`: `MdxLink` resolves it to the page holding the id. - **Widgets**: capitalized named exports of `components/widgets/index.tsx` become MDX components diff --git a/site/README.md b/site/README.md index 0e376244..43580347 100644 --- a/site/README.md +++ b/site/README.md @@ -72,7 +72,7 @@ in doubt, describe the problem in terms of the framework, not of a particular pr `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, -`roadmap.css` styles it). Run `bun scripts/check-roadmap.ts` (part of `bun run check` and +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. diff --git a/site/assets/cobogo-defs.svg b/site/assets/cobogo-defs.svg index 6419de08..c3e2dda5 100644 --- a/site/assets/cobogo-defs.svg +++ b/site/assets/cobogo-defs.svg @@ -1 +1 @@ - + diff --git a/site/components/home/Footer.tsx b/site/components/home/Footer.tsx index 43ccc71b..3ab8671b 100644 --- a/site/components/home/Footer.tsx +++ b/site/components/home/Footer.tsx @@ -1,25 +1,34 @@ import { MdxLink } from '~/components/mdx/MdxLink' import { WordmarkLime } from '~/components/ui/Brand' -import { d } from './Hero' +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 ( -
      -
      -

      - Start with one function. Ship it from Git. -

      -
      - +
      +
      +

      + Start with one function. Ship it from Git. +

      +
      + Read the quickstart → - + How it works
      -

      - The quickstart runs in plain Node, no framework. Or read the API reference ·{' '} - Under the hood. +

      + The quickstart runs in plain Node, no framework. Or read the{' '} + + API reference + {' '} + ·{' '} + + Under the hood + + .

      @@ -69,18 +78,22 @@ const COLUMNS: { title: string; links: [string, string][] }[] = [ }, ] -/** The landing's site footer (legacy.css hides .site-footer outside the landing layout). */ +/** The landing's site footer. `site-footer` is the hook Menu.tsx uses to make it inert under the open drawer. */ export function SiteFooter() { return ( -