diff --git a/CHANGELOG.md b/CHANGELOG.md index a15232999..71ad68059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,41 @@ -# Digital Shopfront CMS Package Changelog +# ArtisanPack UI Visual Editor — Changelog -## [Unreleased] +All notable changes to this project are documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); +this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] — V1 release + +V1 ships the post editor, the site editor, the block fork to the +`artisanpack/*` namespace, and first-class pairing with +`artisanpack-ui/cms-framework`. See the [README](README.md) and the +[`docs/`](docs/) directory for the full V1 surface. + +### Added + +- **Site editor (Phase H).** Mounted at `/visual-editor/site`. Templates, + template parts, theme.json-backed global styles, navigation menus, and + patterns — all editable through a custom shell built on + `@wordpress/block-editor`. Backed by cms-framework's + `Template`/`TemplatePart`/`GlobalStyles`/`Menu`/`Pattern` models when + cms-framework is installed; fail-closed `SiteEditorAccessGate` + defaults to deny until the host binds a permissive gate (or installs + cms-framework, which auto-binds `CmsFrameworkInstallGate`). +- **Documentation set.** Fifteen new / refreshed docs covering install, + content model, Blade component reference, post-editor surface, custom + blocks, renderers, site-editor surface, templates, global styles, + navigation, patterns, Livewire and Inertia embedding recipes, + theming, troubleshooting, and migration. Entry point: + [`docs/getting-started.md`](docs/getting-started.md). +- **V1 expansion plan retained as historical record:** + [`docs/plans/11-v1-expansion.md`](docs/plans/11-v1-expansion.md). + +### Changed + +- README rewritten to reflect final V1 scope: post editor + site editor + + patterns + Livewire/Inertia embedding. + +## [1.0.0-alpha.1] — Gutenberg adoption marker ### Added diff --git a/MEMORY.md b/MEMORY.md new file mode 100644 index 000000000..614a669db --- /dev/null +++ b/MEMORY.md @@ -0,0 +1,167 @@ +# Visual Editor — V1 architecture notes + +Persisted reference for future work on this package. Read on every +session start; update when architecture decisions change. + +--- + +## Package shape + +- Composer package: `artisanpack-ui/visual-editor` (namespace `ArtisanPackUI\VisualEditor\`, src under `src/`) +- npm package: `@artisanpack-ui/visual-editor` (built from `resources/js/visual-editor/`) +- Three sub-packages under `packages/`: + - `artisanpack-ui/visual-editor-renderer-blade` (Packagist; published in V1) + - `@artisanpack-ui/visual-editor-renderer-react` (npm; mirror unpublished in V1.0.0) + - `@artisanpack-ui/visual-editor-renderer-vue` (npm; mirror unpublished in V1.0.0) + +## V1 surfaces + +Two surfaces, mounted independently: + +1. **Post editor** — `` Blade + component. Boots a React tree on `[data-ap-visual-editor]`. Edits + any model that uses `HasBlockContent`. +2. **Site editor** — `GET /visual-editor/site/{path?}`. Templates, + template parts, patterns, global styles, navigation. Gated by + `SiteEditorAccessGate` (`DenyByDefaultGate` by default; + cms-framework auto-binds `CmsFrameworkInstallGate`). + +## Content model + +- `ArtisanPackUI\VisualEditor\Concerns\HasBlockContent` on any Eloquent + model. Default column: `content` (JSON); override via + `$blockContentColumn`. Optional scope via `$blockContentScope`. +- Resource map: slug → model class. Read from + `config('artisanpack.visual-editor.resources')` + `ap.visual-editor.resources` + filter. Static config wins on key collision. Validation deferred to + first request (not boot), so contributor packages don't trip boot + when absent. +- Resolver: `ArtisanPackUI\VisualEditor\Resources\ResourceResolver`. Applies + `forVisualEditor` scope; throws `InvalidArgumentException` on unknown + resource / missing trait. + +## REST surface + +All under `/visual-editor/api`, middleware +`config('artisanpack.visual-editor.api.middleware')` (default +`['api', 'auth']`). + +Endpoint families: + +- `{resource}/{id}/content` — block content CRUD +- `blocks/preview`, `blocks` — block registry + dynamic preview +- `query/resolve` — `core/query` resolution +- `templates`, `template-parts`, `patterns`, `global-styles`, `menus`, + `menu-items`, `menu-locations` — site-editor entities (wp_template + shape; matches Gutenberg's REST contract so core-data shim resolves + unchanged) +- `attachments/{id}`, `search`, `site/{id}` — media, link picker, + site-meta + +cms-framework adds: `posts`, `pages` (REST adapters into +cms-framework's models). + +## @wordpress/* pins + +All exact-version pinned, lockstep, audited per release. V1.0.0 ships +against the `15.16.0` / `32.5.0` / `7.43.0` baseline (see +`docs/release-notes-inputs-1.0.0.md` §1 + §5). Renovate is paused for the +group during V1.x expansion phases and resumes post-tag. + +Schema version: theme.json **v3** (pinned in +`config('artisanpack.visual-editor.global_styles.schema_version')` and +enforced by `UpdateGlobalStylesRequest`). + +Bump procedure: `docs/troubleshooting.md` §3 + `docs/global-styles.md` §8. + +## Core-data shim + +`resources/js/visual-editor/vendor/core-data-shim.ts` — aliased in +`vite.config.ts` as `@wordpress/core-data`. Provides: + +- Entities: `wp_template`, `wp_template_part`, `wp_navigation`, `wp_block` + (patterns), `root:globalStyles`, attachments, site-meta. +- Selectors: `getEntityRecord`, `getEntityRecords`, `getEditedEntityRecord`, + `canUser`, `getCurrentUser`, `getMedia`, plus resolver tracking + (`hasFinishedResolution`, `isResolving`). + +Adding a new entity or selector goes here. Surface only what Gutenberg +demands; broader surface = more re-verification per upstream bump. + +## Block fork (Phase I — closed) + +- 42 forked core blocks under `artisanpack/*`. Source of truth: + `resources/js/visual-editor/blocks/`. +- `core/*` no longer registered. `from:core/*` transforms on every fork + catch pasted upstream markup. +- `@wordpress/block-library` demoted to devDependencies; only consumed + by `npm run upstream-diff`. +- Per-block `upstream-state.json` tracks the upstream commit each fork + derives from. CI runs the diff on every Renovate PR. + +## Renderers + +Three packages, all consume the same block-tree JSON. Parity verified +by `npm run verify:parity`. Static blocks: per-block partial/component +in each renderer. Dynamic blocks: `DynamicBlock` subclass renders +server-side; React + Vue fall through to `` that fetches +from `/visual-editor/api/blocks/preview`. + +## Embedding stacks + +- Livewire: `wire:ignore` + Alpine listeners on `ve:editor:*` events. + See `docs/livewire.md`. +- Inertia React: imperative `mountVisualEditor(el, config)`. +- Inertia Vue: `` SFC from `@artisanpack-ui/visual-editor/vue`. + +Same three browser events fire regardless of stack: +`ve:editor:change` (debounce edge), `ve:editor:autosave` (debounced +save returns 200), `ve:editor:save` (explicit save returns 200). + +## Theming + +- Editor chrome restyled to DaisyUI via `@artisanpack-ui/react`. +- Known issues: `@artisanpack-ui/react` Popover + Dropdown freeze the + canvas — use inline manual patterns. Tabs needs + `tabListClassName="tabs-box"` for DaisyUI 5. +- React package: use subpath imports (`/form`, `/layout`, etc.); root + import breaks tests via missing `react-apexcharts` peer. + +## Site-editor + cms-framework version pair + +V1.x ↔ V1.x. cms-framework provides: + +- Models: `Post`, `Page`, `Template`, `TemplatePart`, `Pattern`, + `GlobalStyles`, `Menu`, `MenuItem`. +- Service contracts: `QueryResolver`, `SiteEditorAccessGate` impl + (`CmsFrameworkInstallGate`), site-meta via `apGetSetting('site.*')`. +- Migrations: `block_content` column on `posts`/`pages`, full + site-editor tables. + +Loose coupling — cms-framework's editor wiring guarded by +`class_exists(\ArtisanPackUI\VisualEditor\VisualEditor::class)`. + +## Smoke flows + +Both run against the version pair before every release tag: + +- `docs/g6-smoke-flow.md` — Phase G (posts, pages, site-meta, query). +- `docs/h8-smoke-flow.md` — Phase H (templates, parts, patterns, + global styles, navigation). + +## Tests + +- PHP: `./vendor/bin/pest` (Pest 3.x, Orchestra Testbench). +- JS: `npm test` (Vitest, jsdom). +- Parity: `npm run verify:parity`. +- Upstream diff: `npm run upstream-diff`. + +## What's deferred to V1.1+ + +- `@wordpress/*` upgrade pass (paused during V1.0.0; resumes post-tag). +- Style variations (theme-level presets). +- Revision history of global-styles edits. +- Standalone-without-cms-framework polish (currently usable but the + site editor requires cms-framework). +- WordPress import companion package + (`artisanpack-ui/visual-editor-wp-import`). diff --git a/README.md b/README.md index 8192ac80f..6688be4f0 100644 --- a/README.md +++ b/README.md @@ -1,130 +1,268 @@ # ArtisanPack UI Visual Editor -Description +A Gutenberg-powered visual editor for Laravel applications. Brings the +WordPress block editor to any Eloquent model, plus a full site editor +for templates, template parts, global styles, navigation menus, and +patterns. + +V1.0 ships: + +- **Post editor** — block-based content editing on any model that opts + in via the `HasBlockContent` trait. +- **Site editor** — templates, template parts, theme.json-backed global + styles, navigation menus, and patterns; mounted at `/visual-editor/site`. +- **42 forked core blocks** under the `artisanpack/*` namespace. + `core/*` markup pasted from upstream auto-converts on insert. +- **Three renderer packages** — Blade (server-side), React, and Vue — + for rendering saved block content on the public site. +- **First-class pairing** with [`artisanpack-ui/cms-framework`](https://github.com/ArtisanPack-UI/cms-framework) + for Posts, Pages, site-meta, navigation, and global-styles persistence. + Both packages remain usable standalone. + +Full documentation: see [`docs/`](docs/). Start with [Getting started](docs/getting-started.md). + +--- ## Installation -You can install the visual-editor package by running the following composer command. +```bash +composer require artisanpack-ui/visual-editor +php artisan migrate +``` + +For the site editor (recommended for most apps): -`composer require artisanpack-ui/visual-editor` +```bash +composer require artisanpack-ui/cms-framework +php artisan migrate +``` + +See [Getting started](docs/getting-started.md) for the full setup. + +--- ## Version compatibility The visual-editor and `artisanpack-ui/cms-framework` packages ship as a -version pair — both packages need to be present, and both need to be -on a compatible major version, for the Phase H site-editor integration -to work. Install one without the other and the site-editor's install -gate (#432) surfaces a "cms-framework required" page instead of mounting. +version pair — both packages need to be present, and both need to be on +a compatible major version, for the site-editor integration to work. +Install one without the other and the site-editor's install gate (#432) +surfaces a "cms-framework required" page instead of mounting. | visual-editor | cms-framework | Notes | | ------------- | ------------- | ---------------------------------------------- | -| v1.x | v1.x | Phase H site-editor integration (this release) | +| v1.x | v1.x | Site-editor integration (this release) | | v0.x | v0.x | Pre-v1 — no site-editor integration | Bumping the major on either package without bumping the partner is unsupported. Both smoke flows run against this version pair before every -release tag: -[`docs/g6-smoke-flow.md`](docs/g6-smoke-flow.md) covers Phase G -(cms-framework content integration — posts, pages, site-meta, query -loop); [`docs/h8-smoke-flow.md`](docs/h8-smoke-flow.md) covers Phase H -(site-editor — templates, parts, patterns, global styles, navigation). +release tag: [`docs/g6-smoke-flow.md`](docs/g6-smoke-flow.md) covers +Phase G (cms-framework content integration — posts, pages, site-meta, +query loop); [`docs/h8-smoke-flow.md`](docs/h8-smoke-flow.md) covers +Phase H (site-editor — templates, parts, patterns, global styles, +navigation). + +--- -## Peer Dependencies +## Peer dependencies -The editor UI is built on [`@artisanpack-ui/react`](https://www.npmjs.com/package/@artisanpack-ui/react), which is styled with DaisyUI and Tailwind CSS. Host applications embedding the editor must have the following installed and loaded: +The editor UI is built on [`@artisanpack-ui/react`](https://www.npmjs.com/package/@artisanpack-ui/react), +which is styled with DaisyUI and Tailwind CSS. Host applications +embedding the editor must have the following installed and loaded: - `tailwindcss` `^4.0.0` - `daisyui` `^5.0.0` +--- + ## Usage -You can use any of the visual editor functions like this: +Add the `HasBlockContent` trait to any model and register it in +`config/artisanpack/visual-editor.php`: -``` -Example Here +```php +use ArtisanPackUI\VisualEditor\Concerns\HasBlockContent; + +class Post extends Model +{ + use HasBlockContent; +} ``` -## Gutenberg adoption — transient shims (V1) +```php +// config/artisanpack/visual-editor.php +return [ + 'resources' => [ + 'posts' => App\Models\Post::class, + ], +]; +``` -The V1 editor adopts the upstream `@wordpress/*` packages. Some of those packages expect a WordPress backend that this package does not provide yet, so we ship **temporary shims** under `resources/js/visual-editor/vendor/`: +Mount the editor in a Blade view: -- **`core-data-shim.ts`** — aliased in `vite.config.ts` as `@wordpress/core-data`. Registers an empty `core` Redux store via `@wordpress/data` so Gutenberg's selectors (`getEntityRecord`, `getEntityRecords`, `getCurrentUser`, `getUsers`, `getMedia`, etc.) see "no data, done loading" instead of crashing. Blocks that need WordPress-specific data (navigation, query, post-*) render empty; those blocks land in the `disabled_blocks` list in M5. -- **`media-upload-stub.tsx`** — registers `editor.MediaUpload` via `@wordpress/hooks` so the media-library picker on `core/image` is routed through a stub that displays an "M4 placeholder" notice instead of silently no-op'ing. +```blade + +``` -Both shims will be replaced by `artisanpack-ui/cms-framework` (the real Laravel-backed `core-data` store and media bridge). Every selector or filter we implement here is one we have to re-verify against Gutenberg upgrades — expand the surface only when an observed crash demands it. +The site editor mounts automatically at `/visual-editor/site` when +cms-framework is installed and the configured `SiteEditorAccessGate` +permits the request. + +See [`docs/blade-component.md`](docs/blade-component.md) for the full +component reference and [`docs/site-editor.md`](docs/site-editor.md) for +the site-editor surface. + +--- + +## Documentation + +| Topic | Doc | +|-------|-----| +| Install and first post | [`docs/getting-started.md`](docs/getting-started.md) | +| `HasBlockContent` + resource map | [`docs/content-model.md`](docs/content-model.md) | +| `` reference | [`docs/blade-component.md`](docs/blade-component.md) | +| Post editor surface tour | [`docs/post-editor.md`](docs/post-editor.md) | +| Authoring custom blocks | [`docs/custom-blocks.md`](docs/custom-blocks.md) | +| Blade / React / Vue renderers | [`docs/renderers.md`](docs/renderers.md) | +| Site editor surface tour | [`docs/site-editor.md`](docs/site-editor.md) | +| Template hierarchy + parts | [`docs/templates.md`](docs/templates.md) | +| theme.json-backed global styles | [`docs/global-styles.md`](docs/global-styles.md) | +| Menus, locations, fallbacks | [`docs/navigation.md`](docs/navigation.md) | +| Synced vs unsynced patterns | [`docs/patterns.md`](docs/patterns.md) | +| Embedding in Livewire | [`docs/livewire.md`](docs/livewire.md) | +| Embedding in Inertia (React/Vue) | [`docs/inertia.md`](docs/inertia.md) | +| Theming editor chrome | [`docs/theming.md`](docs/theming.md) | +| Common problems | [`docs/troubleshooting.md`](docs/troubleshooting.md) | +| Migration & WP import | [`docs/migration.md`](docs/migration.md) | + +--- + +## Gutenberg adoption — transient shims + +The V1 editor adopts the upstream `@wordpress/*` packages. Some of those +packages expect a WordPress backend that this package doesn't provide, +so we ship temporary shims under `resources/js/visual-editor/vendor/`: + +- **`core-data-shim.ts`** — aliased in `vite.config.ts` as + `@wordpress/core-data`. Provides the entity registrations and + selectors Gutenberg expects (`getEntityRecord`, `getEntityRecords`, + resolvers, permissions stubs). Templates, template parts, navigation, + patterns, global styles, attachments, and site-meta are wired through + to the package's REST surface. +- **`media-upload-stub.tsx`** — registers `editor.MediaUpload` via + `@wordpress/hooks` so the media-library picker on `core/image` is + routed through a stub or the real + `registerArtisanpackMediaBridge(MediaModal, uploadMedia)` bridge. + +Both shims will be replaced over the V1.x release line as the cms-framework +side surfaces real backings. Every selector or filter implemented here is +one to re-verify against Gutenberg upgrades. See +[`docs/core-data-shim.md`](docs/core-data-shim.md) and +[`docs/troubleshooting.md`](docs/troubleshooting.md#2-core-data-shim-entities-and-missing-data). + +--- ## Block defaults -V1 ships with a frozen allow-list of forked blocks under the `artisanpack/*` namespace. The defaults in `config/visual-editor.php` expose every block that landed during the Phase I block fork (plan 13) — `@wordpress/block-library`'s `registerCoreBlocks()` is no longer called, and the editor registers only the in-package forks discovered under `resources/js/visual-editor/blocks/`. The `core/*` → `artisanpack/*` mapping table in [`docs/block-library-audit.md`](docs/block-library-audit.md) is the source of truth for which forks exist. - -The forked allow-list covers the content, media, layout, widget, entity, and loop/feed clusters. The entity blocks (`artisanpack/post-*`, `artisanpack/site-*`, `artisanpack/template-part`, `artisanpack/navigation`) and the loop / feed cluster (`artisanpack/query`, `artisanpack/post-template`, `artisanpack/archives`, `artisanpack/categories`, `artisanpack/tag-cloud`) need an entity in scope to render meaningful content — pair the editor with [`artisanpack-ui/cms-framework`](https://github.com/ArtisanPack-UI/cms-framework) (see [Using with cms-framework](#using-with-cms-framework)) and they resolve against Posts / Pages / templates / site settings end-to-end. Standalone, they fall back to empty shells rather than crashing. - -`disabled_blocks` is empty by default: with the I7 cutover (#415) the editor no longer registers any `core/*` block, so there is nothing to deny-list. New `@wordpress/block-library` releases similarly bring no new registrations into this package — additions land only when a fork is added to the in-package blocks directory and to the allow-list. `from:core/*` transforms still ship on each fork so existing `core/*` markup pasted from upstream converts on insert. - -Override the defaults by publishing the config to `config/artisanpack/visual-editor.php` and editing the `enabled_blocks` / `disabled_blocks` arrays. The deny-list always wins over the allow-list. +V1 ships a frozen allow-list of forked blocks under the `artisanpack/*` +namespace. The defaults in `config/artisanpack/visual-editor.php` expose every block +that landed during the Phase I block fork — `@wordpress/block-library`'s +`registerCoreBlocks()` is no longer called, and the editor registers +only the in-package forks discovered under +`resources/js/visual-editor/blocks/`. The `core/*` → `artisanpack/*` +mapping table in [`docs/block-library-audit.md`](docs/block-library-audit.md) +is the source of truth for which forks exist. + +The forked allow-list covers the content, media, layout, widget, entity, +loop/feed, comments, query/pagination, and authentication clusters. +Entity blocks (`artisanpack/post-*`, `artisanpack/site-*`, +`artisanpack/template-part`, `artisanpack/navigation`) and the loop / +feed cluster (`artisanpack/query`, `artisanpack/post-template`, +`artisanpack/archives`, `artisanpack/categories`, +`artisanpack/tag-cloud`) need an entity in scope to render meaningful +content — pair the editor with +[`artisanpack-ui/cms-framework`](https://github.com/ArtisanPack-UI/cms-framework) +and they resolve against Posts / Pages / templates / site settings +end-to-end. Standalone, they fall back to empty shells rather than +crashing. + +`disabled_blocks` is empty by default: with the I7 cutover (#415) the +editor no longer registers any `core/*` block, so there's nothing to +deny-list. New `@wordpress/block-library` releases similarly bring no +new registrations into this package — additions land only when a fork +is added to the in-package blocks directory and to the allow-list. +`from:core/*` transforms ship on each fork so existing `core/*` markup +pasted from upstream converts on insert. + +Override the defaults by publishing the config to +`config/artisanpack/visual-editor.php` and editing the `enabled_blocks` +/ `disabled_blocks` arrays. The deny-list always wins over the allow-list. + +--- ## Using with cms-framework -The visual editor is fully usable standalone, but pairs with [`artisanpack-ui/cms-framework`](https://github.com/ArtisanPack-UI/cms-framework) to unlock editable `Post` and `Page` content, a real backing for `core/site-*` blocks, working `core/post-*` / `core/query` / taxonomy widget blocks, and seeded `visual_editor.*` permissions. The full integration contract lives in [`docs/plans/12-cms-framework-integration.md`](docs/plans/12-cms-framework-integration.md). +The visual editor is fully usable standalone, but pairs with +[`artisanpack-ui/cms-framework`](https://github.com/ArtisanPack-UI/cms-framework) +to unlock: -### Install both packages +- Editable `Post` and `Page` content out of the box. +- A real backing for `core/site-*` blocks (site title, tagline, logo, icon). +- Working `core/post-*`, `core/query`, taxonomy widget, and navigation + blocks. +- Templates, template parts, patterns, global styles, and menus + persisted via cms-framework's models. +- Seeded `visual_editor.*` permissions. ```bash composer require artisanpack-ui/visual-editor artisanpack-ui/cms-framework -``` - -Both packages are loosely coupled — cms-framework's editor wiring is guarded by `class_exists(\ArtisanPackUI\VisualEditor\VisualEditor::class)`, so each package remains usable on its own. - -### Run migrations - -```bash php artisan migrate ``` -cms-framework's V1.x migration set adds a `block_content json nullable` column to its `posts` and `pages` tables (the legacy `content` longText column is preserved for search / excerpt / backwards-compatibility — see plan 12 §4.2 for the dual-state guidance). +Both packages are loosely coupled — cms-framework's editor wiring is +guarded by `class_exists(\ArtisanPackUI\VisualEditor\VisualEditor::class)`, +so each remains usable on its own. The full integration contract lives in +[`docs/plans/12-cms-framework-integration.md`](docs/plans/12-cms-framework-integration.md). -### Resource map - -When both packages are installed, cms-framework registers its `Post` and `Page` into the `ap.visual-editor.resources` filter automatically. The merged map ends up shaped like: - -```php -// Effective config('artisanpack.visual-editor.resources') after the -// ap.visual-editor.resources filter has run with both packages installed. -[ - 'posts' => \ArtisanPackUI\CMSFramework\Modules\Blog\Models\Post::class, - 'pages' => \ArtisanPackUI\CMSFramework\Modules\Pages\Models\Page::class, - // …plus any slug → model entries the host app added to - // config/artisanpack/visual-editor.php (static config wins on collision). -] -``` - -Host-app overrides in `config/artisanpack/visual-editor.php` always take precedence on key collision, so swapping `posts` to a custom `App\Models\Post` is just a config edit. - -### Version pairing - -visual-editor V1.0.x ships against cms-framework V1.x. The compatibility matrix is in [Version compatibility](#version-compatibility) above; the [`docs/g6-smoke-flow.md`](docs/g6-smoke-flow.md) and [`docs/h8-smoke-flow.md`](docs/h8-smoke-flow.md) flows run against the version pair before every release tag. +--- ## Extensibility -The package exposes a small filter surface so other packages can contribute editor wiring at runtime without forcing host apps to publish-and-edit the package config. - ### `ap.visual-editor.resources` -Register slug → Eloquent model class mappings used by `/visual-editor/api/{resource}/{id}/content`. Filter contributions are merged with `config('artisanpack.visual-editor.resources')`; the static config wins on key collision so host-app overrides always take precedence. Models must use `ArtisanPackUI\VisualEditor\Concerns\HasBlockContent` — invalid entries surface as `InvalidArgumentException` on first request rather than at boot, so a contributor's standalone install never trips host boot. +Register slug → Eloquent model class mappings used by +`/visual-editor/api/{resource}/{id}/content`. Filter contributions are +merged with `config('artisanpack.visual-editor.resources')`; the static +config wins on key collision so host-app overrides always take +precedence. Models must use +`ArtisanPackUI\VisualEditor\Concerns\HasBlockContent` — invalid entries +surface as `InvalidArgumentException` on first request rather than at +boot, so a contributor's standalone install never trips host boot. ```php -addFilter( 'ap.visual-editor.resources', function ( array $resources ): array { - return array_merge( [ +addFilter('ap.visual-editor.resources', function (array $resources): array { + return array_merge([ 'posts' => App\Models\Post::class, - ], $resources ); -} ); + ], $resources); +}); ``` -Full contract (input/output shape, collision behavior, validation guarantees, contributor timing): [`docs/plans/12-cms-framework-integration.md`](docs/plans/12-cms-framework-integration.md) §4.1. +Full contract: +[`docs/plans/12-cms-framework-integration.md`](docs/plans/12-cms-framework-integration.md) §4.1. + +### `ap.icons.register-icon-sets` + +Editor chrome icons resolve through `artisanpack-ui/icons`. Register +additional icon sets in a service provider — see the +[`artisanpack-ui/icons`](https://github.com/ArtisanPack-UI/icons) docs. + +--- ## i18n -Editor strings use `@wordpress/i18n` with the `artisanpack-visual-editor` text domain. The domain is initialized via `bootI18n()` in `resources/js/visual-editor/vendor/i18n.ts`. +Editor strings use `@wordpress/i18n` with the `artisanpack-visual-editor` +text domain. The domain is initialized via `bootI18n()` in +`resources/js/visual-editor/vendor/i18n.ts`. Regenerate the placeholder `.pot` catalog with: @@ -132,9 +270,15 @@ Regenerate the placeholder `.pot` catalog with: npm run i18n:extract ``` -The extractor scans `resources/js/visual-editor/**/*.{ts,tsx}` for `__/_x/_n/_nx` calls bound to the text domain and writes `languages/artisanpack-visual-editor.pot`. It is deliberately minimal — a richer extractor (template strings, plural forms, PHP scanning) replaces it post-V1. +The extractor scans `resources/js/visual-editor/**/*.{ts,tsx}` for +`__/_x/_n/_nx` calls bound to the text domain and writes +`languages/artisanpack-visual-editor.pot`. + +--- ## Contributing -As an open source project, this package is open to contributions from anyone. Please [read through the contributing -guidelines](CONTRIBUTING.md) to learn more about how you can contribute to this project. +As an open source project, this package is open to contributions from +anyone. Please [read through the contributing +guidelines](CONTRIBUTING.md) to learn more about how you can contribute +to this project. diff --git a/docs/blade-component.md b/docs/blade-component.md new file mode 100644 index 000000000..543c1c44b --- /dev/null +++ b/docs/blade-component.md @@ -0,0 +1,124 @@ +# Blade component reference + +`` is the single Blade entry point. It renders a +`
` mount point with data-attributes that the +React editor reads on boot. + +```blade + +``` + +--- + +## Attributes + +| Attribute | Type | Required | Default | Purpose | +|-----------|------|----------|---------|---------| +| `:model` | Eloquent `Model` | **yes** | — | The model being edited. Must use `HasBlockContent`. | +| `resource` | `string` | no | reverse-looked-up from the resource map | The slug under which the model is registered. Provide explicitly if your config maps multiple slugs to the same class (or to override). | +| `:apiBase` | `string` | no | `/visual-editor/api` | API root the editor talks to. Change if you've mounted the package routes under a custom prefix. | +| `:initialTitle` | `?string` | no | `null` | Pre-fills the document-title field in the editor topbar. | +| `:initialSlug` | `?string` | no | `null` | Pre-fills the slug field in the document panel. | +| `:initialStatus` | `?string` | no | `null` | Pre-fills the status pill (`draft`, `published`, custom values). | +| `:initialExcerpt` | `?string` | no | `null` | Pre-fills the excerpt field. Requires `supports.excerpt = true`. | +| `:initialFeaturedImage` | `?array` | no | `null` | Pre-fills the featured-image picker. Shape: `['id' => int, 'url' => string, 'alt' => ?string]`. | +| `:initialAuthorId` | `int\|string\|null` | no | `null` | Pre-selects the author. | +| `:initialCommentsOpen` | `?bool` | no | `null` | Pre-fills the comments toggle. | +| `:authorOptions` | `?array` | no | `null` | Dropdown options for the author picker. Shape: `[['value' => 1, 'label' => 'Jane'], …]`. | +| `:supports` | `?array` | no | `null` | Which document-panel fields to render. Keys: `excerpt`, `featuredImage`, `comments` (all `bool`). Omit to show everything the trait supports. | +| `:previewUrl` | `?string` | no | `null` | Front-end preview URL. Opens in a new tab from the topbar Preview button. | + +The component passes its `$attributes` bag through to the root div, so +`class`, `id`, `style`, and `wire:ignore` are all forwarded. + +--- + +## Resource resolution + +If you omit `resource`, the component reverse-looks-up the model class in +`config('artisanpack.visual-editor.resources')` and uses the first +matching slug. If the class isn't in the map, it throws +`RuntimeException` at render time so misconfiguration surfaces +loudly. + +Always set `resource="…"` explicitly when: + +- The same model class is registered under multiple slugs. +- You want render to fail fast at template time rather than at API time. +- A future config change might add a sibling mapping. + +--- + +## API surface used by the editor + +| Method | Path | When | +|--------|------|------| +| `GET` | `{apiBase}/{resource}/{id}/content` | On mount, fetches the persisted block tree. | +| `PUT` | `{apiBase}/{resource}/{id}/content` | On autosave (debounced ~1s after the last change) and on explicit save (⌘S). | +| `POST` | `{apiBase}/blocks/preview` | When a dynamic block requests an editor preview render. | +| `POST` | `{apiBase}/query/resolve` | When a `core/query` block needs paginated results. | +| `GET` | `{apiBase}/blocks` | Once on mount, to fetch the enabled-block manifest. | + +All routes run through the middleware stack in +`config('artisanpack.visual-editor.api.middleware')` (default +`['api', 'auth']`). + +--- + +## Browser events + +The editor dispatches three `CustomEvent`s on `window`: + +| Event | When | `detail` shape | +|-------|------|----------------| +| `ve:editor:change` | Debounce window closes, right before autosave fires. | `{ resource, id, blocks }` | +| `ve:editor:autosave` | Debounce-triggered save returns 200. | `{ resource, id, blocks, updatedAt }` | +| `ve:editor:save` | Explicit save (⌘S or topbar) returns 200. | `{ resource, id, blocks, updatedAt }` | + +`resource` and `id` match the component's `data-resource` / `data-id` +attributes, so a single listener can disambiguate multiple editors on the +same page. See [Livewire](livewire.md) and [Inertia](inertia.md) for +embedding recipes. + +--- + +## Mounting multiple editors + +Each mount is a separate React root with its own store. There's no +cross-talk between them — autosaves don't race, undo histories don't +share. Use distinct DOM ids if you need to scroll to one programmatically. + +```blade +@foreach ($posts as $post) + +@endforeach +``` + +--- + +## Theming and styling + +The root div gets `class="ap-visual-editor"` plus any classes you pass in +via `$attributes`. Editor chrome is themed through DaisyUI tokens — see +[Theming](theming.md) for how to override colors and typography. + +--- + +## See also + +- [Content model](content-model.md) — `HasBlockContent`, resource map, policies +- [Getting started](getting-started.md) — end-to-end install +- [Livewire](livewire.md) / [Inertia](inertia.md) — embedding recipes diff --git a/docs/content-model.md b/docs/content-model.md new file mode 100644 index 000000000..66d5b218c --- /dev/null +++ b/docs/content-model.md @@ -0,0 +1,197 @@ +# Content model + +The visual editor stores content as a Gutenberg-shaped block tree (a JSON +array of `{ name, attributes, innerBlocks, … }` nodes) on any Eloquent +model that opts in via the `HasBlockContent` trait. Models are exposed to +the editor through the **resource map**, a slug → model class registry +read by the API layer. + +This page covers the trait, the resource map, and the policy / authorization +surface. + +--- + +## 1. The `HasBlockContent` trait + +`ArtisanPackUI\VisualEditor\Concerns\HasBlockContent` + +Add the trait to any model whose content should be editable: + +```php +use ArtisanPackUI\VisualEditor\Concerns\HasBlockContent; +use Illuminate\Database\Eloquent\Model; + +class Post extends Model +{ + use HasBlockContent; +} +``` + +### Conventions and overrides + +| Behaviour | Default | Override | +|-----------|---------|----------| +| Column storing the block tree | `content` (JSON) | `protected string $blockContentColumn = 'body'` | +| Query scope applied by the resource resolver | `forVisualEditor` (passthrough) | `protected string $blockContentScope = 'published'` (must exist on the model as a scope method) | +| Cast for the block column | `array` (auto-applied if not present) | Set `$casts['content'] = 'json'` explicitly to opt out of the auto-cast | +| Searchable text | Plain-text extract of the block tree | Override `blockContentSearchableText(): string` | + +The trait's `initializeHasBlockContent()` hook auto-applies the `array` +cast at boot. If your model already casts the column, the trait leaves +your cast untouched. + +### Public API + +```php +$post->getBlockContent(); // array> +$post->setBlockContent($blocks); // void +$post->blockContentSearchableText(); // string +$post->toBlockContentSearchableArray(); // ['block_content' => '…'] for Scout +Post::query()->forVisualEditor()->get(); // optional content scope +``` + +The `forVisualEditor` scope is what the resource resolver applies before +fetching by id. By default it's a passthrough — override +`$blockContentScope` to filter by status, ownership, tenant, etc. + +### Migration + +Add the column when introducing the trait to an existing model: + +```php +Schema::table('posts', function (Blueprint $table) { + $table->json('content')->nullable(); +}); +``` + +The legacy `ve_contents` table the package ships is only used by the +fallback `VisualEditorPost` model and the `/editor` test route. Host-app +models never write to it. + +--- + +## 2. The resource map + +The resource map is a slug → model class array consulted by every +content-bearing API endpoint: + +``` +GET /visual-editor/api/{resource}/{id}/content +PUT /visual-editor/api/{resource}/{id}/content +``` + +`{resource}` is the slug. The map is read from two places, merged at boot: + +1. **Static config** — `config('artisanpack.visual-editor.resources')` + in `config/artisanpack/visual-editor.php`. +2. **Filter** — `ap.visual-editor.resources`, contributed by packages + like cms-framework. + +```php +// config/artisanpack/visual-editor.php +return [ + 'resources' => [ + 'posts' => App\Models\Post::class, + 'pages' => App\Models\Page::class, + ], +]; +``` + +```php +// From a package's service provider +addFilter('ap.visual-editor.resources', function (array $resources): array { + return array_merge([ + 'posts' => MyPackage\Models\Post::class, + ], $resources); +}); +``` + +**Collision wins:** static config beats filter contributions. The host +app's published config is authoritative — packages can suggest a default, +the app can override. + +**Validation timing:** the map is not validated at boot. The first +request that resolves a missing resource raises `NotFoundHttpException` +(returned to the client as 404); a model that's registered but doesn't +use `HasBlockContent` raises `InvalidArgumentException`. This is +deliberate — contributor packages that aren't installed in a given +environment never trip boot. + +### Resolution flow + +1. Request comes in: `PUT /visual-editor/api/posts/42/content`. +2. `ResourceResolver` looks up `posts` → `App\Models\Post`. +3. It calls `Post::query()->forVisualEditor()->findOrFail(42)`. +4. The controller calls `$post->setBlockContent($blocks)` and `$post->save()`. + +The model must use `HasBlockContent` — the resolver checks the trait +explicitly and throws otherwise. + +--- + +## 3. Policies and authorization + +Resource models use **their own Laravel policies**. The package does not +inject a "visual editor policy" on top of them. If your `PostPolicy` +already gates `update`, the editor's save endpoint inherits it. + +The controllers call `Gate::authorize('update', $model)` (or +`Gate::authorize('view', $model)` on the show endpoint) before +reading/writing block content. + +### Site-editor access gate + +The site editor (templates, parts, patterns, global styles, navigation) is +gated by a single boot-time contract: +`ArtisanPackUI\VisualEditor\SiteEditor\Contracts\SiteEditorAccessGate`. + +Default implementation: `DenyByDefaultGate` — fail-closed. Bind your own +to open it: + +```php +// AppServiceProvider::register() +$this->app->bind( + \ArtisanPackUI\VisualEditor\SiteEditor\Contracts\SiteEditorAccessGate::class, + \App\Auth\AllowAdminsGate::class, +); +``` + +When cms-framework is installed it auto-binds `CmsFrameworkInstallGate`, +which checks that cms-framework is installed and migrations have run +before allowing access. + +### API middleware + +Every `/visual-editor/api/*` route runs through +`config('artisanpack.visual-editor.api.middleware')` (default: +`['api', 'auth']`). Replace the default if you need Sanctum, a different +guard, or unauthenticated read access. + +--- + +## 4. Multiple resources, one editor + +A single editor mount edits one resource at a time, scoped by the +`data-resource` and `data-id` attributes that the Blade component emits. +You can mount more than one editor on the same page — every editor event +includes `{ resource, id }` in its `detail`, so listeners can +disambiguate. See the [Livewire recipe](livewire.md) for an example of +multiple editors coexisting. + +--- + +## 5. The `ve_contents` fallback table + +The package's migration creates `ve_contents` (`id`, `author_id`, `title`, +`blocks`, `timestamps`). It backs the `VisualEditorPost` model used by the +default `/editor` route and a handful of tests. Production apps that +register their own resource models can ignore the table — it stays empty +and never grows. + +--- + +## See also + +- [Blade component reference](blade-component.md) — attribute-by-attribute +- [Custom blocks](custom-blocks.md) — extending what authors can insert +- [Renderers](renderers.md) — getting saved content back onto the page diff --git a/docs/custom-blocks.md b/docs/custom-blocks.md index 8c70619f7..63a41fece 100644 --- a/docs/custom-blocks.md +++ b/docs/custom-blocks.md @@ -225,6 +225,83 @@ Use a dynamic block when: render (latest posts, cart totals, stock levels). - The block needs to run Laravel authorization before exposing data. +### Dynamic block hooks + +`DynamicBlock` exposes four overridable methods. Only `name()` and +`render()` are required; the others have safe defaults. + +```php +abstract class DynamicBlock +{ + abstract public function name(): string; + abstract public function render(array $attrs); // : View|Stringable|string + + public function validateAttrs(array $attrs): array + { + return $attrs; + } + + public function searchableText(array $attrs): string + { + return ''; + } + + public function authorize(?Authenticatable $user, array $attrs): bool + { + return true; + } +} +``` + +- **`validateAttrs(array $attrs): array`** — runs before `render()` and + before persistence. Normalize, coerce, and reject bad input. Throw + `InvalidArgumentException` to abort. Defaults to passthrough. + + ```php + public function validateAttrs(array $attrs): array + { + $limit = filter_var($attrs['limit'] ?? 5, FILTER_VALIDATE_INT); + if ($limit === false || $limit < 1 || $limit > 50) { + throw new \InvalidArgumentException('limit must be 1–50'); + } + + return ['limit' => $limit]; + } + ``` + +- **`searchableText(array $attrs): string`** — plain-text extract used by + `HasBlockContent::blockContentSearchableText()` and the Scout searchable + array. Return whatever the block contributes to full-text search. + Defaults to empty string. + + ```php + public function searchableText(array $attrs): string + { + return (string) ($attrs['heading'] ?? ''); + } + ``` + +- **`authorize(?Authenticatable $user, array $attrs): bool`** — gates + preview rendering during authoring. Return `false` to render an + authorization-denied placeholder instead of the block. Defaults to + `true`. Public-site renders go through the renderer package directly + and run the host app's own authorization. + + ```php + public function authorize(?Authenticatable $user, array $attrs): bool + { + return $user?->can('view-internal-block') ?? false; + } + ``` + +### Block preview endpoint + +While authoring, dynamic blocks render via +`POST /visual-editor/api/blocks/preview`. The editor sends +`{ blockName, attributes }`; the controller resolves the block, runs +`validateAttrs` → `authorize` → `render`, and returns HTML. Cache misses +during typing are amortized by the editor's per-block debounce. + --- ## 5. Rendering on the public frontend diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 000000000..5245a0e9a --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,192 @@ +# Getting started + +Install the visual editor, register a model as editable content, mount the +Blade component, and ship a first post — under an hour from `composer require` +to working editor. + +If you also need site-wide chrome (templates, template parts, theme +typography, menus, patterns) install `artisanpack-ui/cms-framework` alongside +the editor. The two packages are a version pair (V1.x ↔ V1.x). See +[Version compatibility](../README.md#version-compatibility). + +--- + +## 1. Install + +```bash +composer require artisanpack-ui/visual-editor +php artisan migrate +``` + +The package ships a `ve_contents` table for the legacy `VisualEditorPost` +fallback model. Host-app resource models you register later don't touch this +table — they store block content on their own. + +### Peer dependencies + +The editor UI is built on [`@artisanpack-ui/react`](https://www.npmjs.com/package/@artisanpack-ui/react), +which is styled with DaisyUI + Tailwind CSS v4. Host apps must have: + +- `tailwindcss` `^4.0.0` +- `daisyui` `^5.0.0` + +Both are loaded once at the app shell level. The editor inherits them from +the host page. + +### Pair with cms-framework (recommended) + +```bash +composer require artisanpack-ui/cms-framework +php artisan migrate +``` + +Adds a `block_content` column to cms-framework's `posts` and `pages` tables, +seeds `visual_editor.*` permissions, and auto-registers +`Post`/`Page` into the resource map. The site editor's install gate goes +green and the `core/post-*`, `core/site-*`, `core/query`, taxonomy, and +navigation blocks all resolve. + +You can skip this step — the editor works standalone for a single resource +model — but you'll have to wire entity blocks yourself. + +--- + +## 2. Publish the config (optional) + +```bash +php artisan vendor:publish --tag=artisanpack-visual-editor-config +``` + +Drops `config/artisanpack/visual-editor.php`. Edit it to register +resources, swap the API middleware, tune the block allow/deny lists, or +change global-styles theme/schema pinning. See [Blade component reference](blade-component.md) +and [Custom blocks](custom-blocks.md) for what each key does. + +--- + +## 3. Register a resource model + +A "resource" is any Eloquent model whose content is editable via the visual +editor. Add the `HasBlockContent` trait and declare it in the config: + +```php +// app/Models/Post.php +namespace App\Models; + +use ArtisanPackUI\VisualEditor\Concerns\HasBlockContent; +use Illuminate\Database\Eloquent\Model; + +class Post extends Model +{ + use HasBlockContent; + + protected $fillable = ['title', 'slug', 'content']; +} +``` + +```php +// config/artisanpack/visual-editor.php +return [ + 'resources' => [ + 'posts' => App\Models\Post::class, + ], + // … +]; +``` + +The trait adds a `content` JSON column convention, a Scout-friendly +searchable accessor, and the `forVisualEditor` query scope used by the +resource resolver. Full reference: [Content model](content-model.md). + +If your model already has a different column for block content: + +```php +class Post extends Model +{ + use HasBlockContent; + + protected $blockContentColumn = 'body'; +} +``` + +Run the migration that adds the column: + +```bash +php artisan make:migration add_content_to_posts_table +``` + +```php +Schema::table('posts', function (Blueprint $table) { + $table->json('content')->nullable(); +}); +``` + +--- + +## 4. Mount the editor + +In whatever Blade view backs your edit screen: + +```blade +{{-- resources/views/admin/posts/edit.blade.php --}} + +``` + +The Blade component emits a single `
` mount +point. The React app boots against it on page load, fetches block content +from `/visual-editor/api/posts/{id}/content`, and autosaves on every change. + +Full attribute list and event contract: [Blade component reference](blade-component.md). + +--- + +## 5. Build assets + +The editor JS bundle is registered automatically by the package's Vite +plugin. From your app: + +```bash +npm install +npm run build # or: npm run dev +``` + +The editor mounts on every page that contains a `[data-ap-visual-editor]` +element. + +--- + +## 6. Try it + +1. Run `php artisan serve` (or `composer run dev` in this dev app). +2. Visit the edit screen. +3. Insert a Paragraph block, type, watch the autosave indicator fire. +4. Reload — content persists. + +If the editor doesn't appear, check the browser console for missing peer +deps (Tailwind / DaisyUI) and confirm the resource is registered. See +[Troubleshooting](troubleshooting.md). + +--- + +## 7. Add the site editor (optional) + +If cms-framework is installed, the site editor shell is already mounted at +`/visual-editor/site`. The default access gate fails closed — implement +your own `SiteEditorAccessGate` or rely on cms-framework's +`CmsFrameworkInstallGate`. See [Site editor](site-editor.md). + +--- + +## Where to go next + +- **Authoring custom blocks:** [Custom blocks](custom-blocks.md) +- **Rendering saved content on the public site:** [Renderers](renderers.md) +- **Editing templates and theme styles:** [Site editor](site-editor.md), [Templates](templates.md), [Global styles](global-styles.md) +- **Embedding inside Livewire:** [Livewire](livewire.md) +- **Embedding inside Inertia:** [Inertia](inertia.md) +- **Things that go wrong:** [Troubleshooting](troubleshooting.md) diff --git a/docs/global-styles.md b/docs/global-styles.md index 23e5c70df..695eeef63 100644 --- a/docs/global-styles.md +++ b/docs/global-styles.md @@ -1,30 +1,38 @@ -# Global styles — theme.json schema pin +# Global styles -The visual editor's `globalStyles` singleton is the theme.json-shaped -record the site editor reads and writes. C3 (#359) introduces the -backend: a `VisualEditorGlobalStyles` model, migration, REST endpoints, -validated write path, and policy. This document records the **schema -version we pin the package to**, the constraints that follow from that -decision, and how a future version bump is handled. +The visual editor's global styles are the theme.json-shaped record the +site editor reads and writes to drive site-wide typography, color, +layout, and per-block style overrides. One record per theme, accessed as +a singleton, validated against a pinned schema version, emitted to CSS at +render time. -## Pinned version +This page covers the schema pin, record shape, REST surface, validation, +the site-editor styles panels, CSS emission, and how a future schema bump +is handled. + +--- + +## 1. Pinned schema version The package pins to **theme.json schema version 3**. The value lives in config at `artisanpack.visual-editor.global_styles.schema_version` (not -as a hard-coded constant in code) so host apps can override if they -know what they are doing; the default is what the package is tested -against. +as a hard-coded constant) so host apps can override if they know what +they're doing — the default is what the package is tested against. The pin tracks the `@wordpress/*` packages vendored into the editor -bundle (see `package.json` — `@wordpress/block-editor`, -`@wordpress/block-library`, `@wordpress/components`, etc.). These -packages target a specific WordPress core release series; the -corresponding theme.json schema version is the one the package treats -as "compatible". Updating the pinned `@wordpress/*` versions without a -conscious theme.json review is what §4.2 of the V1 plan calls out as -silent drift — the pin is here to force the review. +bundle (`@wordpress/block-editor`, `@wordpress/blocks`, +`@wordpress/components`). Those packages target a specific WordPress +core release; the corresponding theme.json schema version is what the +package treats as compatible. Updating the pinned `@wordpress/*` versions +without a conscious theme.json review is the silent drift the V1 plan +explicitly calls out — the pin is here to force the review. + +See [Troubleshooting §3](troubleshooting.md#3-wordpress-package-upgrades) +for the schema-bump procedure. + +--- -## Record shape +## 2. Record shape The record stores a theme.json-shaped blob: @@ -32,69 +40,137 @@ The record stores a theme.json-shaped blob: { "id": 7, "version": 3, + "theme": "artisanpack-base", "settings": { - "color": { "palette": [ /* { slug, name, color } */ ] }, + "color": { "palette": [ { "slug": "primary", "name": "Primary", "color": "#3b82f6" } ] }, "typography": { - "fontFamilies": [ /* { slug, name, fontFamily } */ ], - "fontSizes": [ /* { slug, name, size } */ ] + "fontFamilies": [ { "slug": "sans", "name": "Sans", "fontFamily": "Inter, system-ui, sans-serif" } ], + "fontSizes": [ { "slug": "base", "name": "Base", "size": "1rem" } ] }, - "layout": { "contentSize": "720px", "wideSize": "1120px" } + "layout": { "contentSize": "720px", "wideSize": "1120px" }, + "spacing": { "spacingScale": { "operator": "*", "increment": 1.5, "steps": 7 } } }, "styles": { "color": { "background": "...", "text": "..." }, "typography": { "fontFamily": "...", "fontSize": "...", "lineHeight": "..." }, "elements": { "link": { /* ... */ }, "heading": { /* ... */ } }, - "blocks": { "core/button": { /* ... */ } } + "blocks": { "artisanpack/button": { /* ... */ } } } } ``` -The full reference payload lives at -`resources/theme-json/default-base.php` and is returned by -`GET /visual-editor/api/global-styles/base`. +The packaged defaults live at `resources/theme-json/default-base.php` and +are returned by `GET /visual-editor/api/global-styles/base`. Host apps +override defaults by setting +`artisanpack.visual-editor.global_styles.base_path` to a custom PHP file +that returns the same shape. -## Endpoints +--- -See `docs/core-data-shim.md` §Global styles for the REST contract the -core-data shim addresses. C3 ships four endpoints: +## 3. REST API -- `GET /visual-editor/api/global-styles/lookup` — resolves the active - theme's singleton id (creating the row on first access). -- `GET /visual-editor/api/global-styles/{id}` — user record. -- `PUT /visual-editor/api/global-styles/{id}` — write user record; - validated against the pinned schema version. -- `GET /visual-editor/api/global-styles/base` — theme defaults; the - site-editor diffs the user record against this to show what has been - customized. +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/visual-editor/api/global-styles/lookup` | Resolve the active theme's singleton id (creates the record on first access). | +| `GET` | `/visual-editor/api/global-styles/base` | Theme defaults — the site editor diffs the user record against this to show what's been customized. | +| `GET` | `/visual-editor/api/global-styles/{id}` | Fetch the user record. | +| `PUT` | `/visual-editor/api/global-styles/{id}` | Update the user record. Validated against the pinned schema version. | +| `GET` | `/visual-editor/api/global-styles/css` | Emit the user record as CSS for front-end injection. | -## Validation +All endpoints behind the API middleware stack. + +--- + +## 4. Validation `PUT` requests flow through -`ArtisanPackUI\VisualEditor\Http\Requests\UpdateGlobalStylesRequest` which -enforces: +`ArtisanPackUI\VisualEditor\Http\Requests\UpdateGlobalStylesRequest`, +which enforces: - `version` must equal the pinned `schema_version` — a schema bump is - explicit work, not a client-driven drift. + explicit work, not client-driven drift. - `settings` and `styles` are required arrays. -- `settings.color.palette` entries must each carry `slug`, `name`, - `color`; slugs must be unique across the palette (duplicate slugs - collapse to a single CSS variable, masking a real bug). +- `settings.color.palette` entries each carry `slug`, `name`, `color`; + slugs must be unique across the palette (duplicate slugs collapse to a + single CSS variable, masking a real bug). - `settings.typography.fontFamilies` and `settings.typography.fontSizes` likewise require unique slugs. +- Per-block styles under `styles.blocks.{block}` must reference a + registered block name. -A 422 response with a `version`, `settings`, or -`settings.color.palette` validation error is the signal that the -payload does not match the pinned schema. +A 422 response with `version`, `settings`, or `settings.color.palette` +validation errors is the signal that the payload doesn't match the +pinned schema. -## Multi-theme scoping +--- -The record carries a `theme` column so each installed theme gets its -own singleton. The active theme is configured at +## 5. Multi-theme scoping + +The record carries a `theme` column so each installed theme gets its own +singleton. The active theme is configured at `artisanpack.visual-editor.global_styles.theme` (default -`artisanpack-base`). Switching themes gets a fresh singleton rather +`artisanpack-base`). Switching themes loads a fresh singleton rather than inheriting the previous theme's customizations. -## Handling a future schema bump +--- + +## 6. Site-editor styles panels + +The site editor's Styles section exposes four nested panels: + +- **Typography** — font families, font sizes, base type styles + (`styles.typography`), element overrides (heading, link). +- **Colors** — palette, background, text, link. +- **Layout** — content width, wide width, root padding, spacing scale. +- **Blocks** — per-block style overrides (`styles.blocks.{block}`), + organized alphabetically by block name. + +Each panel is a controlled form over a slice of the record; saves are +debounced and write the full record via PUT. + +A "Reset to theme default" button at the panel level (and per-field +chevrons) deletes the user value for that key, restoring whatever +`/global-styles/base` returns. + +--- + +## 7. CSS emission + +`GET /visual-editor/api/global-styles/css` returns a stylesheet derived +from the user record. The Blade renderer's `` and React/Vue +`` components inject it once at the root. + +Generated CSS shape: + +```css +:root { + --wp--preset--color--primary: #3b82f6; + --wp--preset--font-size--base: 1rem; + --wp--preset--font-family--sans: Inter, system-ui, sans-serif; +} + +body { + color: var(--wp--preset--color--text); + background-color: var(--wp--preset--color--background); + font-family: var(--wp--preset--font-family--sans); +} + +.is-style-h1, h1.wp-block-heading { + font-size: var(--wp--preset--font-size--xxx-large); +} + +.wp-block-button { + /* per-block styles emitted from styles.blocks['artisanpack/button'] */ +} +``` + +CSS variables follow the `--wp--preset--{category}--{slug}` convention +so existing Gutenberg block markup using `var(--wp--preset--*)` resolves +without modification. + +--- + +## 8. Handling a future schema bump When the `@wordpress/*` pins are upgraded and the upstream theme.json schema changes: @@ -107,21 +183,20 @@ schema changes: 3. Update `resources/theme-json/default-base.php` so the defaults match the new schema. 4. Ship a migration that either rewrites existing user records into the - new schema shape or documents an upgrade path for host apps that - have customized their global styles. + new schema shape or documents an upgrade path for host apps that have + customized their global styles. 5. Update this doc with the new pinned version and the WP core release it tracks. -The point of the pin is that each of these steps is a conscious -decision — not a silent change that happens the next time `composer -update` or `npm update` runs. +The point of the pin is that each of these steps is a conscious decision +— not a silent change that happens the next time `composer update` or +`npm update` runs. + +--- -## Out of scope for C3 +## See also -- CSS emission / frontend rendering — **E1** (front-end global-styles - CSS emission). -- Site-editor global-styles UI (typography, colors, layout, blocks, - variations) — **D3**. -- Style variations (theme-level presets) — deferred; §8 of the plan doc - tracks this as an open question for 1.1. -- Revision history of global-styles edits. +- [Site editor](site-editor.md) — the surface that edits global styles +- [Templates](templates.md) — templates that the styles apply to +- [Renderers](renderers.md) — CSS injection on the public site +- [Troubleshooting](troubleshooting.md) — schema-bump procedure diff --git a/docs/inertia.md b/docs/inertia.md new file mode 100644 index 000000000..4cc7e064f --- /dev/null +++ b/docs/inertia.md @@ -0,0 +1,256 @@ +# Inertia embedding recipes + +The visual editor is a React app under the hood, so embedding inside +Inertia is just a matter of mounting the Blade component on the Inertia +page and letting the React tree boot inside it. The same recipe works +inside Inertia + React (direct) and Inertia + Vue (via the Vue wrapper). + +Both recipes assume the editor is being mounted inside an Inertia page +that the host app owns — the host controls auth, navigation, and +non-editor chrome; the editor takes over the canvas region. + +--- + +## 1. Inertia + React + +The Blade `` mount works inside an Inertia-rendered +template because Inertia hydrates inside an outer Blade layout. Mount the +editor as a top-level page or inside a content slot, and import the +editor's React bundle from the host's entrypoint. + +### Server-side: the Inertia page + +```php +// app/Http/Controllers/PostEditorController.php +public function edit(Post $post) +{ + return Inertia::render('Posts/Edit', [ + 'post' => [ + 'id' => $post->id, + 'title' => $post->title, + 'slug' => $post->slug, + 'status' => $post->status, + 'previewUrl' => route('posts.show', $post), + ], + ]); +} +``` + +### Client-side: the React page + +```tsx +// resources/js/Pages/Posts/Edit.tsx +import { useEffect, useRef } from 'react'; +import { mountVisualEditor } from '@artisanpack-ui/visual-editor'; + +export default function PostEditor({ post }) { + const mountRef = useRef(null); + + useEffect(() => { + if (!mountRef.current) return; + + const editor = mountVisualEditor(mountRef.current, { + resource: 'posts', + id: String(post.id), + apiBase: '/visual-editor/api', + initialTitle: post.title, + initialSlug: post.slug, + initialStatus: post.status, + previewUrl: post.previewUrl, + supports: { excerpt: true, featuredImage: true }, + }); + + return () => editor.unmount(); + }, [post.id]); + + return ( + +
+ + ); +} +``` + +The `mountVisualEditor(el, config)` helper is the imperative equivalent +of the Blade component — pass the same config keys (camelCase). It returns +an `{ unmount, save, getBlocks }` handle. + +### Bridging editor events + +The same `ve:editor:*` events the Blade component dispatches fire here. +Listen in the page component: + +```tsx +useEffect(() => { + const onSave = (event: CustomEvent) => { + if (event.detail.resource !== 'posts') return; + if (event.detail.id !== String(post.id)) return; + // Sync Inertia state, show toast, etc. + }; + + window.addEventListener('ve:editor:save', onSave as EventListener); + return () => window.removeEventListener('ve:editor:save', onSave as EventListener); +}, [post.id]); +``` + +### Inertia navigation and `wire:ignore`-equivalent + +Inertia doesn't have `wire:ignore` — when navigating between Inertia +pages, the React tree unmounts entirely. The cleanup function in the +`useEffect` handles this; the editor's autosave debounce flushes before +unmount. + +To preserve unsaved edits during programmatic navigation, gate +`Inertia.visit()` on the editor's `getBlocks()` returning a clean state: + +Inside the `useEffect`, hold the editor handle in a ref so it survives +between renders: + +```tsx +const editorRef = useRef>(); + +useEffect(() => { + if (!mountRef.current) return; + editorRef.current = mountVisualEditor(mountRef.current, { /* …config */ }); + return () => editorRef.current?.unmount(); +}, [post.id]); + +const handleNavigate = (href: string) => { + if (editorRef.current?.isDirty()) { + if (!confirm('You have unsaved changes. Leave?')) return; + } + Inertia.visit(href); +}; +``` + +--- + +## 2. Inertia + Vue + +The Vue wrapper at `@artisanpack-ui/visual-editor/vue` exports a +`` Vue component that wraps the React editor in a Vue tree +(the editor itself stays React; Vue manages mount/unmount + prop +reactivity). + +### Server-side: same Inertia page + +The controller is identical to the React recipe — Inertia doesn't care +which client renderer the page uses. + +### Client-side: the Vue page + +```vue + + + + +``` + +The Vue component re-emits `ve:editor:*` browser events as Vue +component events (`save`, `autosave`, `change`) so you can wire them +declaratively. + +### Vue → React data flow + +Props on `` map 1:1 to the React editor's config. Changing +a prop after mount re-renders the React tree with the new config — but +the editor doesn't reload content on a re-render. Change `:id` or +`:resource` and the editor will detect it and fetch the new entity. + +--- + +## 3. Front-end rendering + +The Inertia + React / Inertia + Vue recipes above are for the **edit +surface**. Rendering saved content on the public site uses the renderer +packages: + +- React: `@artisanpack-ui/visual-editor-renderer-react` +- Vue: `@artisanpack-ui/visual-editor-renderer-vue` + +```tsx +// resources/js/Pages/Posts/Show.tsx +import { BlockTree, GlobalStyles } from '@artisanpack-ui/visual-editor-renderer-react'; + +export default function PostShow({ post }) { + return ( + <> + + + + ); +} +``` + +See [Renderers](renderers.md) for the full client renderer API. + +--- + +## 4. SSR + +The editor itself is client-only (it relies on `window` and DOM APIs). +Inertia SSR will skip the editor's render pass — wrap the mount in a +`` (Inertia + Vue) or guard the `useEffect` (Inertia + React) +so server rendering produces an empty mount point. + +Front-end **renderers** work in SSR — the React renderer renders to +static HTML, the Vue renderer renders to static markup. Use the Blade +renderer when you want SSR via Laravel itself (Inertia or not). + +--- + +## 5. Authentication + +The editor's API calls inherit the host page's session cookies — no +extra wiring needed when Inertia uses the standard `web` middleware. For +Sanctum / API-token auth, override the API middleware: + +```php +// config/artisanpack/visual-editor.php +'api' => [ + 'middleware' => ['api', 'auth:sanctum'], +], +``` + +--- + +## See also + +- [Blade component reference](blade-component.md) — the underlying mount contract +- [Renderers](renderers.md) — public-site rendering +- [Livewire](livewire.md) — alternative embedding inside a server-rendered Blade page diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 000000000..f668147f4 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,148 @@ +# Migration + +Migrating content **from WordPress into the visual editor** is handled +by a companion package, not by `visual-editor` itself. This page +explains why, what the companion does, and what the in-package +migrations cover. + +--- + +## 1. WordPress import — companion package + +**Package:** `artisanpack-ui/visual-editor-wp-import` +**Status:** Planned. Ships separately on its own release cadence; +first version targets after V1.0.0 of `visual-editor`. + +The companion handles the one-way migration from a WordPress export +(WXR or direct database connection) into your Laravel app: + +- Maps WP `posts`, `pages`, `wp_template`, `wp_template_part`, + `wp_navigation`, `wp_block` (patterns), and `wp_global_styles` into + the corresponding Laravel models. +- Rewrites `core/*` block names to `artisanpack/*` using the same + transform table the block forks ship (`from:core/*` in each block.json). +- Resolves attachment URLs to media library records. +- Resolves user references to local user records by email or username. +- Optionally preserves WP post ids, slugs, dates, and meta. + +Reasons it lives in its own package: + +- Importer dependencies (WP XML parsers, HTTP clients for remote media, + optional MySQL drivers) are heavy and only needed once. +- Import is a one-off operation — bundling it into the runtime package + bloats every install. +- Importer release cycle is faster than the editor's; a bug in the + importer shouldn't force a `visual-editor` patch release. + +When the companion is published, this page will link to its own +documentation. Until then, manual migration is the path: export WP +content, transform locally, seed via Laravel. + +--- + +## 2. In-package migrations + +`visual-editor` ships these migrations under `database/migrations/`: + +| Migration | Purpose | +|-----------|---------| +| `2026_04_14_000000_create_ve_contents_table.php` | Creates the `ve_contents` fallback table used by the legacy `VisualEditorPost` model and the `/editor` test route. Host-app resource models don't touch this table. | +| `2026_05_15_000000_drop_legacy_visual_editor_tables.php` | Drops the Phase D legacy tables (`visual_editor_templates`, `_template_parts`, `_global_styles`, `_navigations`, `_patterns`, `_pattern_categories`, and the pivot). Idempotent — safe on fresh installs. | + +cms-framework provides the actual storage tables for templates, parts, +patterns, global styles, and menus. Install cms-framework and run +`php artisan migrate` and the site-editor REST surface is backed by real +tables. + +--- + +## 3. Upgrading existing host-app models + +Adding `HasBlockContent` to an existing model requires a column: + +```php +Schema::table('posts', function (Blueprint $table) { + $table->json('content')->nullable(); +}); +``` + +Set `$blockContentColumn` on the model if the column name is something +other than `content`. + +For models that already store HTML or Markdown in a different column, +keep both: the legacy column remains the source of truth until you've +backfilled the block-content column from it, then deprecate the +legacy column. cms-framework follows this pattern — its `posts` and +`pages` tables retain a `content longText` column alongside the new +`block_content json` column. See plan-12 §4.2. + +--- + +## 4. Upgrading from `v1.0.0-alpha.1` + +If you installed `v1.0.0-alpha.1` (the Gutenberg-adoption marker), the +upgrade path to `v1.0.0` is: + +1. `composer update artisanpack-ui/visual-editor`. +2. `php artisan migrate` to apply the legacy-table drop. +3. `npm install` then `npm run build` to pick up the new editor bundle. +4. Review the [CHANGELOG](../CHANGELOG.md) for breaking changes — + notable: all `core/*` blocks are now `artisanpack/*` via the + Phase I block fork; pasted upstream `core/*` markup auto-converts on + insert via the `from:core/*` transforms shipped on every fork. + +Existing post content saved against `v1.0.0-alpha.1`'s `core/*` blocks +re-renders correctly — the renderers recognize both namespaces during +V1. From V2 onwards, only `artisanpack/*` is registered for new content; +old `core/*` content continues to render via the transform table. + +--- + +## 5. Upgrading from a future version + +`visual-editor` follows semver: + +- Patch releases (`v1.0.x`) — bug fixes, no API changes. +- Minor releases (`v1.x.0`) — new features, backwards-compatible. +- Major releases (`v2.0.0`) — breaking changes, documented migration + path in that release's CHANGELOG. + +Each major release ships a `docs/upgrade-{from}-to-{to}.md` doc +covering the breaking changes and required migration steps. + +--- + +## 6. Manual content migration + +If `visual-editor-wp-import` isn't an option (custom CMS source, +hand-crafted content), the manual path is: + +1. **Export source content** — get the source HTML/JSON into a + transformable shape. +2. **Map to block trees** — write a transformer that produces + Gutenberg-shape block JSON: + + ```php + [ + [ + 'name' => 'artisanpack/paragraph', + 'attributes' => ['content' => $htmlParagraph], + 'innerBlocks' => [], + ], + // … + ]; + ``` + +3. **Seed via Laravel** — set the block tree on each model via + `$model->setBlockContent($blocks)` and save. + +The block content column is plain JSON; you can backfill via a +database seeder, an artisan command, or a one-off migration script. + +--- + +## See also + +- [Getting started](getting-started.md) +- [Content model](content-model.md) — `HasBlockContent`, resource map +- [`visual-editor-wp-import` repo](https://github.com/ArtisanPack-UI/visual-editor-wp-import) (when published) diff --git a/docs/navigation.md b/docs/navigation.md new file mode 100644 index 000000000..bf39afaef --- /dev/null +++ b/docs/navigation.md @@ -0,0 +1,200 @@ +# Navigation + +Navigation menus are editable through the site editor's Navigation +section, persisted by cms-framework's `Menu` model, exposed via REST, +and rendered on the public site through the `core/navigation` block. + +This page covers menu locations, the fallback chain, the `core/navigation` +block, REST endpoints, and the nav editor UI. + +--- + +## 1. Concepts + +- **Menu** — a named, ordered tree of links. Persisted in cms-framework's + `menus` table. Can be assigned to one or more **menu locations**. +- **Menu item** — a single link inside a menu. Persisted in `menu_items` + (id, menu_id, parent_id, title, url, type, object, position). +- **Menu location** — a theme-declared slot (e.g. `primary`, `footer`, + `mobile`). The theme decides what locations exist; the site editor + assigns menus to them. +- **`core/navigation` block** — renders a menu by id or by location with + a fallback chain. + +--- + +## 2. Menu locations + +Locations are declared in config: + +```php +// config/artisanpack/visual-editor.php +'site-editor' => [ + 'navigation' => [ + 'locations' => [ + 'primary' => ['title' => 'Primary Menu'], + 'footer' => ['title' => 'Footer Menu'], + 'mobile' => ['title' => 'Mobile Menu'], + ], + ], +], +``` + +The site editor lists declared locations in the Navigation section and +lets authors assign any menu to any location. + +Locations are read-only via REST — `GET /visual-editor/api/menu-locations` +returns the declared list; menu → location assignment is written via the +menu's `location` field on `PUT /menus/{id}`. + +--- + +## 3. Fallback chain + +When a `core/navigation` block renders, the resolver walks this chain: + +1. **Explicit menu id** on the block attributes — `{ menuId: 7 }`. +2. **Menu assigned to the requested location** — `{ location: 'primary' }` + resolves to whatever menu currently holds `location = 'primary'`. +3. **Fallback menu** — declared in config as the location's `fallback` + key: + + ```php + 'locations' => [ + 'primary' => [ + 'title' => 'Primary Menu', + 'fallback' => 'auto', // 'auto' | 'first' | menu-slug | null + ], + ], + ``` + + - `'auto'` — auto-generate from top-level pages (the WordPress + default behaviour). + - `'first'` — the first menu by created_at. + - `'menu-slug'` — a specific menu's slug. + - `null` — render nothing. + +4. **Empty render** — emits a wrapping `