Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d25a08d
feat(builder): create the package
mobeenabdullah Aug 10, 2026
b18c51d
fix(release): version the builder package in lockstep with the others
mobeenabdullah Aug 10, 2026
a505d0a
fix(builder): catch dynamic imports in the layering guard
mobeenabdullah Aug 10, 2026
21e23b2
fix(builder): require react 19, as the renderer it depends on does
mobeenabdullah Aug 10, 2026
0a3d5e8
refactor(builder): export the package name under its own name
mobeenabdullah Aug 10, 2026
14f8cb8
ci: accept the builder scope in pr titles
mobeenabdullah Aug 10, 2026
cc38c59
docs: map the builder package and its commit scope
mobeenabdullah Aug 10, 2026
7d9e07d
docs(release): say what the builder package is for
mobeenabdullah Aug 10, 2026
85b5741
Merge remote-tracking branch 'origin/main' into feat/builder-package
mobeenabdullah Aug 10, 2026
74983d0
ci: run the builder package tests
mobeenabdullah Aug 11, 2026
bf1e562
fix(builder): scan require calls in the layering guard
mobeenabdullah Aug 11, 2026
b684cbe
fix(builder): allow exact specifiers, not every subpath of a package
mobeenabdullah Aug 11, 2026
71a2278
fix(builder): catch typeof-import type queries in the layering guard
mobeenabdullah Aug 11, 2026
0b29229
ci: lint builder with the react rule set
mobeenabdullah Aug 11, 2026
50c2242
docs: claim only the layering rules the guard can check
mobeenabdullah Aug 11, 2026
2edb1ac
fix(builder): read every module shape the bundler follows
mobeenabdullah Aug 11, 2026
a6b40cc
docs(builder): add the package readme
mobeenabdullah Aug 11, 2026
9af8d23
docs(builder): the builder is not the renderer
mobeenabdullah Aug 11, 2026
e4ea618
fix(builder): read import types inside jsdoc comments
mobeenabdullah Aug 11, 2026
c7dc2e4
ci: treat javascript sources as test inputs
mobeenabdullah Aug 11, 2026
f740369
test(builder): enforce the admin boundary at the dependency graph
mobeenabdullah Aug 11, 2026
2fd2f88
test(builder): assert the manifest never declares admin
mobeenabdullah Aug 11, 2026
5f8b92f
fix(builder): read jsdoc import tags in the layering guard
mobeenabdullah Aug 11, 2026
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
27 changes: 27 additions & 0 deletions .changeset/builder-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"nextly": patch
"create-nextly-app": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": patch
"@nextlyhq/ui": patch
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
"@nextlyhq/builder": patch
---

Add the @nextlyhq/builder package, which will hold the visual page-builder editor. It ships no features yet, so there is nothing to install it for: it exists now so the editor arrives under a name that is already reserved and already versioned in lockstep with the rest. It requires React 19, matching the renderer it draws with (@nextlyhq/blocks-react).
3 changes: 2 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"@nextlyhq/eslint-config",
"@nextlyhq/prettier-config",
"@nextlyhq/telemetry",
"@nextlyhq/tsconfig"
"@nextlyhq/tsconfig",
"@nextlyhq/builder"
]
],
"linked": [],
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ jobs:
pnpm turbo test
--filter=@nextlyhq/blocks-engine
--filter=@nextlyhq/blocks-react
--filter=@nextlyhq/builder
Comment thread
mobeenabdullah marked this conversation as resolved.
--filter=@nextlyhq/plugin-page-builder
--filter=@nextlyhq/plugin-form-builder
--filter=@nextlyhq/plugin-seo
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pr-title.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jobs:
storage-uploadthing
blocks-engine
blocks-react
builder
plugin-form-builder
plugin-page-builder
plugin-seo
Expand Down
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ for all published packages. Status: alpha, all packages version in lockstep.
documents; its root entry imports no `next/*`, no admin and no CMS runtime, so
it is usable standalone (enforced by `src/layering.test.ts`). Next-coupled
helpers live at the `/next` subpath.
- `packages/builder` - the visual page-builder editor (shell, canvas, op store).
It reaches admin only through `plugin-sdk/admin`, and imports nothing outside a
short allowlist of exact specifiers - both enforced by `src/layering.test.ts`.
That it draws with `blocks-react` rather than a renderer of its own is a
review-time convention, NOT a checked one: reimplementing rendering on React
and `blocks-engine` imports exactly the same packages, so no import guard can
tell the two apart.
- `packages/plugin-sdk` - the ONLY stable import surface for plugin authors.
- `packages/plugin-{form-builder,page-builder}` - first-party plugins.
- `packages/storage-{s3,vercel-blob,uploadthing}` - media storage adapters.
Expand Down Expand Up @@ -74,7 +81,7 @@ Before editing a package, read its README.md and check for a nested AGENTS.md.
`adapter-postgres`, `adapter-mysql`, `adapter-sqlite`, `adapter-drizzle`,
`storage-s3`, `storage-vercel-blob`, `storage-uploadthing`,
`plugin-form-builder`, `plugin-page-builder`, `plugin-seo`, `plugin-sdk`,
`blocks-engine`, `blocks-react`,
`blocks-engine`, `blocks-react`, `builder`,
`create-nextly-app`, `eslint-config`, `prettier-config`, `tsconfig`,
`telemetry`, `client`) plus `playground`, `root`, `ci`, `docs`, `deps`,
`release`. Scope is optional; the subject must not start with an uppercase
Expand Down
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const REACT_FILES = [
"packages/admin/**/*.{ts,tsx,js,jsx}",
"packages/ui/**/*.{ts,tsx,js,jsx}",
"packages/blocks-react/**/*.{ts,tsx,js,jsx}",
"packages/builder/**/*.{ts,tsx,js,jsx}",
"apps/playground/**/*.{ts,tsx,js,jsx}",
];

