@prenta/admin 1.1.0 → 1.3.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 (121) hide show
  1. package/CHANGELOG.md +12 -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/page-audit-input.test.js +81 -0
  5. package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
  6. package/dist/__tests__/lib/preview-bridge.test.js +19 -79
  7. package/dist/__tests__/lib/preview-bridge.test.js.map +1 -1
  8. package/dist/__tests__/lib/version-history.test.js +18 -0
  9. package/dist/__tests__/lib/version-history.test.js.map +1 -1
  10. package/dist/__tests__/lib/view-site-chrome.test.js +11 -1
  11. package/dist/__tests__/lib/view-site-chrome.test.js.map +1 -1
  12. package/dist/__tests__/no-server-barrel-imports.test.js +3 -4
  13. package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -1
  14. package/dist/__tests__/views/canvas-review-mode.render.test.js +11 -1
  15. package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -1
  16. package/dist/__tests__/views/editor-top-bar.render.test.js +13 -0
  17. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  18. package/dist/__tests__/views/page-section-editor.test.js +23 -9
  19. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  20. package/dist/__tests__/views/post-section-editor.test.js +38 -0
  21. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  22. package/dist/__tests__/views/post-tab.render.test.d.ts +2 -0
  23. package/dist/__tests__/views/post-tab.render.test.d.ts.map +1 -0
  24. package/dist/__tests__/views/post-tab.render.test.js +86 -0
  25. package/dist/__tests__/views/post-tab.render.test.js.map +1 -0
  26. package/dist/components/seo/SeoEditorPane.d.ts +9 -1
  27. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  28. package/dist/components/seo/SeoEditorPane.js +5 -2
  29. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  30. package/dist/hooks/usePageAudit.d.ts +4 -1
  31. package/dist/hooks/usePageAudit.d.ts.map +1 -1
  32. package/dist/hooks/usePageAudit.js +3 -3
  33. package/dist/hooks/usePageAudit.js.map +1 -1
  34. package/dist/lib/canvas-edit-chrome.d.ts +0 -17
  35. package/dist/lib/canvas-edit-chrome.d.ts.map +1 -1
  36. package/dist/lib/canvas-edit-chrome.js +0 -62
  37. package/dist/lib/canvas-edit-chrome.js.map +1 -1
  38. package/dist/lib/page-audit-input.d.ts +26 -1
  39. package/dist/lib/page-audit-input.d.ts.map +1 -1
  40. package/dist/lib/page-audit-input.js +48 -2
  41. package/dist/lib/page-audit-input.js.map +1 -1
  42. package/dist/lib/preview-bridge.d.ts +7 -74
  43. package/dist/lib/preview-bridge.d.ts.map +1 -1
  44. package/dist/lib/preview-bridge.js +12 -73
  45. package/dist/lib/preview-bridge.js.map +1 -1
  46. package/dist/lib/version-history.d.ts +5 -0
  47. package/dist/lib/version-history.d.ts.map +1 -1
  48. package/dist/lib/version-history.js +18 -0
  49. package/dist/lib/version-history.js.map +1 -1
  50. package/dist/lib/view-site-chrome.d.ts +3 -0
  51. package/dist/lib/view-site-chrome.d.ts.map +1 -1
  52. package/dist/lib/view-site-chrome.js +11 -2
  53. package/dist/lib/view-site-chrome.js.map +1 -1
  54. package/dist/views/page-editor/EditorCanvas.d.ts +1 -1
  55. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  56. package/dist/views/page-editor/EditorTopBar.d.ts +3 -1
  57. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  58. package/dist/views/page-editor/EditorTopBar.js +3 -2
  59. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  60. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  61. package/dist/views/page-editor/PageSectionEditor.js +8 -47
  62. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  63. package/dist/views/page-editor/sections/SectionRenderer.d.ts +1 -1
  64. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  65. package/dist/views/page-editor/sections/SectionRenderer.js +2 -1
  66. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  67. package/dist/views/post-editor/PostEditorCanvas.d.ts +15 -2
  68. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  69. package/dist/views/post-editor/PostEditorCanvas.js +2 -2
  70. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  71. package/dist/views/post-editor/PostFieldsForm.d.ts +39 -0
  72. package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -0
  73. package/dist/views/post-editor/PostFieldsForm.js +85 -0
  74. package/dist/views/post-editor/PostFieldsForm.js.map +1 -0
  75. package/dist/views/post-editor/PostFieldsModal.d.ts +2 -14
  76. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  77. package/dist/views/post-editor/PostFieldsModal.js +20 -81
  78. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  79. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  80. package/dist/views/post-editor/PostSectionEditor.js +128 -74
  81. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  82. package/dist/views/post-editor/PostTab.d.ts +17 -0
  83. package/dist/views/post-editor/PostTab.d.ts.map +1 -0
  84. package/dist/views/post-editor/PostTab.js +23 -0
  85. package/dist/views/post-editor/PostTab.js.map +1 -0
  86. package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
  87. package/dist/views/settings/SeoSettingsTab.js +2 -1
  88. package/dist/views/settings/SeoSettingsTab.js.map +1 -1
  89. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
  90. package/dist/views/settings/SiteInformationCard.js +3 -1
  91. package/dist/views/settings/SiteInformationCard.js.map +1 -1
  92. package/package.json +4 -4
  93. package/src/__tests__/lib/canvas-edit-chrome.test.ts +0 -27
  94. package/src/__tests__/lib/page-audit-input.test.ts +99 -0
  95. package/src/__tests__/lib/preview-bridge.test.ts +21 -110
  96. package/src/__tests__/lib/version-history.test.ts +20 -0
  97. package/src/__tests__/lib/view-site-chrome.test.ts +16 -1
  98. package/src/__tests__/no-server-barrel-imports.test.ts +3 -4
  99. package/src/__tests__/views/canvas-review-mode.render.test.tsx +14 -1
  100. package/src/__tests__/views/editor-top-bar.render.test.tsx +25 -0
  101. package/src/__tests__/views/page-section-editor.test.tsx +23 -13
  102. package/src/__tests__/views/post-section-editor.test.tsx +59 -0
  103. package/src/__tests__/views/post-tab.render.test.tsx +116 -0
  104. package/src/components/seo/SeoEditorPane.tsx +16 -1
  105. package/src/hooks/usePageAudit.ts +9 -2
  106. package/src/lib/canvas-edit-chrome.ts +0 -64
  107. package/src/lib/page-audit-input.ts +67 -1
  108. package/src/lib/preview-bridge.ts +11 -157
  109. package/src/lib/version-history.ts +23 -0
  110. package/src/lib/view-site-chrome.ts +15 -3
  111. package/src/views/page-editor/EditorCanvas.tsx +1 -1
  112. package/src/views/page-editor/EditorTopBar.tsx +6 -2
  113. package/src/views/page-editor/PageSectionEditor.tsx +6 -63
  114. package/src/views/page-editor/sections/SectionRenderer.tsx +5 -2
  115. package/src/views/post-editor/PostEditorCanvas.tsx +28 -6
  116. package/src/views/post-editor/PostFieldsForm.tsx +322 -0
  117. package/src/views/post-editor/PostFieldsModal.tsx +30 -281
  118. package/src/views/post-editor/PostSectionEditor.tsx +209 -116
  119. package/src/views/post-editor/PostTab.tsx +46 -0
  120. package/src/views/settings/SeoSettingsTab.tsx +21 -0
  121. 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
  })
