Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ describe("the codemod's list of the root's next-major exports", () => {
path.resolve(__dirname, "../../../../packages/blocks/src/index.ts"),
"utf8",
);
const block = /export \{([^}]*)\} from "\.\/v8\/index";/.exec(index)?.[1] ?? "";
const block = /export \{([^}]*)\} from "\.\/v8\/index\.ts";/.exec(index)?.[1] ?? "";
const names = block
.split(",")
.map((n) => n.trim().replace(/^type\s+/, ""))
Expand Down
52 changes: 52 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: CI

# Type-checks and builds the 8 publishable packages on every PR. The build is
# what npm publishes: `tsc` emits one .js (+ .d.ts and maps) per source file
# into each package's dist/, with no bundler, so the module graph — and every
# module-level singleton — is the same as the source's. The last step imports
# each built entry point with plain Node, which can't load .ts from
# node_modules: if a relative import lost its extension, it fails here.

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 24

- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.5

# The root `prepare` script builds the workspace on install.
- run: bun install --frozen-lockfile

- run: bun run typecheck

- run: bun run build

- name: Plain Node imports every built export
run: |
for dir in packages/*; do
(cd "$dir" && node --input-type=module -e '
import fs from "node:fs";
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
const leaves = (v) => typeof v === "string" ? [] : "default" in v && typeof v.default === "string" ? [v.default] : Object.values(v).flatMap(leaves);
for (const target of Object.values(pkg.exports).flatMap(leaves)) {
await import(new URL(target, `file://${process.cwd()}/`).href);
console.log(`${pkg.name}: ${target}`);
}
')
done
node packages/blocks/bin/deco.js --help > /dev/null
2 changes: 1 addition & 1 deletion .releaserc.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
[
"@semantic-release/exec",
{
"prepareCmd": "VERSION=${nextRelease.version} node scripts/sync-versions.mjs",
"prepareCmd": "VERSION=${nextRelease.version} node scripts/sync-versions.mjs && bun run build",
"publishCmd": "PACKAGES=\"blocks apps-algolia apps-magento apps-resend apps-sfmc-personalization apps-shopify apps-vtex apps-wake\"; if [ \"$DRY_RUN\" = \"true\" ]; then echo \"[dry-run] would publish version ${nextRelease.version} for: $PACKAGES\"; else failed=0; for name in $PACKAGES; do (cd \"packages/$name\" && npm publish --access public --tag ${nextRelease.channel ? nextRelease.channel : 'latest'}) || { echo \"::error::npm publish failed for packages/$name\"; failed=1; }; done; exit \"$failed\"; fi"
}
],
Expand Down
27 changes: 20 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This is **blocks** (repo `decocms/blocks`): a Bun workspace monorepo for the fra

**v7 lives on the `7.x` branch.** `@decocms/tanstack`, `@decocms/nextjs`, `@decocms/blocks-admin`, `@decocms/blocks-cli`, `@decocms/apps-commerce`, `@decocms/apps-blog`, `@decocms/apps-website` and v7's `@decocms/blocks` modules (`/cms`, `/sdk/*`, `/hooks`, `/setup`, …) are maintained and released there, not here. Don't reintroduce them on this line.

History worth knowing: the framework used to be a single tsup-bundled package, `@decocms/start`, reverted at v5.2.2 because bundling created two module instances of what must be one singleton (the CMS registry). Packages here import each other's plain `.ts` source; no bundler is in the loop.
History worth knowing: the framework used to be a single tsup-bundled package, `@decocms/start`, reverted at v5.2.2 because bundling created two module instances of what must be one singleton (the CMS registry). Here no bundler is in the loop: each package is compiled file-for-file by `tsc` (see "How packages build" below), so module boundaries, and the singletons that live in them, are exactly the source's.

## Tech Stack

Expand All @@ -20,10 +20,11 @@ History worth knowing: the framework used to be a single tsup-bundled package, `
## Common Commands

```bash
bun install
bun install # also builds every package's dist/ (root `prepare` script)
bun run build # tsc (TypeScript 7) -> dist/, per package, blocks first
bun run test # vitest, whole repo: packages, tests/, the migration skill's scripts
bun run typecheck # tsc --noEmit per package, plus the skill scripts and tests/
bun run examples # build the three examples, then typecheck them
bun run examples # build the packages and the three examples, then typecheck them
bun run check # typecheck + lint + lint:unused
```

Expand Down Expand Up @@ -55,24 +56,36 @@ v7-only design docs, plans and Cursor skills live on the `7.x` branch. The docs

Nothing else is published from this line: the release allowlist in `.releaserc.json` is `blocks` plus the seven `apps-*`.

### How packages build (per-file `tsc`, no bundler)

Every package publishes compiled JavaScript so plain Node (scripts, Playwright, Node servers, Next without `transpilePackages`) can import it; npm never ships only `.ts`. The build is `tsc -p tsconfig.build.json` (via `scripts/tsc.mjs`, which runs the TypeScript 7 native compiler from the root `typescript7` alias): `rootDir: src`, `outDir: dist`, `module`/`moduleResolution: NodeNext`, `declaration` + declaration and source maps, tests and fixtures excluded. Each `src/**/*.ts(x)` becomes exactly one `dist/**/*.js` (+ `.d.ts`), with its imports left as imports.

- **Why no bundler**: a bundle inlines modules, so a package that bundles another (or a bundle loaded next to the unbundled copy) carries a second instance of module state — the `@decocms/start` registry bug. With per-file output there is one `dist/x.js` per `src/x.ts` and every importer gets that one module, as with the source; cross-package imports stay package imports (`@decocms/blocks`), never inlined. Don't add tsup/esbuild/rollup to a package build.
- **Relative imports carry the `.ts` extension** (`from "./run.ts"`, `from "./server/index.ts"`, never extensionless or a bare directory) and `rewriteRelativeImportExtensions` turns them into `.js` in the output; the build's `NodeNext` resolution rejects a specifier Node couldn't resolve. Test files may stay extensionless (they're never emitted).
- **Exports maps**: every subpath is `{ "types": "./dist/….d.ts", "source": "./src/….ts", "default": "./dist/….js" }`; conditional subpaths nest that object under each branch (`/protocol/storage/fs`: `node` before `default`). `files` ships `dist` and `src` (minus tests) so the `source` condition and the declaration maps resolve. Add a subpath to both `src` and the map in that shape (`sdk.test.ts`/`run.test.ts` check it).
- **The `source` condition** lets in-repo tools use `.ts` without a build: `tsconfig.base.json` (`customConditions`) for type-checks and `vitest.config.ts` (`resolve.conditions`) for tests, so a test importing a file by path and one importing `@decocms/blocks` share one module. A consumer that opts into `source` must do so for every environment it builds; mixing `source` and dist in one process loads both copies.
- **The bin runs dist**: `bin/deco.js` imports `dist/v8/cli/main.js` (no tsx). `tests/globalSetup.ts` rebuilds blocks' dist before the suite because the CLI conformance tests spawn the bin.
- **Two TypeScripts**: builds and type-checks use TypeScript 7 (`typescript7`, npm alias of `typescript@7`); the plain `typescript` name stays on 5.x because TypeScript 7 has no JavaScript compiler API, and `deco schema` loads the app's `typescript` peer through that API (as do Next's type-check and a few conformance tests).
- **Release**: `.releaserc.json`'s `prepareCmd` runs `bun run build` after the version sync, and each package's `prepack` rebuilds, so `npm publish` never ships a stale or missing dist. CI (`.github/workflows/ci.yml`) type-checks, builds and imports every built export with plain Node.

### `@decocms/blocks` exports

Every export maps to a source file; there is no dist indirection.
Each export is built from the source file below (`dist/<same path>.js`, see above).

| Import path | File |
|---|---|
| `@decocms/blocks` | `src/index.ts` — the v8 runtime API only (`createCMS`, `matchRoute`, `remoteLoader`, `draftPointer`, `Blocks`, `Lazy`, …), re-exported from `src/v8/index.ts` as one explicit `export { … } from "./v8/index"` block (a conformance test parses that form) |
| `@decocms/blocks` | `src/index.ts` — the v8 runtime API only (`createCMS`, `matchRoute`, `remoteLoader`, `draftPointer`, `Blocks`, `Lazy`, …), re-exported from `src/v8/index.ts` as one explicit `export { … } from "./v8/index.ts"` block (a conformance test parses that form) |
| `@decocms/blocks/analytics` | `src/v8/analytics.ts` — `AnalyticsScript`, `track` |
| `@decocms/blocks/fetch` | `src/v8/fetch.ts` — `createInstrumentedFetch` |
| `@decocms/blocks/secrets` | `src/v8/secrets.ts` |
| `@decocms/blocks/cli` | `src/v8/cli/index.ts` — the `deco` CLI (`schema`, `content`, `check`, `serve`), also the bin (`bin/deco.js`, which loads the TS sources via tsx under Node, directly under Bun). CLI-only: the runtime never imports it (`run.test.ts`), so it and the TypeScript compiler never reach an app bundle |
| `@decocms/blocks/cli` | `src/v8/cli/index.ts` — the `deco` CLI (`schema`, `content`, `check`, `serve`), also the bin (`bin/deco.js`, which runs the compiled `dist/v8/cli/main.js` under Node or Bun). CLI-only: the runtime never imports it (`run.test.ts`), so it and the TypeScript compiler never reach an app bundle |
| `@decocms/blocks/protocol` (+ `/keys`, `/server`, `/storage/fs`, `/conformance`) | `src/protocol/**` — the content protocol the site editor uses; browser-safe except `storage/fs` (conditional export: `node` → real, `default` → a stub that throws) |

### Key boundaries

- **The runtime never imports the protocol or the CLI.** `src/v8` modules import only each other (no Node built-ins, no React at runtime except `/analytics`); `src/v8/browserBundle.test.ts` proves it with a real esbuild bundle. The CLI may import the protocol.
- **Clients depend only on `@decocms/blocks`**, with no peers and no framework binding. A client takes settings as arguments (no env reads), never caches, has no hooks, converters or `"use client"` code: those belong in the site's platform template (`tests/upstream-clients.conformance.test.ts`).
- **Cross-package imports resolve to `.ts` source** (`moduleResolution: bundler`). No `tsconfig.json` `references` anywhere: adding them back reintroduces a TS6305 build-ordering bug.
- **Cross-package imports are package imports**: type-checks resolve them to `.ts` source through the `source` condition; the build resolves them to the dependency's built `dist/*.d.ts`, which is why `bun run build` builds blocks before the clients. No `tsconfig.json` `references` anywhere: adding them back reintroduces a TS6305 build-ordering bug.
- **No compat layers.** If a site needs something a package should export, add the export; don't let sites (or this repo) re-create v7 APIs as shims.

## v8 core — `packages/blocks/src/v8/`
Expand Down
Loading
Loading