@prenta/admin 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/__tests__/lib/canvas-edit-chrome.test.js +1 -22
  3. package/dist/__tests__/lib/canvas-edit-chrome.test.js.map +1 -1
  4. package/dist/__tests__/lib/preview-bridge.test.js +19 -79
  5. package/dist/__tests__/lib/preview-bridge.test.js.map +1 -1
  6. package/dist/__tests__/lib/view-site-chrome.test.js +11 -1
  7. package/dist/__tests__/lib/view-site-chrome.test.js.map +1 -1
  8. package/dist/__tests__/views/canvas-review-mode.render.test.js +11 -1
  9. package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -1
  10. package/dist/__tests__/views/editor-top-bar.render.test.js +8 -0
  11. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  12. package/dist/__tests__/views/page-section-editor.test.js +23 -9
  13. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  14. package/dist/lib/canvas-edit-chrome.d.ts +0 -17
  15. package/dist/lib/canvas-edit-chrome.d.ts.map +1 -1
  16. package/dist/lib/canvas-edit-chrome.js +0 -62
  17. package/dist/lib/canvas-edit-chrome.js.map +1 -1
  18. package/dist/lib/preview-bridge.d.ts +7 -74
  19. package/dist/lib/preview-bridge.d.ts.map +1 -1
  20. package/dist/lib/preview-bridge.js +12 -73
  21. package/dist/lib/preview-bridge.js.map +1 -1
  22. package/dist/lib/view-site-chrome.d.ts +3 -0
  23. package/dist/lib/view-site-chrome.d.ts.map +1 -1
  24. package/dist/lib/view-site-chrome.js +11 -2
  25. package/dist/lib/view-site-chrome.js.map +1 -1
  26. package/dist/views/page-editor/EditorCanvas.d.ts +1 -1
  27. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  28. package/dist/views/page-editor/EditorTopBar.d.ts +3 -1
  29. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  30. package/dist/views/page-editor/EditorTopBar.js +2 -1
  31. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  32. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  33. package/dist/views/page-editor/PageSectionEditor.js +8 -47
  34. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  35. package/dist/views/page-editor/sections/SectionRenderer.d.ts +1 -1
  36. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  37. package/dist/views/page-editor/sections/SectionRenderer.js +2 -1
  38. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  39. package/dist/views/post-editor/PostEditorCanvas.d.ts +1 -1
  40. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  41. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  42. package/dist/views/post-editor/PostSectionEditor.js +7 -41
  43. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  44. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  45. package/dist/views/settings/SeoSettingsTab.js +2 -1
  46. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  47. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
  48. package/dist/views/settings/SiteInformationCard.js +3 -1
  49. package/dist/views/settings/SiteInformationCard.js.map +1 -1
  50. package/package.json +3 -3
  51. package/src/__tests__/lib/canvas-edit-chrome.test.ts +0 -27
  52. package/src/__tests__/lib/preview-bridge.test.ts +21 -110
  53. package/src/__tests__/lib/view-site-chrome.test.ts +16 -1
  54. package/src/__tests__/views/canvas-review-mode.render.test.tsx +14 -1
  55. package/src/__tests__/views/editor-top-bar.render.test.tsx +19 -0
  56. package/src/__tests__/views/page-section-editor.test.tsx +23 -13
  57. package/src/lib/canvas-edit-chrome.ts +0 -64
  58. package/src/lib/preview-bridge.ts +11 -157
  59. package/src/lib/view-site-chrome.ts +15 -3
  60. package/src/views/page-editor/EditorCanvas.tsx +1 -1
  61. package/src/views/page-editor/EditorTopBar.tsx +4 -0
  62. package/src/views/page-editor/PageSectionEditor.tsx +6 -63
  63. package/src/views/page-editor/sections/SectionRenderer.tsx +5 -2
  64. package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
  65. package/src/views/post-editor/PostSectionEditor.tsx +5 -57
  66. package/src/views/settings/SeoSettingsTab.tsx +21 -0
  67. package/src/views/settings/SiteInformationCard.tsx +7 -0
