@prenta/admin 1.2.0 → 1.3.1

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 (81) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/design-system/README.md +1 -1
  3. package/design-system/tokens.css +3 -3
  4. package/design-system/tokens.json +2 -2
  5. package/design-system/tokens.scss +1 -3
  6. package/dist/__tests__/lib/page-audit-input.test.js +81 -0
  7. package/dist/__tests__/lib/page-audit-input.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__/no-server-barrel-imports.test.js +3 -4
  11. package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -1
  12. package/dist/__tests__/views/editor-top-bar.render.test.js +5 -0
  13. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  14. package/dist/__tests__/views/post-section-editor.test.js +38 -0
  15. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  16. package/dist/__tests__/views/post-tab.render.test.d.ts +2 -0
  17. package/dist/__tests__/views/post-tab.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/views/post-tab.render.test.js +86 -0
  19. package/dist/__tests__/views/post-tab.render.test.js.map +1 -0
  20. package/dist/components/CanvasEditTip.js +1 -1
  21. package/dist/components/CanvasEditTip.js.map +1 -1
  22. package/dist/components/seo/SeoEditorPane.d.ts +9 -1
  23. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  24. package/dist/components/seo/SeoEditorPane.js +5 -2
  25. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  26. package/dist/hooks/usePageAudit.d.ts +4 -1
  27. package/dist/hooks/usePageAudit.d.ts.map +1 -1
  28. package/dist/hooks/usePageAudit.js +3 -3
  29. package/dist/hooks/usePageAudit.js.map +1 -1
  30. package/dist/lib/page-audit-input.d.ts +26 -1
  31. package/dist/lib/page-audit-input.d.ts.map +1 -1
  32. package/dist/lib/page-audit-input.js +48 -2
  33. package/dist/lib/page-audit-input.js.map +1 -1
  34. package/dist/lib/version-history.d.ts +5 -0
  35. package/dist/lib/version-history.d.ts.map +1 -1
  36. package/dist/lib/version-history.js +18 -0
  37. package/dist/lib/version-history.js.map +1 -1
  38. package/dist/prenta-admin.css +1 -1
  39. package/dist/views/page-editor/EditorTopBar.js +1 -1
  40. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  41. package/dist/views/post-editor/PostEditorCanvas.d.ts +14 -1
  42. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  43. package/dist/views/post-editor/PostEditorCanvas.js +2 -2
  44. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  45. package/dist/views/post-editor/PostFieldsForm.d.ts +39 -0
  46. package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -0
  47. package/dist/views/post-editor/PostFieldsForm.js +85 -0
  48. package/dist/views/post-editor/PostFieldsForm.js.map +1 -0
  49. package/dist/views/post-editor/PostFieldsModal.d.ts +2 -14
  50. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  51. package/dist/views/post-editor/PostFieldsModal.js +20 -81
  52. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  53. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  54. package/dist/views/post-editor/PostSectionEditor.js +122 -34
  55. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  56. package/dist/views/post-editor/PostTab.d.ts +17 -0
  57. package/dist/views/post-editor/PostTab.d.ts.map +1 -0
  58. package/dist/views/post-editor/PostTab.js +23 -0
  59. package/dist/views/post-editor/PostTab.js.map +1 -0
  60. package/dist/views/settings/useAppearanceSettings.js +1 -1
  61. package/dist/views/settings/useAppearanceSettings.js.map +1 -1
  62. package/package.json +6 -6
  63. package/src/__tests__/lib/page-audit-input.test.ts +99 -0
  64. package/src/__tests__/lib/version-history.test.ts +20 -0
  65. package/src/__tests__/no-server-barrel-imports.test.ts +3 -4
  66. package/src/__tests__/views/editor-top-bar.render.test.tsx +6 -0
  67. package/src/__tests__/views/post-section-editor.test.tsx +59 -0
  68. package/src/__tests__/views/post-tab.render.test.tsx +116 -0
  69. package/src/components/CanvasEditTip.tsx +2 -2
  70. package/src/components/seo/SeoEditorPane.tsx +16 -1
  71. package/src/hooks/usePageAudit.ts +9 -2
  72. package/src/lib/page-audit-input.ts +67 -1
  73. package/src/lib/version-history.ts +23 -0
  74. package/src/styles/prenta-tokens.css +3 -3
  75. package/src/views/page-editor/EditorTopBar.tsx +2 -2
  76. package/src/views/post-editor/PostEditorCanvas.tsx +27 -5
  77. package/src/views/post-editor/PostFieldsForm.tsx +322 -0
  78. package/src/views/post-editor/PostFieldsModal.tsx +30 -281
  79. package/src/views/post-editor/PostSectionEditor.tsx +204 -59
  80. package/src/views/post-editor/PostTab.tsx +46 -0
  81. package/src/views/settings/useAppearanceSettings.ts +1 -1
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { auditPage } from '@prenta/core/seo/page-audit'
3
3
  import type { PageSection } from '@prenta/core/page-sections'