@@ -40,6 +40,26 @@ describe('toPageVersion', () => {
40
40
  expect(mapped.author.name).toBe('Sarah Mills')
41
41
  })
42
42
 
43
+ it('lifts post identity fields so canvas review can show the snapshot header', () => {
44
+ const mapped = toPageVersion({
45
+ id: 'v1',
46
+ changeType: 'UPDATE',
47
+ createdAt: '2026-08-02T12:00:00Z',
48
+ data: {
49
+ title: 'Snapshot title',
50
+ excerpt: 'Snapshot excerpt',
51
+ featuredImage: { url: 'https://cdn.example.com/hero.webp' },
52
+ body: '<p>Snapshot body</p>',
53
+ sections: [],
54
+ },
55
+ })
56
+
57
+ expect(mapped.title).toBe('Snapshot title')
58
+ expect(mapped.excerpt).toBe('Snapshot excerpt')
59
+ expect(mapped.featuredImage).toBe('https://cdn.example.com/hero.webp')
60
+ expect(mapped.body).toBe('<p>Snapshot body</p>')
61
+ })
62
+
43
63
  it('preserves an unmanaged section rather than dropping it from the snapshot', () => {
44
64
  // Coercing a snapshot more strictly than the live document reads would
45
65
  // make an externally-managed section look deleted in every version.
@@ -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,
@@ -9,9 +9,8 @@ import { describe, expect, it } from 'vitest'
9
9
  // the bug Opal hit where Turbopack pulled `pg`/`fs` into the browser build.
10
10
  //
11
11
  // Type-only imports (`import type` / `export type`) are erased by tsc and are
12
- // safe. Page-builder runtime utilities must come from the client-safe
13
- // `@prenta/core/page-builder` subpath instead. This test fails if
14
- // any source file reintroduces a value import from the bare barrel.
12
+ // safe. This test fails if any source file reintroduces a value import from
13
+ // the bare barrel.
15
14
 
16
15
  const SRC_DIR = fileURLToPath(new URL('../', import.meta.url))
17
16
 
@@ -52,7 +51,7 @@ describe('cms-admin client/server bundle boundary', () => {
52
51
 
53
52
  expect(
54
53
  offenders,
55
- `Use '@prenta/core/page-builder' (or 'import type') instead:\n${offenders.join('\n')}`,
54
+ `Use 'import type' from '@prenta/core' or a client-safe subpath instead:\n${offenders.join('\n')}`,
56
55
  ).toEqual([])
57
56
  })
58
57
  })