Expand Down
93 changes: 93 additions & 0 deletions packages/builder/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# @nextlyhq/builder

The visual page-builder editor: the shell, the canvas, and the op store that
everything in it either produces or reads.

**It ships no features yet.** The package exists ahead of them so its name is
claimed on npm — trusted publishing cannot perform a package's first publish, and
the bootstrap script will not claim a name that is not already a workspace
package. There is nothing to install it for until the editor lands.

## What this package is not

**It is not a renderer.** The canvas draws documents through
`@nextlyhq/blocks-react` — the same renderer that serves published pages — and
re-implements nothing downstream of the document model. Read-path preparation,
condition gating and slot pruning are consumed from the engine's own entry
points, never reproduced here.

That rule is not stylistic. `plugin-page-builder` carries a second renderer of
its own, and the two disagree about condition gating in opposite directions: one
fails closed, the other does not evaluate conditions at all. Sharing a predicate
would not have prevented that, because sharing a predicate does not share the
decision to call it. Only sharing the entry point does.

## The layering contract

`src/layering.test.ts` enforces two boundaries as a build failure:

**The strongest enforcement is not the scan.** `@nextlyhq/admin` is in no
dependency field of this package, and pnpm's `node_modules` is not hoisted, so it
does not resolve here at all — verified, `MODULE_NOT_FOUND`. Every spelling of a
direct admin import therefore fails to build, including spellings TypeScript has
not shipped yet. A test asserts the manifest stays that way, which is complete by
construction in a way a syntax scan can never be.

The scan is the second layer: it fails at test time naming the rule, rather than
at build time with a resolution error, and it is the **only** enforcement for the
subpath policy below — `blocks-react/next`, the `plugin-sdk` root and
`plugin-sdk/testing` all resolve perfectly, because their packages are legitimate
dependencies. The graph has nothing to say about which _entry_ of a dependency is
allowed.

1. **Never `@nextlyhq/admin` directly.** Admin is reached only through
`@nextlyhq/plugin-sdk/admin`, a curated facade where every export is named
individually and carries a stability tag. A direct import bypasses the facade
and takes a dependency on internals nobody promised to keep. The pull toward
it is concrete: the editor wants admin's Lexical node set for inline rich
text.
2. **Never the CMS runtime.** The allowlist is **exact specifiers**, not
packages, because subpaths are where the coupling lives —
`@nextlyhq/blocks-react/next` imports `nextly/runtime`, and the
`@nextlyhq/plugin-sdk` root re-exports runtime values from `nextly`. Only
`@nextlyhq/plugin-sdk/admin` is admitted, and only the root entry of
`blocks-react`.

The guard reads every shape that reaches a module — static imports and
re-exports, `import()`, bare `require()`, `import x = require()`,
`typeof import()`, and triple-slash type references — because each of those has,
at some point, walked straight past a narrower version of it.

**What the guard does NOT prove:** that the canvas renders _through_
`blocks-react` rather than reimplementing rendering on React and
`blocks-engine`. Both spellings import exactly the same packages, so no import
scan can separate them. That rule is held by review. The allowlist makes the
shortcut inconvenient; it cannot make it impossible.

Adding an allowlist entry is a deliberate act with a reason recorded beside it.

## Public surface

`BUILDER_PACKAGE_NAME` — this package's npm name, for diagnostics that report
which packages a host loaded. The name and not the version: a version literal in
source would be stale one release after it was written, because every release
bumps this package in lockstep with its siblings.

## Development