@@ -1,128 +1,39 @@
1
+ // @vitest-environment happy-dom
1
2
  import { describe, expect, it } from 'vitest'
2
3
  import {
3
- canvasOriginFromSrc,
4
4
  companionDimensionPaths,
5
+ fieldPathFromEventTarget,
6
+ fieldPathLeaf,
5
7
  inspectorIdFromFieldPath,
6
- isMediaFieldPath,
7
- isPreviewBridgeMessage,
8
- isPreviewEditMessage,
9
- isPreviewModeMessage,
10
- isPreviewRequestMediaMessage,
11
- PREVIEW_BRIDGE_VERSION,
12
- toPreviewSectionPayload,
13
8
  } from '../../lib/preview-bridge.js'
14
9
 
15
- describe('preview-bridge', () => {
16
- it('accepts versioned prenta-preview messages', () => {
17
- expect(
18
- isPreviewBridgeMessage({ type: 'prenta-preview:ready', v: PREVIEW_BRIDGE_VERSION }),
19
- ).toBe(true)
20
- expect(
21
- isPreviewBridgeMessage({
22
- type: 'prenta-preview:select',
23
- v: PREVIEW_BRIDGE_VERSION,
24
- sectionId: 'sec-1',
25
- }),
26
- ).toBe(true)
27
- })
28
-
29
- it('rejects legacy or wrong-version payloads', () => {
30
- expect(isPreviewBridgeMessage({ type: 'prenta-preview-update', data: {} })).toBe(false)
31
- expect(isPreviewBridgeMessage({ type: 'prenta-preview:ready', v: 99 })).toBe(false)
32
- expect(isPreviewBridgeMessage(null)).toBe(false)
33
- })
34
-
35
- it('parses canvas origin from iframe src', () => {
36
- expect(canvasOriginFromSrc('https://cms.example.com/about/?preview=tok')).toBe(
37
- 'https://cms.example.com',
38
- )
39
- expect(canvasOriginFromSrc(null)).toBeNull()
40
- })
41
-
10
+ describe('preview-bridge field paths', () => {
42
11
  it('maps dotted field paths to unified insp-field ids', () => {
43
12
  expect(inspectorIdFromFieldPath('lead')).toBe('insp-field-lead')
44
13
  expect(inspectorIdFromFieldPath('stats.0.num')).toBe('insp-field-stats-0-num')
45
14
  expect(inspectorIdFromFieldPath('rotateWords.2.word')).toBe('insp-field-rotateWords-2-word')
46
15
  })
47
16
 
48
- it('accepts prenta-preview:sections payloads', () => {
49
- expect(
50
- isPreviewBridgeMessage({
51
- type: 'prenta-preview:sections',
52
- v: PREVIEW_BRIDGE_VERSION,
53
- sections: [{ id: 's1', sectionType: 'hero', content: { heading: 'Hi' } }],
54
- }),
55
- ).toBe(true)
56
- })
57
-
58
- it('strips editor-only fields from preview section payloads', () => {
59
- expect(
60
- toPreviewSectionPayload({
61
- id: 's1',
62
- sectionType: 'hero',
63
- name: 'Hero',
64
- visible: true,
65
- content: { heading: 'Hi' },
66
- settings: {},
67
- internalLabel: 'team note',
68
- }),
69
- ).toEqual({
70
- id: 's1',
71
- sectionType: 'hero',
72
- name: 'Hero',
73
- visible: true,
74
- content: { heading: 'Hi' },
75
- settings: {},
76
- unmanaged: undefined,
77
- })
78
- })
79
-
80
- it('validates edit / mode / request-media payloads', () => {
81
- expect(
82
- isPreviewEditMessage({
83
- type: 'prenta-preview:edit',
84
- v: PREVIEW_BRIDGE_VERSION,
85
- sectionId: 's1',
86
- fieldPath: 'lead',
87
- value: 'Hello',
88
- }),
89
- ).toBe(true)
90
- expect(
91
- isPreviewEditMessage({
92
- type: 'prenta-preview:edit',
93
- v: PREVIEW_BRIDGE_VERSION,
94
- sectionId: 's1',
95
- fieldPath: 'lead',
96
- }),
97
- ).toBe(false)
98
- expect(
99
- isPreviewModeMessage({
100
- type: 'prenta-preview:mode',
101
- v: PREVIEW_BRIDGE_VERSION,
102
- editing: true,
103
- }),
104
- ).toBe(true)
105
- expect(
106
- isPreviewRequestMediaMessage({
107
- type: 'prenta-preview:request-media',
108
- v: PREVIEW_BRIDGE_VERSION,
109
- sectionId: 's1',
110
- fieldPath: 'awards.0.imageUrl',
111
- }),
112
- ).toBe(true)
113
- })
114
-
115
- it('detects media field paths and companion dimension paths', () => {
116
- expect(isMediaFieldPath('imageUrl')).toBe(true)
117
- expect(isMediaFieldPath('awards.0.imageUrl')).toBe(true)
118
- expect(isMediaFieldPath('lead')).toBe(false)
119
- expect(isMediaFieldPath('ctaUrl')).toBe(false)
120
- expect(isMediaFieldPath('url', 'media')).toBe(true)
121
- expect(isMediaFieldPath('images.0.url')).toBe(true)
122
- expect(isMediaFieldPath('buttonUrl')).toBe(false)
17
+ it('reads companion dimension paths for media URL fields', () => {
123
18
  expect(companionDimensionPaths('awards.0.imageUrl')).toEqual({
124
19
  widthPath: 'awards.0.imageWidth',
125
20
  heightPath: 'awards.0.imageHeight',
126
21
  })
22
+ expect(companionDimensionPaths('images.0.url')).toEqual({
23
+ widthPath: 'images.0.width',
24
+ heightPath: 'images.0.height',
25
+ })
26
+ expect(fieldPathLeaf('awards.0.imageUrl')).toBe('imageUrl')
27
+ })
28
+
29
+ it('reads data-cms-field from a click target or its ancestor', () => {
30
+ const wrap = document.createElement('div')
31
+ wrap.setAttribute('data-cms-field', 'heading')
32
+ const inner = document.createElement('span')
33
+ wrap.appendChild(inner)
34
+ expect(fieldPathFromEventTarget(inner)).toBe('heading')
35
+ expect(fieldPathFromEventTarget(wrap)).toBe('heading')
36
+ expect(fieldPathFromEventTarget(document.createElement('div'))).toBeUndefined()
37
+ expect(fieldPathFromEventTarget(null)).toBeUndefined()
127
38
  })
128
39
  })
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { NO_PUBLIC_SITE_URL_REASON, resolveViewSiteChrome } from '../../lib/view-site-chrome.js'
2
+ import {
3
+ ADMIN_ORIGIN_SITE_URL_REASON,
4
+ NO_PUBLIC_SITE_URL_REASON,
5
+ resolveViewSiteChrome,
6
+ } from '../../lib/view-site-chrome.js'
3
7
 
4
8
  const base = {
5
9
  dirty: false,
@@ -53,6 +57,17 @@ describe('resolveViewSiteChrome', () => {
53
57
  expect(chrome.shareVisible).toBe(true)
54
58
  })
55
59
 
60
+ it('disables View site and Share when the site URL is the admin origin', () => {
61
+ const chrome = resolveViewSiteChrome({
62
+ ...base,
63
+ status: 'DRAFT',
64
+ matchesAdminOrigin: true,
65
+ })
66
+ expect(chrome.viewEnabled).toBe(false)
67
+ expect(chrome.viewDisabledReason).toBe(ADMIN_ORIGIN_SITE_URL_REASON)
68
+ expect(chrome.shareVisible).toBe(true)
69
+ })
70
+
56
71
  it('hides View site and Share when the document is unsaved', () => {
57
72
  const chrome = resolveViewSiteChrome({
58
73
  ...base,
@@ -68,10 +68,23 @@ describe('SectionRenderer — review mode', () => {
68
68
 
69
69
  fireEvent.click(sectionEl())
70
70
 
71
- expect(onSelect).toHaveBeenCalledWith('s1')
71
+ expect(onSelect).toHaveBeenCalledWith('s1', undefined)
72
72
  expect(sectionEl().getAttribute('data-editable')).toBe('true')
73
73
  })
74
74
 
75
+ it('passes the data-cms-field path when a field inside the section is clicked', () => {
76
+ const onSelect = vi.fn()
77
+ render(<SectionRenderer section={SECTION} editable onSelect={onSelect} />)
78
+
79
+ const field = document.createElement('h2')
80
+ field.setAttribute('data-cms-field', 'heading')
81
+ field.textContent = 'A headline'
82
+ sectionEl().appendChild(field)
83
+ fireEvent.click(field)
84
+
85
+ expect(onSelect).toHaveBeenCalledWith('s1', 'heading')
86
+ })
87
+
75
88
  it('names the change in the accessible name, not only in colour and a dashed border', () => {
76
89
  render(<SectionRenderer section={SECTION} editable reviewing changed />)
77
90
  expect(sectionEl().getAttribute('aria-label')).toBe(
@@ -151,6 +151,25 @@ describe('EditorTopBar view site / share', () => {
151
151
  expect(screen.getByRole('button', { name: 'Share' })).toBeTruthy()
152
152
  })
153
153
 
154
+ it('disables View site and Share when the site URL is the admin origin', () => {
155
+ render(
156
+ <EditorTopBar
157
+ {...baseProps}
158
+ onViewOnSite={vi.fn()}
159
+ onShare={vi.fn()}
160
+ hasPublicSiteUrl
161
+ matchesAdminOrigin
162
+ />,
163
+ )
164
+ const view = screen.getByRole('button', { name: 'View site' }) as HTMLButtonElement
165
+ const share = screen.getByRole('button', { name: 'Share' }) as HTMLButtonElement
166
+ expect(view.disabled).toBe(true)
167
+ expect(share.disabled).toBe(true)
168
+ expect(view.getAttribute('title')).toBe(
169
+ 'This origin is the admin, not the public site. Set the public site URL in Settings → SEO.',
170
+ )
171
+ })
172
+
154
173
  it('disables View site and Share when the public site URL is missing', () => {
155
174
  render(
156
175
  <EditorTopBar
@@ -213,12 +213,22 @@ describe('PageSectionEditor section disclosure', () => {
213
213
  expect(screen.queryByLabelText('Section name')).toBeNull()
214
214
  })
215
215
 
216
- it('keeps the row open when the site preview points at one of its fields', async () => {
216
+ it('keeps the row open when a canvas field click targets the same section', async () => {
217
217
  // A `fieldPath` means "the editor clicked THIS field" — toggling there
218
- // would close the form on the second click of a field being edited.
219
- fetchPageForEditor.mockResolvedValue(
220
- loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }]),
221
- )
218
+ // would close the form on the second click of a field being inspected.
219
+ fetchPageForEditor.mockResolvedValue({
220
+ ...loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }]),
221
+ sections: [
222
+ {
223
+ id: 's1',
224
+ sectionType: 'hero',
225
+ name: 'Hero',
226
+ visible: true,
227
+ content: { heading: 'Welcome' },
228
+ settings: {},
229
+ },
230
+ ],
231
+ })
222
232
  render(
223
233
  <PageSectionEditor documentId="p1" config={validConfig} onNavigate={() => {}} session={{}} />,
224
234
  )
@@ -227,14 +237,14 @@ describe('PageSectionEditor section disclosure', () => {
227
237
  fireEvent.click(screen.getByRole('button', { name: 'Edit Hero' }))
228
238
  await screen.findByRole('button', { name: 'Close Hero' })
229
239
 
230
- // Same section, arriving with a field path, twice.
231
- for (let i = 0; i < 2; i++) {
232
- window.dispatchEvent(
233
- new MessageEvent('message', {
234
- data: { v: 1, type: 'prenta-preview:select', sectionId: 's1', fieldPath: 'heading' },
235
- }),
236
- )
237
- }
240
+ const section = document.querySelector('[data-section-id="s1"]')
241
+ expect(section).toBeTruthy()
242
+ const field = document.createElement('h2')
243
+ field.setAttribute('data-cms-field', 'heading')
244
+ field.textContent = 'Welcome'
245
+ section?.appendChild(field)
246
+ fireEvent.click(field)
247
+ fireEvent.click(field)
238
248
 
239
249
  expect(screen.getByRole('button', { name: 'Close Hero' })).toBeTruthy()
240
250
  })
@@ -1,70 +1,6 @@
1
- /**
2
- * Shared edit affordances for the page/post canvas and Site preview iframe.
3
- *
4
- * Structure canvas: section hover is styled in `theme.css` under `.prenta-canvas`.
5
- * Site preview: the same field chrome is injected into the iframe document when
6
- * same-origin (CMS-host preview) so consumers get hover borders without shipping CSS.
7
- */
8
-
9
1
  /** localStorage key for the dismissible canvas tip. */
10
2
  export const CANVAS_EDIT_TIP_STORAGE_KEY = 'prenta.canvasEditTip.dismissed'
11
3
 
12
- /**
13
- * CSS injected into the Site preview iframe while editing is enabled.
14
- * Uses system colors so it works without admin theme tokens in the iframe.
15
- */
16
- export const PREVIEW_EDITABLE_FIELD_CHROME_CSS = `
17
- /* Prenta Site preview — editable field hover chrome (injected by @prenta/admin) */
18
- [data-cms-field] {
19
- outline: 1px solid transparent;
20
- outline-offset: 2px;
21
- border-radius: 2px;
22
- transition: outline-color 120ms ease;
23
- cursor: text;
24
- }
25
- [data-cms-field]:hover,
26
- [data-cms-field]:focus,
27
- [data-cms-field]:focus-within {
28
- outline-color: color-mix(in oklab, CanvasText 24%, transparent);
29
- }
30
- [data-cms-field][data-cms-field-kind="media"] {
31
- cursor: pointer;
32
- }
33
- [data-cms-field][data-cms-field-kind="media"]:hover,
34
- [data-cms-field][data-cms-field-kind="media"]:focus {
35
- outline-color: color-mix(in oklab, CanvasText 32%, transparent);
36
- }
37
- @media (prefers-reduced-motion: reduce) {
38
- [data-cms-field] {
39
- transition: none;
40
- }
41
- }
42
- `.trim()
43
-
44
- const STYLE_ATTR = 'data-prenta-edit-chrome'
45
-
46
- /**
47
- * Inject (or refresh) editable-field hover chrome into a same-origin preview
48
- * iframe. No-ops on cross-origin frames (browser blocks contentDocument).
49
- */
50
- export function injectPreviewEditChrome(iframe: HTMLIFrameElement | null): boolean {
51
- try {
52
- const doc = iframe?.contentDocument
53
- if (!doc?.head) return false
54
- let style = doc.head.querySelector<HTMLStyleElement>(`style[${STYLE_ATTR}]`)
55
- if (!style) {
56
- style = doc.createElement('style')
57
- style.setAttribute(STYLE_ATTR, '1')
58
- doc.head.appendChild(style)
59
- }
60
- style.textContent = PREVIEW_EDITABLE_FIELD_CHROME_CSS
61
- doc.documentElement.setAttribute('data-prenta-preview-editing', 'true')
62
- return true
63
- } catch {
64
- return false
65
- }
66
- }
67
-
68
4
  export function isCanvasEditTipDismissed(): boolean {
69
5
  try {
70
6
  return localStorage.getItem(CANVAS_EDIT_TIP_STORAGE_KEY) === '1'
@@ -1,140 +1,11 @@
1
1
  /**
2
- * Versioned postMessage protocol between the page editor (parent) and the
3
- * Next editing canvas (iframe). Namespaced with colons so it does not collide
4
- * with the legacy `prenta-preview-update` DocumentEdit bus.
2
+ * Field-path helpers shared by the editor canvas and the inspector.
5
3
  *
6
- * @see prenta docs/superpowers/specs/2026-07-29-visual-editing-design.md
4
+ * The old `prenta-preview:*` postMessage protocol is gone — View site opens a
5
+ * normal tab and does not talk back. `data-cms-field` on first-party section
6
+ * renderers still feeds the reflow guard and canvas field-focus.
7
7
  */
8
8
 
9
- export const PREVIEW_BRIDGE_VERSION = 1 as const
10
-
11
- /** Debounce for live section pushes while typing in the inspector. */
12
- export const PREVIEW_SECTIONS_DEBOUNCE_MS = 120
13
-
14
- export type PreviewBridgeMessageType =
15
- | 'prenta-preview:ready'
16
- | 'prenta-preview:select'
17
- | 'prenta-preview:highlight'
18
- | 'prenta-preview:sections'
19
- | 'prenta-preview:mode'
20
- | 'prenta-preview:edit'
21
- | 'prenta-preview:request-media'
22
-
23
- export interface PreviewBridgeEnvelope {
24
- type: PreviewBridgeMessageType
25
- v: typeof PREVIEW_BRIDGE_VERSION
26
- }
27
-
28
- export interface PreviewReadyMessage extends PreviewBridgeEnvelope {
29
- type: 'prenta-preview:ready'
30
- }
31
-
32
- export interface PreviewSelectMessage extends PreviewBridgeEnvelope {
33
- type: 'prenta-preview:select'
34
- sectionId: string
35
- fieldPath?: string
36
- }
37
-
38
- export interface PreviewHighlightMessage extends PreviewBridgeEnvelope {
39
- type: 'prenta-preview:highlight'
40
- sectionId: string | null
41
- }
42
-
43
- /**
44
- * Opaque JSON object on the wire. Uses `object` (not `Record<string, unknown>`)
45
- * so concrete editor interfaces like `SectionSettings` remain assignable.
46
- */
47
- export type PreviewObjectPayload = object
48
-
49
- /** Wire shape for live canvas updates — matches `PublicSection` / editor sections. */
50
- export interface PreviewSectionPayload {
51
- id: string
52
- sectionType: string
53
- name?: string
54
- visible?: boolean
55
- content?: PreviewObjectPayload
56
- settings?: PreviewObjectPayload
57
- unmanaged?: boolean
58
- }
59
-
60
- export interface PreviewSectionsMessage extends PreviewBridgeEnvelope {
61
- type: 'prenta-preview:sections'
62
- sections: PreviewSectionPayload[]
63
- }
64
-
65
- /** Admin → canvas: enable/disable inline editing affordances. */
66
- export interface PreviewModeMessage extends PreviewBridgeEnvelope {
67
- type: 'prenta-preview:mode'
68
- editing: boolean
69
- }
70
-
71
- /** Canvas → admin: text field edit from contenteditable. */
72
- export interface PreviewEditMessage extends PreviewBridgeEnvelope {
73
- type: 'prenta-preview:edit'
74
- sectionId: string
75
- fieldPath: string
76
- value: string
77
- }
78
-
79
- /** Canvas → admin: open media picker for an image field. */
80
- export interface PreviewRequestMediaMessage extends PreviewBridgeEnvelope {
81
- type: 'prenta-preview:request-media'
82
- sectionId: string
83
- fieldPath: string
84
- }
85
-
86
- export type PreviewBridgeMessage =
87
- | PreviewReadyMessage
88
- | PreviewSelectMessage
89
- | PreviewHighlightMessage
90
- | PreviewSectionsMessage
91
- | PreviewModeMessage
92
- | PreviewEditMessage
93
- | PreviewRequestMediaMessage
94
-
95
- export function isPreviewBridgeMessage(data: unknown): data is PreviewBridgeMessage {
96
- if (!data || typeof data !== 'object') return false
97
- const msg = data as Record<string, unknown>
98
- if (msg.v !== PREVIEW_BRIDGE_VERSION) return false
99
- if (typeof msg.type !== 'string' || !msg.type.startsWith('prenta-preview:')) return false
100
- return true
101
- }
102
-
103
- export function isPreviewEditMessage(data: unknown): data is PreviewEditMessage {
104
- if (!isPreviewBridgeMessage(data) || data.type !== 'prenta-preview:edit') return false
105
- return (
106
- typeof data.sectionId === 'string' &&
107
- data.sectionId.length > 0 &&
108
- typeof data.fieldPath === 'string' &&
109
- data.fieldPath.length > 0 &&
110
- typeof data.value === 'string'
111
- )
112
- }
113
-
114
- export function isPreviewRequestMediaMessage(data: unknown): data is PreviewRequestMediaMessage {
115
- if (!isPreviewBridgeMessage(data) || data.type !== 'prenta-preview:request-media') return false
116
- return (
117
- typeof data.sectionId === 'string' &&
118
- data.sectionId.length > 0 &&
119
- typeof data.fieldPath === 'string' &&
120
- data.fieldPath.length > 0
121
- )
122
- }
123
-
124
- export function isPreviewModeMessage(data: unknown): data is PreviewModeMessage {
125
- if (!isPreviewBridgeMessage(data) || data.type !== 'prenta-preview:mode') return false
126
- return typeof data.editing === 'boolean'
127
- }
128
-
129
- export function canvasOriginFromSrc(src: string | null | undefined): string | null {
130
- if (!src) return null
131
- try {
132
- return new URL(src, typeof window !== 'undefined' ? window.location.origin : undefined).origin
133
- } catch {
134
- return null
135
- }
136
- }
137
-
138
9
  /**
139
10
  * Map a dotted content field path (`lead`, `stats.0.num`) to the inspector
140
11
  * input id (`insp-field-lead`, `insp-field-stats-0-num`). Same string shape
@@ -181,29 +52,12 @@ export function companionDimensionPaths(fieldPath: string): {
181
52
  }
182
53
 
183
54
  /**
184
- * Media URL slots are not contenteditable. Prefer `data-cms-field-kind="media"`
185
- * on the canvas; fall back to common image field leaf names.
55
+ * Read `data-cms-field` from a canvas click target (or its ancestor).
56
+ * Used so a field click opens the accordion on the matching inspector input.
186
57
  */
187
- export function isMediaFieldPath(fieldPath: string, kindAttr?: string | null): boolean {
188
- if (kindAttr === 'media') return true
189
- if (kindAttr === 'text') return false
190
- const leaf = fieldPathLeaf(fieldPath)
191
- if (leaf === 'imageUrl' || leaf.endsWith('ImageUrl') || leaf === 'featuredImage') return true
192
- // Gallery rows use `images.N.url` (not link CTAs).
193
- return leaf === 'url' && fieldPath.includes('images.')
194
- }
195
-
196
- /** Strip editor chrome to the public wire shape before posting to the canvas. */
197
- export function toPreviewSectionPayload(
198
- section: PreviewSectionPayload & { internalLabel?: string },
199
- ): PreviewSectionPayload {
200
- return {
201
- id: section.id,
202
- sectionType: section.sectionType,
203
- name: section.name,
204
- visible: section.visible,
205
- content: section.content ?? {},
206
- settings: section.settings ?? {},
207
- unmanaged: section.unmanaged,
208
- }
58
+ export function fieldPathFromEventTarget(target: EventTarget | null): string | undefined {
59
+ if (!(target instanceof Element)) return undefined
60
+ const field = target.closest('[data-cms-field]')
61
+ const path = field?.getAttribute('data-cms-field')?.trim()
62
+ return path || undefined
209
63
  }
@@ -5,11 +5,16 @@ export type ViewSiteIntent = 'preview-draft' | 'view-live'
5
5
  export const NO_PUBLIC_SITE_URL_REASON =
6
6
  'Set the public site URL in Settings → SEO to open the live design.'
7
7
 
8
+ export const ADMIN_ORIGIN_SITE_URL_REASON =
9
+ 'This origin is the admin, not the public site. Set the public site URL in Settings → SEO.'
10
+
8
11
  export function resolveViewSiteChrome(input: {
9
12
  status: PageStatus
10
13
  dirty: boolean
11
14
  hasPublicSiteUrl: boolean
12
15
  hasIdentity: boolean
16
+ /** True when the configured site URL is the CMS host (Astro-split misconfig). */
17
+ matchesAdminOrigin?: boolean
13
18
  }): {
14
19
  viewEnabled: boolean
15
20
  viewDisabledReason?: string
@@ -21,12 +26,19 @@ export function resolveViewSiteChrome(input: {
21
26
  const primaryIntent: ViewSiteIntent =
22
27
  input.status === 'PUBLISHED' && !input.dirty ? 'view-live' : 'preview-draft'
23
28
  const shareVisible = input.hasIdentity && unpublished
24
- const viewEnabled = input.hasIdentity && input.hasPublicSiteUrl
29
+ const usablePublicUrl = input.hasPublicSiteUrl && !input.matchesAdminOrigin
30
+ const viewEnabled = input.hasIdentity && usablePublicUrl
31
+
32
+ let viewDisabledReason: string | undefined
33
+ if (input.hasIdentity && !input.hasPublicSiteUrl) {
34
+ viewDisabledReason = NO_PUBLIC_SITE_URL_REASON
35
+ } else if (input.hasIdentity && input.matchesAdminOrigin) {
36
+ viewDisabledReason = ADMIN_ORIGIN_SITE_URL_REASON
37
+ }
25
38
 
26
39
  return {
27
40
  viewEnabled,
28
- viewDisabledReason:
29
- input.hasIdentity && !input.hasPublicSiteUrl ? NO_PUBLIC_SITE_URL_REASON : undefined,
41
+ viewDisabledReason,
30
42
  primaryIntent,
31
43
  showViewLive: input.hasIdentity && input.status === 'PUBLISHED' && input.dirty,
32
44
  shareVisible,
@@ -13,7 +13,7 @@ interface EditorCanvasProps {
13
13
  selectedId: string | null
14
14
  breakpoint: BreakpointId
15
15
  zoom?: Zoom
16
- onSelect: (id: string) => void
16
+ onSelect: (id: string, fieldPath?: string) => void
17
17
  onScaleChange?: (scale: number) => void
18
18
  onCanvasElement?: (el: HTMLElement | null) => void
19
19
  /** Section types rendered as placeholders — shows the "Structure view" notice. */
@@ -55,6 +55,8 @@ interface EditorTopBarProps {
55
55
  onShare?: () => void
56
56
  /** When false, View site and Share are disabled with the SEO settings reason. */
57
57
  hasPublicSiteUrl?: boolean
58
+ /** When true, View site and Share stay disabled — the URL is the CMS host. */
59
+ matchesAdminOrigin?: boolean
58
60
  /**
59
61
  * Toggle the version history panel. When omitted the History button is
60
62
  * hidden — e.g. for unsaved documents that have no versions yet.
@@ -122,6 +124,7 @@ export function EditorTopBar({
122
124
  onViewOnSite,
123
125
  onShare,
124
126
  hasPublicSiteUrl,
127
+ matchesAdminOrigin,
125
128
  onHistory,
126
129
  historyOpen = false,
127
130
  onOpenSettings,
@@ -142,6 +145,7 @@ export function EditorTopBar({
142
145
  status,
143
146
  dirty,
144
147
  hasPublicSiteUrl: Boolean(hasPublicSiteUrl),
148
+ matchesAdminOrigin: Boolean(matchesAdminOrigin),
145
149
  hasIdentity: true,
146
150
  })
147
151
  : null