4
+ import { imageSlots } from '@prenta/core/seo/page-audit'
4
5
  import {
5
6
  buildPageAuditInput,
6
7
  buildPageAuditInputFromBuilder,
@@ -147,6 +148,104 @@ describe('page audit input', () => {
147
148
  })
148
149
  })
149
150
 
151
+ const textOnlySections = [
152
+ {
153
+ id: 's-text',
154
+ sectionType: 'text',
155
+ name: 'Text',
156
+ visible: true,
157
+ content: {
158
+ heading: 'Section heading only',
159
+ body: 'Short.',
160
+ },
161
+ settings: {},
162
+ },
163
+ ] as unknown as PageSection[]
164
+
165
+ const postMeta = {
166
+ metaTitle: 'Headless CMS for teams',
167
+ metaDescription: record.metaDescription!,
168
+ focusKeyword: 'unique post',
169
+ structuredDataType: 'Article',
170
+ noindex: false,
171
+ nosnippet: false,
172
+ llmsInclude: 'AUTO',
173
+ hasOgImage: false,
174
+ }
175
+
176
+ describe('post audit extras', () => {
177
+ it('uses the post title as H1 instead of only the first section heading', () => {
178
+ const sectionsOnly = auditPage(
179
+ buildPageAuditInput({
180
+ sections: textOnlySections,
181
+ meta: postMeta,
182
+ slug: 'a-post',
183
+ record: null,
184
+ }),
185
+ )
186
+ expect(sectionsOnly.seo.checks.find((c) => c.id === 'keyword-in-h1')?.outcome).toBe('fail')
187
+
188
+ const withTitle = auditPage(
189
+ buildPageAuditInput({
190
+ sections: textOnlySections,
191
+ meta: postMeta,
192
+ slug: 'a-post',
193
+ record: null,
194
+ post: { title: 'A unique post title' },
195
+ }),
196
+ )
197
+ expect(withTitle.seo.checks.find((c) => c.id === 'keyword-in-h1')?.outcome).toBe('pass')
198
+ })
199
+
200
+ it('uses stripped post body as the opening prose', () => {
201
+ const opening =
202
+ 'Prenta is a headless CMS that gives developers an API-first architecture and editors a design-owned authoring surface for shipping content.'
203
+ expect(opening.length).toBeGreaterThanOrEqual(80)
204
+ expect(opening.length).toBeLessThanOrEqual(340)
205
+
206
+ const sectionsOnly = auditPage(
207
+ buildPageAuditInput({
208
+ sections: textOnlySections,
209
+ meta: postMeta,
210
+ slug: 'a-post',
211
+ record: null,
212
+ }),
213
+ )
214
+ expect(sectionsOnly.geo.checks.find((c) => c.id === 'direct-answer')?.outcome).toBe('fail')
215
+
216
+ const withBody = auditPage(
217
+ buildPageAuditInput({
218
+ sections: textOnlySections,
219
+ meta: postMeta,
220
+ slug: 'a-post',
221
+ record: null,
222
+ post: { bodyHtml: `<p>${opening}</p>` },
223
+ }),
224
+ )
225
+ expect(withBody.geo.checks.find((c) => c.id === 'direct-answer')?.outcome).toBe('pass')
226
+ })
227
+
228
+ it('counts a featured image as an image slot that sections-only would miss', () => {
229
+ const sectionsOnly = buildPageAuditInput({
230
+ sections: textOnlySections,
231
+ meta: postMeta,
232
+ slug: 'a-post',
233
+ record: null,
234
+ })
235
+ expect(imageSlots(sectionsOnly.sections)).toHaveLength(0)
236
+
237
+ const withImage = buildPageAuditInput({
238
+ sections: textOnlySections,
239
+ meta: postMeta,
240
+ slug: 'a-post',
241
+ record: null,
242
+ post: { featuredImage: 'https://cdn.example.com/hero.webp' },
243
+ })
244
+ expect(imageSlots(withImage.sections)).toHaveLength(1)
245
+ expect(imageSlots(withImage.sections)[0]?.sectionId).toBe('__post-featured-image')
246
+ })
247
+ })
248
+
150
249
  function builderTree(heroData: Record<string, unknown>) {
151
250
  return {
152
251
  type: 'page',
@@ -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.
@@ -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
  })