@@ -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,4 +232,10 @@ describe('EditorTopBar history button', () => {
213
232
  expect(button.className, `${name} has no touch target`).toContain('prenta-touch-target')
214
233
  }
215
234
  })
235
+
236
+ it('labels the gear Post settings when the editor is a post', () => {
237
+ render(<EditorTopBar {...baseProps} backLabel="Posts" />)
238
+ expect(screen.getByRole('button', { name: 'Post settings' })).toBeTruthy()
239
+ expect(screen.queryByRole('button', { name: 'Page settings' })).toBeNull()
240
+ })
216
241
  })
@@ -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
  })
@@ -51,6 +51,7 @@ function loadedPost(
51
51
  featuredImage: '',
52
52
  body: '',
53
53
  category: '',
54
+ tags: [],
54
55
  status: 'DRAFT',
55
56
  publishDate: null,
56
57
  sections: sections.map((s) => ({ ...s, visible: true, content: {}, settings: {} })),
@@ -276,6 +277,64 @@ describe('PostSectionEditor rail', () => {
276
277
 
277
278
  expect(screen.getByRole('tab', { name: /Sections/ })).toBeTruthy()
278
279
  expect(screen.getByRole('tab', { name: /SEO/ })).toBeTruthy()
280
+ expect(screen.getByRole('tab', { name: 'Post' })).toBeTruthy()
281
+ })
282
+
283
+ it('live-applies Post tab edits onto the editor', async () => {
284
+ fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
285
+ fetchPostForEditor.mockResolvedValue(
286
+ loadedPost([{ id: 's1', sectionType: 'quote', name: 'Quote' }]),
287
+ )
288
+ render(
289
+ <PostSectionEditor
290
+ postType="blog"
291
+ documentId="p1"
292
+ config={validConfig}
293
+ onNavigate={() => {}}
294
+ session={{ user: { role: 'EDITOR' } }}
295
+ />,
296
+ )
297
+ await screen.findByRole('tablist', { name: 'Editor panels' })
298
+
299
+ fireEvent.click(screen.getByRole('tab', { name: 'Post' }))
300
+ const title = document.getElementById('post-title') as HTMLInputElement
301
+ fireEvent.change(title, { target: { value: 'Renamed in rail' } })
302
+ expect(screen.getAllByText('Renamed in rail').length).toBeGreaterThan(0)
303
+ })
304
+
305
+ it('inlines post fields on the Post tab, and the gear still opens the modal', async () => {
306
+ await renderLoaded()
307
+
308
+ fireEvent.click(screen.getByRole('tab', { name: 'Post' }))
309
+ const panel = document.getElementById('rail-panel-page')
310
+ expect(panel).toBeTruthy()
311
+ expect(panel!.querySelector('#post-title')).toBeTruthy()
312
+ expect(panel!.querySelector('#post-slug')).toBeTruthy()
313
+ expect(panel!.querySelector('#post-excerpt')).toBeTruthy()
314
+ expect(screen.queryByRole('dialog', { name: 'Post details' })).toBeNull()
315
+
316
+ fireEvent.click(screen.getByRole('button', { name: 'Post settings' }))
317
+ expect(screen.getByRole('dialog', { name: 'Post details' })).toBeTruthy()
318
+ })
319
+
320
+ it('puts the audit score on the SEO tab and the header chip', async () => {
321
+ await renderLoaded()
322
+
323
+ expect(
324
+ screen.getByRole('tab', { name: /SEO(?:.|\s)*(score \d+ of 100|not rated)/i }),
325
+ ).toBeTruthy()
326
+ expect(screen.getByRole('button', { name: /SEO(?:, score \d+ out of 100)?/ })).toBeTruthy()
327
+ })
328
+
329
+ it('presses the History button while the overlay is open', async () => {
330
+ await renderLoaded()
331
+
332
+ const history = screen.getByRole('button', { name: 'Version history' })
333
+ expect(history.getAttribute('aria-pressed')).toBe('false')
334
+ fireEvent.click(history)
335
+ expect(history.getAttribute('aria-pressed')).toBe('true')
336
+ expect(screen.getByRole('region', { name: 'Version history' })).toBeTruthy()
337
+ expect(screen.queryByRole('dialog', { name: /version history/i })).toBeNull()
279
338
  })
280
339
 
281
340
  it('opens SEO inside the rail, not as a second panel beside it', async () => {
@@ -0,0 +1,116 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+ import type { EditorPost } from '../../lib/post-editor-service.js'
5
+
6
+ const cmsApi = vi.fn(async () => ({ data: { field: 'tags', values: [] }, status: 200 }))
7
+
8
+ vi.mock('../../lib/api.js', () => ({
9
+ cmsApi: (...args: unknown[]) => cmsApi(...(args as [])),
10
+ ensureCsrfToken: vi.fn(async () => {}),
11
+ }))
12
+
13
+ vi.mock('../../lib/coauthor-client.js', () => ({
14
+ coauthorText: vi.fn(async () => ({
15
+ ok: true,
16
+ result: { action: 'compress', text: 'AI excerpt' },
17
+ })),
18
+ }))
19
+
20
+ const { PostTab } = await import('../../views/post-editor/PostTab.js')
21
+
22
+ function basePost(over: Partial<EditorPost> = {}): EditorPost {
23
+ return {
24
+ id: 'p1',
25
+ postType: 'blog',
26
+ title: 'Hello',
27
+ slug: 'hello',
28
+ excerpt: 'A short summary.',
29
+ featuredImage: '',
30
+ body: '',
31
+ category: '',
32
+ tags: [],
33
+ status: 'DRAFT',
34
+ publishDate: null,
35
+ scheduledAt: null,
36
+ scheduledUnpublishAt: null,
37
+ sections: [],
38
+ seoTitle: '',
39
+ seoDescription: '',
40
+ publishedAt: null,
41
+ updatedAt: null,
42
+ ...over,
43
+ }
44
+ }
45
+
46
+ const SELECT_FIELDS = {
47
+ category: {
48
+ type: 'select' as const,
49
+ options: [
50
+ { label: 'News', value: 'news' },
51
+ { label: 'Tutorials', value: 'tutorials' },
52
+ ],
53
+ },
54
+ tags: { type: 'array' as const, maxRows: 12 },
55
+ }
56
+
57
+ beforeEach(() => {
58
+ cmsApi.mockClear()
59
+ })
60
+
61
+ describe('PostTab', () => {
62
+ it('live-applies title, slug, and excerpt without a dialog', () => {
63
+ const onChange = vi.fn()
64
+ render(<PostTab post={basePost()} canEdit fields={SELECT_FIELDS} onChange={onChange} />)
65
+
66
+ expect(screen.queryByRole('dialog')).toBeNull()
67
+ fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Renamed' } })
68
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ title: 'Renamed' }))
69
+
70
+ fireEvent.change(screen.getByLabelText(/^Slug/), { target: { value: 'custom-slug' } })
71
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ slug: 'custom-slug' }))
72
+
73
+ fireEvent.change(screen.getByLabelText(/^Excerpt/), { target: { value: 'New excerpt' } })
74
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ excerpt: 'New excerpt' }))
75
+ })
76
+
77
+ it('derives slug from title until the slug field is edited', () => {
78
+ const onChange = vi.fn()
79
+ const { rerender } = render(
80
+ <PostTab post={basePost({ title: '', slug: '' })} canEdit onChange={onChange} />,
81
+ )
82
+
83
+ fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Hello World' } })
84
+ expect(onChange).toHaveBeenCalledWith(
85
+ expect.objectContaining({ title: 'Hello World', slug: 'hello-world' }),
86
+ )
87
+
88
+ rerender(
89
+ <PostTab
90
+ post={basePost({ title: 'Hello World', slug: 'hello-world' })}
91
+ canEdit
92
+ onChange={onChange}
93
+ />,
94
+ )
95
+ fireEvent.change(screen.getByLabelText(/^Slug/), { target: { value: 'custom-slug' } })
96
+ onChange.mockClear()
97
+ rerender(
98
+ <PostTab
99
+ post={basePost({ title: 'Hello World', slug: 'custom-slug' })}
100
+ canEdit
101
+ onChange={onChange}
102
+ />,
103
+ )
104
+ fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Changed Title' } })
105
+ expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ title: 'Changed Title' }))
106
+ expect(onChange).not.toHaveBeenCalledWith(expect.objectContaining({ slug: expect.anything() }))
107
+ })
108
+
109
+ it('does not offer page-only controls', () => {
110
+ render(<PostTab post={basePost()} canEdit onChange={() => {}} />)
111
+ expect(screen.queryByText(/301 redirect/)).toBeNull()
112
+ expect(screen.queryByText(/Danger zone/)).toBeNull()
113
+ expect(screen.queryByText(/Search directives/)).toBeNull()
114
+ expect(screen.queryByLabelText(/URL slug/)).toBeNull()
115
+ })
116
+ })
@@ -154,6 +154,14 @@ export interface SeoEditorPaneProps {
154
154
  slug?: string
155
155
  draftMeta?: { seoTitle: string; seoDescription: string }
156
156
  onDraftChange?: (patch: { seoTitle?: string; seoDescription?: string }) => void
157
+ /**
158
+ * Post identity the template renders outside the section list. Folded into
159
+ * the same `buildPageAuditInput` path the saved-record audit uses, so live
160
+ * rings and the header chip cannot disagree on H1 / opening / featured image.
161
+ */
162
+ title?: string
163
+ bodyHtml?: string
164
+ featuredImage?: string
157
165
  }