Run these from this directory (`packages/builder`), not the repository root —
turbo swallows the summary line at the root.

```bash
pnpm run test # vitest, including the layering guard
pnpm run check-types # tsc --noEmit; unlike some packages here, this DOES
# cover the test files (tsconfig has no test exclude)
pnpm run lint # eslint --max-warnings 0; a single warning fails
pnpm run build # tsup
```

## Peer dependencies

React 19, matching the renderer it draws with. `@nextlyhq/blocks-react` requires
`react: ^19.0.0`, and it is a dependency here rather than a peer, so a React 18
host would hit an unsatisfiable peer one level down.
8 changes: 8 additions & 0 deletions packages/builder/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// The builder is a React package — it renders THROUGH `@nextlyhq/blocks-react`
// rather than being a renderer — so it layers the shared React rules over the
// repository base. The root config scopes those rules by path; this per-package
// config applies them unconditionally, matching how the other React packages
Comment thread
mobeenabdullah marked this conversation as resolved.
// lint when run directly.
import { config } from "@nextlyhq/eslint-config/react-internal";

export default [...config];
79 changes: 79 additions & 0 deletions packages/builder/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
{
"name": "@nextlyhq/builder",
Comment thread
mobeenabdullah marked this conversation as resolved.
"version": "0.0.2-alpha.55",
"description": "The visual page-builder editor for Nextly: shell, canvas and op store.",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/nextlyhq/nextly.git",
"directory": "packages/builder"
},
"homepage": "https://nextlyhq.com/docs",
"bugs": {
"url": "https://github.com/nextlyhq/nextly/issues"
},
"type": "module",
"main": "dist/index.mjs",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"lint": "eslint . --max-warnings 0",
"lint:fix": "eslint . --fix",
"test": "vitest run",
Comment thread
mobeenabdullah marked this conversation as resolved.
"test:watch": "vitest",
"clean": "rimraf dist"
},
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"@nextlyhq/blocks-engine": "workspace:*",
"@nextlyhq/blocks-react": "workspace:*"
},
"peerDependencies": {
"@nextlyhq/plugin-sdk": "workspace:*",
"@nextlyhq/ui": "workspace:^",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@nextlyhq/eslint-config": "workspace:*",
"@nextlyhq/plugin-sdk": "workspace:*",
"@nextlyhq/tsconfig": "workspace:*",
"@nextlyhq/ui": "workspace:*",
"@types/node": "^20.19.17",
"@types/react": "19.2.0",
"@types/react-dom": "19.2.0",
"eslint": "^9.39.1",
"react": "19.2.0",
"react-dom": "19.2.0",
"rimraf": "^6.1.3",
"tsup": "^8.5.0",
"typescript": "^5.9.3",
"vitest": "^4.1.0"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org/",
"provenance": true
},
"keywords": [
"nextly",
"page-builder",
"visual-editor",
"canvas"
]
}
43 changes: 43 additions & 0 deletions packages/builder/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* `@nextlyhq/builder` — the visual page-builder editor.
*
* The editor half of the page builder: the shell, the canvas, and the op store
* that everything in it either produces or reads. It is deliberately NOT a
* renderer.
*
* **The invariant this package is built around**: the canvas renders documents
* through `@nextlyhq/blocks-react` — the same renderer that serves published
* pages — and re-implements nothing downstream of the document model. Read-path
* preparation, condition gating and slot pruning are consumed from the engine's
* own entry points, never reproduced here.
*
* It is held by review rather than by a test. Reimplementing rendering on React
* and the engine imports exactly the same packages as delegating to the
* renderer, so the layering guard cannot tell the two apart; it narrows what may
* be imported, which makes the shortcut inconvenient rather than impossible.
*
* That rule is not stylistic. `plugin-page-builder` carries a second renderer of
* its own, and the two disagree about condition gating in OPPOSITE directions —
* one failing closed, the other not evaluating conditions at all. Sharing a
* predicate would not have prevented that, because sharing a predicate does not
* share the decision to call it; only sharing the entry point does.
*
* This entry exports no features yet. The package exists ahead of them so its
* name is claimed on npm: trusted publishing cannot perform a package's first
* publish, and the bootstrap script will not claim a name that is not already a
* workspace package.
*
* @module @nextlyhq/builder
*/

/**
* This package's npm name, for diagnostics that report which packages a host
* has loaded.
*
* The name and not the version. A version literal in source would be stale one
* release after it was written, because every release bumps this package in
* lockstep with its siblings; reporting a version means injecting the manifest's
* value at build time, which belongs with the surface that displays it rather
* than with a constant nothing reads yet.
*/
export const BUILDER_PACKAGE_NAME = "@nextlyhq/builder" as const;
Loading
Loading