@@ -232,4 +232,10 @@ describe('EditorTopBar history button', () => {
232
232
  expect(button.className, `${name} has no touch target`).toContain('prenta-touch-target')
233
233
  }
234
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
+ })
235
241
  })
@@ -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
+ })
@@ -27,8 +27,8 @@ export function CanvasEditTip() {
27
27
  <span className="text-foreground font-medium">Editing tip</span>
28
28
  {' — '}
29
29
  Click a section on the canvas, or in the list, to open its copy and images in the section
30
- list. Use <span className="text-foreground font-medium">View site</span> for the live
31
- design.
30
+ list. The device picker sizes this canvas, not the live site. Use{' '}
31
+ <span className="text-foreground font-medium">View site</span> for the real design.
32
32
  </p>
33
33
  <button
34
34
  type="button"
@@ -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
 
@@ -33,6 +33,20 @@ export interface AuditMetaSource {
33
33
  hasOgImage: boolean
34
34
  }
35
35
 
36
+ /**
37
+ * Post identity the public template renders outside the section list.
38
+ *
39
+ * The audit engine only reads `sections`. Posts put the H1, opening prose, and
40
+ * featured image on the document itself, so both audit callers (saved record
41
+ * and live SEO pane) fold these into synthetic sections here — one mapping,
42
+ * so the header chip and the rings cannot disagree.
43
+ */
44
+ export interface PostAuditExtras {
45
+ title?: string
46
+ bodyHtml?: string
47
+ featuredImage?: string
48
+ }
49
+
36
50
  export interface BuildAuditInputArgs {
37
51
  sections: PageSection[]
38
52
  meta: AuditMetaSource
@@ -41,6 +55,57 @@ export interface BuildAuditInputArgs {
41
55
  record: Pick<SeoContentRecord, 'title' | 'siteName' | 'titleTemplate' | 'url'> | null
42
56
  /** Fallback title when there is no saved record yet. */
43
57
  fallbackTitle?: string
58
+ /** Post-only slots prepended so the same engine sees title / body / image. */
59
+ post?: PostAuditExtras
60
+ }
61
+
62
+ const POST_TITLE_SECTION_ID = '__post-title'
63
+ const POST_IMAGE_SECTION_ID = '__post-featured-image'
64
+
65
+ function stripHtml(html: string): string {
66
+ return html
67
+ .replace(/<[^>]*>/g, ' ')
68
+ .replace(/\s+/g, ' ')
69
+ .trim()
70
+ }
71
+
72
+ /**
73
+ * Prepend the post's own title, body, and featured image as sections the
74
+ * audit already knows how to read (H1, opening prose, image slot).
75
+ *
76
+ * A `text` section carries title + body so we do not invent a phantom hero
77
+ * image slot. The featured image is a separate `feature` section, and only
78
+ * when one is set — otherwise `imageSlots` would count an empty media field
79
+ * and fail alt-text for an image that is not on the page.
80
+ */
81
+ export function withPostAuditSlots(sections: PageSection[], post?: PostAuditExtras): PageSection[] {
82
+ if (!post) return sections
83
+
84
+ const extras: PageSection[] = []
85
+ const title = post.title?.trim() ?? ''
86
+ const body = post.bodyHtml ? stripHtml(post.bodyHtml) : ''
87
+ if (title || body) {
88
+ extras.push({
89
+ id: POST_TITLE_SECTION_ID,
90
+ sectionType: 'text',
91
+ name: 'Post',
92
+ visible: true,
93
+ content: { heading: title, body },
94
+ settings: {},
95
+ })
96
+ }
97
+ const image = post.featuredImage?.trim() ?? ''
98
+ if (image) {
99
+ extras.push({
100
+ id: POST_IMAGE_SECTION_ID,
101
+ sectionType: 'feature',
102
+ name: 'Featured image',
103
+ visible: true,
104
+ content: { imageUrl: image, imageAlt: '' },
105
+ settings: {},
106
+ })
107
+ }
108
+ return extras.length ? [...extras, ...sections] : sections
44
109
  }
45
110
 
46
111
  export function buildPageAuditInput({
@@ -49,6 +114,7 @@ export function buildPageAuditInput({
49
114
  slug,
50
115
  record,
51
116
  fallbackTitle,
117
+ post,
52
118
  }: BuildAuditInputArgs): PageAuditInput {
53
119
  // Measure the title a searcher actually sees — the page meta title with the
54
120
  // site's template applied — not the raw field. Checking the raw field would
@@ -65,7 +131,7 @@ export function buildPageAuditInput({
65
131
  : meta.metaTitle.trim() || fallbackTitle || ''
66
132
 
67
133
  return {
68
- sections,
134
+ sections: withPostAuditSlots(sections, post),
69
135
  seo: {
70
136
  title: renderedTitle,
71
137
  description: meta.metaDescription,
@@ -35,6 +35,11 @@ export interface PageVersion {
35
35
  author: VersionAuthor
36
36
  /** Full page content as of this version — what the canvas renders. */
37
37
  sections: PageSection[]
38
+ /** Post identity fields, when the snapshot is a post. */
39
+ title?: string
40
+ excerpt?: string
41
+ featuredImage?: string
42
+ body?: string
38
43
  }
39
44
 
40
45
  /** Versions sharing a calendar day, newest day first. */
@@ -87,6 +92,20 @@ function toAuthor(row: VersionApiRow): VersionAuthor {
87
92
  }
88
93
  }
89
94
 
95
+ function optionalString(value: unknown): string | undefined {
96
+ return typeof value === 'string' ? value : undefined
97
+ }
98
+
99
+ function optionalFeaturedImage(value: unknown): string | undefined {
100
+ if (typeof value === 'string') return value
101
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
102
+ const obj = value as Record<string, unknown>
103
+ const url = obj.url ?? obj.publicUrl ?? obj.src
104
+ if (typeof url === 'string') return url
105
+ }
106
+ return undefined
107
+ }
108
+
90
109
  export function toPageVersion(row: VersionApiRow): PageVersion {
91
110
  const data = row.data ?? {}
92
111
  return {
@@ -98,6 +117,10 @@ export function toPageVersion(row: VersionApiRow): PageVersion {
98
117
  // snapshot more strictly than the current page would make an unmanaged
99
118
  // section look like it was deleted in every version that contains one.
100
119
  sections: coerceSections(data.sections, { onUnknown: 'preserve' }),
120
+ title: optionalString(data.title),
121
+ excerpt: optionalString(data.excerpt),
122
+ featuredImage: optionalFeaturedImage(data.featuredImage),
123
+ body: optionalString(data.body),
101
124
  }
102
125
  }
103
126
 
@@ -75,10 +75,10 @@
75
75
 
76
76
  /* Type */
77
77
  /*
78
- * UI text. Self-hosted woff2 under design-system/fonts — never a third-party
79
- * request, because the admin renders inside consumer applications.
78
+ * UI text. system-ui until OFL woff2 ships under design-system/fonts/. Never
79
+ * a third-party request the admin renders inside consumer applications.
80
80
  */
81
- --font-sans: 'DM Sans', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
81
+ --font-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
82
82
  /* Code, robots directives, token names */
83
83
  --font-mono: 'DM Mono', ui-monospace, 'SFMono-Regular', monospace;
84
84
  --fw-reg: 450;
@@ -231,8 +231,8 @@ export function EditorTopBar({
231
231
  <button
232
232
  type="button"
233
233
  onClick={onOpenSettings}
234
- aria-label="Page settings"
235
- title="Page settings"
234
+ aria-label={backLabel === 'Posts' ? 'Post settings' : 'Page settings'}
235
+ title={backLabel === 'Posts' ? 'Post settings' : 'Page settings'}
236
236
  className="prenta-touch-target text-muted-foreground hover:text-foreground hover:bg-accent rounded-lg p-2"
237
237
  >
238
238
  <Settings2 className="h-4 w-4" aria-hidden />
@@ -24,6 +24,19 @@ interface PostEditorCanvasProps {
24
24
  onAddSection?: () => void
25
25
  /** Section types rendered as placeholders — shows the "Structure view" notice. */
26
26
  structuralTypes?: string[]
27
+ /**
28
+ * Read-only render of a past version. Suppresses selection and the editing
29
+ * affordances, and turns on the changed-section outlines.
30
+ */
31
+ reviewing?: boolean
32
+ /** Sections that differ from the version before the one being reviewed. */
33
+ changedSectionIds?: ReadonlySet<string>
34
+ /**
35
+ * Rendered in place of the editing tip / structure notice while reviewing.
36
+ * Passed in rather than built here because the review bar's actions belong
37
+ * to the editor's history state, which the canvas knows nothing about.
38
+ */
39
+ reviewBar?: React.ReactNode
27
40
  }
28
41
 
29
42
  /**
@@ -51,6 +64,9 @@ export function PostEditorCanvas({
51
64
  onCanvasElement,
52
65
  onAddSection,
53
66
  structuralTypes = [],
67
+ reviewing = false,
68
+ changedSectionIds,
69
+ reviewBar,
54
70
  }: PostEditorCanvasProps) {
55
71
  const registerRef = useSectionRefs(selectedId)
56
72
 
@@ -62,15 +78,19 @@ export function PostEditorCanvas({
62
78
  onScaleChange={onScaleChange}
63
79
  onCanvasElement={onCanvasElement}
64
80
  chrome={
65
- <>
66
- {sections.length > 0 && <CanvasEditTip />}
67
- <StructureNotice structuralTypes={structuralTypes} />
68
- </>
81
+ reviewing ? (
82
+ reviewBar
83
+ ) : (
84
+ <>
85
+ {sections.length > 0 && <CanvasEditTip />}
86
+ <StructureNotice structuralTypes={structuralTypes} />
87
+ </>
88
+ )
69
89
  }
70
90
  >
71
91
  <PostHeader config={header} context={context} />
72
92
  {sections.length === 0 ? (
73
- <EmptyBody onAddSection={onAddSection} />
93
+ <EmptyBody onAddSection={reviewing ? undefined : onAddSection} />
74
94
  ) : (
75
95
  sections.map((section) => (
76
96
  <SectionRenderer
@@ -78,6 +98,8 @@ export function PostEditorCanvas({
78
98
  section={section}
79
99
  context={context}
80
100
  editable
101
+ reviewing={reviewing}
102
+ changed={changedSectionIds?.has(section.id) ?? false}
81
103
  selected={section.id === selectedId}
82
104
  onSelect={onSelect}
83
105
  registerRef={registerRef}