158
166
 
159
167
  export function SeoEditorPane({
@@ -167,6 +175,9 @@ export function SeoEditorPane({
167
175
  slug = '',
168
176
  draftMeta,
169
177
  onDraftChange,
178
+ title,
179
+ bodyHtml,
180
+ featuredImage,
170
181
  }: SeoEditorPaneProps) {
171
182
  const isDraft = !entityId
172
183
  const [record, setRecord] = useState<SeoContentRecord | null>(null)
@@ -471,9 +482,13 @@ export function SeoEditorPane({
471
482
  slug,
472
483
  record,
473
484
  fallbackTitle: draftMeta?.seoTitle,
485
+ post:
486
+ title !== undefined || bodyHtml !== undefined || featuredImage !== undefined
487
+ ? { title, bodyHtml, featuredImage }
488
+ : undefined,
474
489
  }),
475
490
  )
476
- }, [auditSections, form, slug, record, draftMeta?.seoTitle])
491
+ }, [auditSections, form, slug, record, draftMeta?.seoTitle, title, bodyHtml, featuredImage])
477
492
 
478
493
  useEffect(() => {
479
494
  onAuditChange?.(audit)
@@ -4,7 +4,11 @@ import { useCallback, useEffect, useState } from 'react'
4
4
  import type { PageSection } from '@prenta/core/page-sections'
5
5
  import { auditPage, type PageAuditResult } from '@prenta/core/seo/page-audit'
6
6
  import { fetchSeoContentRecord, type SeoContentRecord } from '../lib/seo-service.js'
7
- import { buildPageAuditInput, metaFromRecord } from '../lib/page-audit-input.js'
7
+ import {
8
+ buildPageAuditInput,
9
+ metaFromRecord,
10
+ type PostAuditExtras,
11
+ } from '../lib/page-audit-input.js'
8
12
 
9
13
  export interface UsePageAuditArgs {
10
14
  entityType: 'page' | 'post'
@@ -19,6 +23,8 @@ export interface UsePageAuditArgs {
19
23
  * exactly the pages that most need the nudge.
20
24
  */
21
25
  fallback?: { title?: string; description?: string }
26
+ /** Post identity the template renders outside the section list. */
27
+ post?: PostAuditExtras
22
28
  }
23
29
 
24
30
  /**
@@ -42,6 +48,7 @@ export function usePageAudit({
42
48
  sections,
43
49
  slug,
44
50
  fallback,
51
+ post,
45
52
  }: UsePageAuditArgs): {
46
53
  audit: PageAuditResult
47
54
  record: SeoContentRecord | null
@@ -90,7 +97,7 @@ export function usePageAudit({
90
97
  hasOgImage: false,
91
98
  }
92
99
 
93
- const audit = auditPage(buildPageAuditInput({ sections, meta, slug, record }))
100
+ const audit = auditPage(buildPageAuditInput({ sections, meta, slug, record, post }))
94
101
 
95
102
  const reload = useCallback(() => setNonce((n) => n + 1), [])
96
103
 
@@ -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'