diff --git a/.changeset/preview-scoped-updates.md b/.changeset/preview-scoped-updates.md new file mode 100644 index 0000000..e250186 --- /dev/null +++ b/.changeset/preview-scoped-updates.md @@ -0,0 +1,13 @@ +--- +'@b10cks/client': minor +'@b10cks/vue': minor +'@b10cks/nuxt': minor +--- + +Fix live preview losing the page on a scoped edit, and duplicate-instance injection failures + +- `PreviewStore` now merges `CONTENT_UPDATE` by block `id` (`applyContentUpdate` / `mergeContentUpdate`) instead of replacing the root. The editor sends updates scoped to the edited block, which previously collapsed the whole preview to that block. Unknown ids are ignored. +- Vue injection keys use `Symbol.for(...)`, so a duplicated `@b10cks/vue` copy (Vite dep pre-bundling) can no longer break `useB10cksApi()` during hydration. +- `@b10cks/nuxt` registers `@b10cks/vue`, `@b10cks/client` and `@b10cks/richtext` in `vite.resolve.dedupe` and `optimizeDeps.exclude`, and transpiles them by bare specifier (the previous `resolver.resolve('@b10cks/vue')` produced a nonexistent path). +- New `toRootBlock(entry)` helper (`@b10cks/client`, re-exported from `@b10cks/vue`, auto-imported in Nuxt) returns `{ ...entry.content, id, block }` so the root block is selectable with `v-editable` and root-level editor updates match it. READMEs updated. +- `getConfig()` now includes the config entry's `id` in its result, so `v-editable="config"` works for config-driven regions. Potentially breaking only for a config schema with its own `id` field, which the entry id now shadows. diff --git a/packages/client/README.md b/packages/client/README.md index bcdd34b..d4fd50f 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -144,7 +144,7 @@ const params = serializeFilter({ | `getSitemap(params, options)` | Sitemap entries (default sitemap) | | `getNamedSitemap(name, params, options)` | Entries of a named sitemap from `settings.sitemaps` | | `getSpace(params)` | Current space info | -| `getConfig(options)` | Config content entry (cached) | +| `getConfig(options)` | Config entry content plus its `id` (cached) | | `syncRevision(fallbackRv)` | Sync local RV from the space | | `clearCache()` | Clear redirect and config caches | @@ -386,8 +386,20 @@ setPreviewScrollOffset(80) // number → px, or pass a string like '5rem' `PreviewStore` is a framework-agnostic, subscribable holder for the content tree. `bindPreviewStore` feeds `CONTENT_UPDATE`/`CONTENT_PATCH` events into it, so any complex field — including rich text — re-renders from the new snapshot. The framework packages expose this as `usePreviewContent` / `createPreviewContent`. +The editor sends `CONTENT_UPDATE` scoped to the block that changed, carrying that block's `id`; only an edit of the root block pushes the whole tree (as `{ id: entryId, …entryContent }`). The store therefore merges an update by `id`: + +- payload without an `id` → treated as the whole tree +- id equal to the root's id → replaces the root +- id found in the tree → replaces that node in place, immutably +- id found nowhere → ignored, so a scoped update can never collapse the page + +Give the root block the entry's id with `toRootBlock` so root-level edits match it; when the root has no id, an update whose `block` type equals the root's is taken as the root. + ```typescript -import { PreviewStore, bindPreviewStore, setAtPath, getAtPath } from '@b10cks/client' +import { PreviewStore, bindPreviewStore, setAtPath, getAtPath, toRootBlock } from '@b10cks/client' + +// `entry.content` has no id of its own — the id lives on the entry. +const initialContent = toRootBlock(entry) // { ...entry.content, id, block } const store = new PreviewStore(initialContent) const offBridge = bindPreviewStore(store) diff --git a/packages/client/src/content.test.ts b/packages/client/src/content.test.ts new file mode 100644 index 0000000..3d1fff7 --- /dev/null +++ b/packages/client/src/content.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { toRootBlock } from './content' +import type { IBContent } from './types' + +const entry = { + id: 'entry-1', + name: 'Home', + slug: 'home', + block: 'page', + parent_id: null, + full_slug: 'home', + content: { block: 'page', body: [{ id: 'hero', block: 'hero' }] }, + language_iso: 'en', + translations: [], + published_at: null, + first_published_at: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +} as unknown as IBContent> + +describe('toRootBlock', () => { + it('carries the entry id into the root block', () => { + const block = toRootBlock(entry) + + expect(block).toEqual({ + id: 'entry-1', + block: 'page', + body: [{ id: 'hero', block: 'hero' }], + }) + }) + + it('falls back to the entry block when the content object has none', () => { + const block = toRootBlock({ ...entry, content: { headline: 'hi' } }) + + expect(block).toEqual({ id: 'entry-1', block: 'page', headline: 'hi' }) + }) + + it('returns null for a missing entry', () => { + expect(toRootBlock(null)).toBeNull() + expect(toRootBlock(undefined)).toBeNull() + }) +}) diff --git a/packages/client/src/content.ts b/packages/client/src/content.ts index d128ec1..8cb7d6f 100644 --- a/packages/client/src/content.ts +++ b/packages/client/src/content.ts @@ -1,4 +1,39 @@ -import type { B10cksLink } from './types' +import type { B10cksLink, IBContent } from './types' + +export type RootBlock = T & { id: string; block: string } + +/** + * Flatten a content entry into a renderable root block. + * + * An entry's `content` object carries `block` but not `id` — the id lives on the + * entry. Rendering `entry.content` directly therefore yields a root block the + * visual editor cannot address: `v-editable` no-ops on it, and the editor's + * root-level `CONTENT_UPDATE` (sent as `{ id: entryId, … }`) matches nothing. + * Use this helper to carry the entry id into the tree: + * + * ```ts + * const block = toRootBlock(entry) // { ...entry.content, id, block } + * ``` + */ +export function toRootBlock>(entry: IBContent): RootBlock +export function toRootBlock>( + entry: IBContent | null | undefined +): RootBlock | null +export function toRootBlock>( + entry: IBContent | null | undefined +): RootBlock | null { + if (!entry) { + return null + } + + const content = (entry.content ?? {}) as T & { block?: string } + + return { + ...content, + id: entry.id, + block: content.block ?? entry.block, + } as RootBlock +} export interface B10cksLinkResolved { href: string diff --git a/packages/client/src/data-api.test.ts b/packages/client/src/data-api.test.ts index 5b3da8f..31d1148 100644 --- a/packages/client/src/data-api.test.ts +++ b/packages/client/src/data-api.test.ts @@ -283,8 +283,8 @@ describe('B10cksDataApi', () => { const draftConfig = await dataApi.getConfig<{ theme: string }>(draftOptions) const publishedConfig = await dataApi.getConfig<{ theme: string }>(publishedOptions) - expect(draftConfig).toEqual({ theme: 'draft' }) - expect(publishedConfig).toEqual({ theme: 'published' }) + expect(draftConfig).toEqual({ theme: 'draft', id: 'config-1' }) + expect(publishedConfig).toEqual({ theme: 'published', id: 'config-2' }) expect(client.get).toHaveBeenNthCalledWith(1, 'contents/_config', { vid: 'draft', language_iso: undefined, @@ -294,4 +294,19 @@ describe('B10cksDataApi', () => { language_iso: undefined, }) }) + + it('exposes the config entry id so the config is editable in the visual editor', async () => { + const client: DataApiClient = { + get: vi.fn().mockResolvedValue({ data: buildContent('config-entry', 'dark') }), + getAll: vi.fn(), + setRv: vi.fn(), + } + + const dataApi = new B10cksDataApi(client) + + const config = await dataApi.getConfig<{ theme: string; id: string }>() + + expect(config.id).toBe('config-entry') + expect(config.theme).toBe('dark') + }) }) diff --git a/packages/client/src/data-api.ts b/packages/client/src/data-api.ts index 519dc1f..0730a00 100644 --- a/packages/client/src/data-api.ts +++ b/packages/client/src/data-api.ts @@ -369,8 +369,16 @@ export class B10cksDataApi { ...params, language_iso: normalizedLanguage, }) - .then((configContent) => { - const value = (configContent.content ?? {}) as T + .then((configEntry) => { + // Carry the entry id into the returned object so the config is + // addressable in the visual editor (`v-editable="config"`); without it + // config-driven regions like a header/footer are not selectable. The + // entry id wins over a same-named content field, which would not be a + // block id. + const value = { + ...((configEntry.content ?? {}) as Record), + id: configEntry.id, + } as T if (!bypassCache) { this.setConfigCache(cacheKey, value) } diff --git a/packages/client/src/preview-store.test.ts b/packages/client/src/preview-store.test.ts index 8614a1d..4a0d577 100644 --- a/packages/client/src/preview-store.test.ts +++ b/packages/client/src/preview-store.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { getAtPath, PreviewStore, setAtPath } from './preview-store' +import { + findPathById, + getAtPath, + mergeContentUpdate, + PreviewStore, + setAtPath, +} from './preview-store' describe('getAtPath', () => { it('reads nested object and array values', () => { @@ -49,6 +55,52 @@ describe('setAtPath', () => { }) }) +describe('findPathById', () => { + it('finds nested nodes in objects and arrays', () => { + const tree = { + id: 'root', + body: [{ id: 'a', columns: [{ id: 'b' }] }], + hero: { id: 'c' }, + } + + expect(findPathById(tree, 'a')).toEqual(['body', 0]) + expect(findPathById(tree, 'b')).toEqual(['body', 0, 'columns', 0]) + expect(findPathById(tree, 'c')).toEqual(['hero']) + }) + + it('never matches the root itself and returns null for unknown ids', () => { + expect(findPathById({ id: 'root' }, 'root')).toBeNull() + expect(findPathById({ id: 'root' }, 'nope')).toBeNull() + }) +}) + +describe('mergeContentUpdate', () => { + it('treats a payload without an id as the whole tree', () => { + const update = { block: 'page', body: [] } + expect(mergeContentUpdate({ id: 'root', block: 'page' }, update)).toBe(update) + }) + + it('treats a matching block type as the root when the root carries no id', () => { + // The docs shape `usePreviewContent(() => data.value.content)` has no id on + // the root, while the editor pushes `{ id: entryId, ...content }`. + const update = { id: 'entry-1', block: 'page', body: [{ id: 'hero', block: 'hero' }] } + expect(mergeContentUpdate({ block: 'page', body: [] }, update)).toBe(update) + }) + + it('still merges nested updates when the root carries no id', () => { + const root = { block: 'page', body: [{ id: 'hero', block: 'hero', headline: 'old' }] } + const merged = mergeContentUpdate(root, { id: 'hero', block: 'hero', headline: 'new' }) + + expect(merged.block).toBe('page') + expect(merged.body[0]?.headline).toBe('new') + }) + + it('returns the original tree for an unknown id of a different block type', () => { + const root = { block: 'page', body: [] } + expect(mergeContentUpdate(root, { id: 'x', block: 'hero' })).toBe(root) + }) +}) + describe('PreviewStore', () => { it('notifies subscribers on setContent and exposes the snapshot', () => { const store = new PreviewStore<{ title: string }>({ title: 'old' }) @@ -65,6 +117,54 @@ describe('PreviewStore', () => { expect(listener).toHaveBeenCalledTimes(1) // no longer notified }) + it('merges a scoped content update into the tree instead of replacing the root', () => { + const initial = { + id: 'root', + block: 'page', + body: [ + { id: 'hero', block: 'hero', headline: 'old' }, + { id: 'teaser', block: 'teaser' }, + ], + } + const store = new PreviewStore(initial) + const listener = vi.fn() + store.subscribe(listener) + + store.applyContentUpdate({ id: 'hero', block: 'hero', headline: 'new' }) + + const snapshot = store.getSnapshot() + expect(snapshot.block).toBe('page') + expect(snapshot.body).toHaveLength(2) + expect(snapshot.body[0]).toEqual({ id: 'hero', block: 'hero', headline: 'new' }) + expect(snapshot.body[1]).toBe(initial.body[1]) + expect(initial.body[0]?.headline).toBe('old') + expect(listener).toHaveBeenCalledTimes(1) + }) + + it('replaces the whole tree when the update targets the root', () => { + const store = new PreviewStore>({ + id: 'root', + block: 'page', + body: [{ id: 'hero', block: 'hero' }], + }) + + store.applyContentUpdate({ id: 'root', block: 'page', body: [] }) + + expect(store.getSnapshot()).toEqual({ id: 'root', block: 'page', body: [] }) + }) + + it('ignores an update for a block that is not in the tree', () => { + const initial = { id: 'root', block: 'page', body: [{ id: 'hero', block: 'hero' }] } + const store = new PreviewStore(initial) + const listener = vi.fn() + store.subscribe(listener) + + store.applyContentUpdate({ id: 'elsewhere', block: 'hero' }) + + expect(store.getSnapshot()).toBe(initial) + expect(listener).not.toHaveBeenCalled() + }) + it('applies granular patches immutably and notifies', () => { const initial = { body: [{ headline: 'a' }] } const store = new PreviewStore(initial) diff --git a/packages/client/src/preview-store.ts b/packages/client/src/preview-store.ts index 2a50606..a3fd966 100644 --- a/packages/client/src/preview-store.ts +++ b/packages/client/src/preview-store.ts @@ -38,11 +38,102 @@ export function setAtPath(target: T, path: FieldPath, value: unknown): T { return next as T } +/** + * Depth-first search for the path to the node carrying `id`. The root node + * itself is not considered a match — callers handle the root explicitly. + */ +export function findPathById(root: unknown, id: string): FieldPath | null { + const search = (node: unknown, path: FieldPath): FieldPath | null => { + if (Array.isArray(node)) { + for (let index = 0; index < node.length; index++) { + const found = search(node[index], [...path, index]) + if (found) { + return found + } + } + return null + } + + if (!node || typeof node !== 'object') { + return null + } + + const record = node as Record + if (path.length > 0 && record.id === id) { + return path + } + + for (const key of Object.keys(record)) { + const value = record[key] + if (value && typeof value === 'object') { + const found = search(value, [...path, key]) + if (found) { + return found + } + } + } + + return null + } + + return search(root, []) +} + +/** + * Merge a `CONTENT_UPDATE` payload into an existing content tree. + * + * The editor sends updates scoped to the edited item — the payload is the block + * that changed, carrying its own `id` — and only sends the whole tree when the + * root block itself is edited (as `{ id: entryId, ...entryContent }`). Blindly + * assigning the payload as the new root therefore collapses the page to the + * edited block, so the payload is instead matched by `id`: + * + * - payload without an `id` → treated as the whole tree (legacy/whole-tree push) + * - payload id equal to the root's id → replaces the root + * - payload id found in the tree → replaces that node in place, immutably + * - payload id found nowhere → ignored (an update for a block not rendered here) + * + * When the root is rendered without its entry id (`entry.content` alone, so the + * root has no `id` to match) an update that matches nothing nested but has the + * same `block` type as the root is taken as the root. + */ +export function mergeContentUpdate(root: T, update: Record): T { + if (!update || typeof update !== 'object') { + return root + } + + const updateId = typeof update.id === 'string' ? update.id : undefined + if (!updateId) { + return update as unknown as T + } + + if (!root || typeof root !== 'object') { + return update as unknown as T + } + + const rootRecord = root as unknown as Record + const rootId = typeof rootRecord.id === 'string' ? rootRecord.id : undefined + if (rootId === updateId) { + return update as unknown as T + } + + const path = findPathById(root, updateId) + if (path) { + return setAtPath(root, path, update) + } + + if (!rootId && rootRecord.block !== undefined && rootRecord.block === update.block) { + return update as unknown as T + } + + return root +} + type Listener = () => void /** * A framework-agnostic, reactive holder for the content tree shown in the - * preview. The editor pushes whole-tree (`CONTENT_UPDATE`) or granular + * preview. The editor pushes block-scoped (`CONTENT_UPDATE`) or granular * (`CONTENT_PATCH`) changes; subscribers re-render from the new snapshot. */ export class PreviewStore> { @@ -67,6 +158,20 @@ export class PreviewStore> { this.emit() } + /** + * Apply an editor `CONTENT_UPDATE` payload. See {@link mergeContentUpdate}: + * scoped payloads are merged by `id` instead of replacing the whole tree. + * Unknown ids are ignored and do not notify subscribers. + */ + applyContentUpdate(update: Record) { + const next = mergeContentUpdate(this.content, update) + if (next === this.content) { + return + } + this.content = next + this.emit() + } + patch(path: FieldPath, value: unknown) { this.content = setAtPath(this.content, path, value) this.emit() @@ -85,7 +190,7 @@ export class PreviewStore> { */ export function bindPreviewStore(store: PreviewStore): () => void { const offUpdate = previewBridge.on('CONTENT_UPDATE', ({ content }) => { - store.setContent(content as T) + store.applyContentUpdate(content) }) const offPatch = previewBridge.on('CONTENT_PATCH', ({ path, value }) => { store.patch(path, value) diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index f6ac509..9a1aa86 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -104,12 +104,21 @@ The helpers use Nuxt's `useAsyncData()` under the hood, so requests participate `B10cksComponent`, `v-editable`, and `v-editable-field` are available globally after registering the module. `componentsDir` in the config tells the module where your block components live; it auto-registers them by block name. ```vue + + ``` @@ -135,8 +144,9 @@ For whole-tree reactive updates while editing — including nested and rich text const { useContent } = useB10cksApi() const { data } = await useContent('home') // Pass a getter (or ref) so the preview resets when content is refetched -// on a route/locale change instead of keeping the first tree. -const content = usePreviewContent(() => data.value.content) +// on a route/locale change instead of keeping the first tree. `toRootBlock` +// carries the entry id into the root block so root-level edits are matched. +const content = usePreviewContent(() => toRootBlock(data.value))