@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.
- package/CHANGELOG.md +12 -0
- package/design-system/README.md +1 -1
- package/design-system/tokens.css +3 -3
- package/design-system/tokens.json +2 -2
- package/design-system/tokens.scss +1 -3
- package/dist/__tests__/lib/page-audit-input.test.js +81 -0
- package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
- package/dist/__tests__/lib/version-history.test.js +18 -0
- package/dist/__tests__/lib/version-history.test.js.map +1 -1
- package/dist/__tests__/no-server-barrel-imports.test.js +3 -4
- package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -1
- package/dist/__tests__/views/editor-top-bar.render.test.js +5 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +38 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-tab.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-tab.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-tab.render.test.js +86 -0
- package/dist/__tests__/views/post-tab.render.test.js.map +1 -0
- package/dist/components/CanvasEditTip.js +1 -1
- package/dist/components/CanvasEditTip.js.map +1 -1
- package/dist/components/seo/SeoEditorPane.d.ts +9 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +5 -2
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/hooks/usePageAudit.d.ts +4 -1
- package/dist/hooks/usePageAudit.d.ts.map +1 -1
- package/dist/hooks/usePageAudit.js +3 -3
- package/dist/hooks/usePageAudit.js.map +1 -1
- package/dist/lib/page-audit-input.d.ts +26 -1
- package/dist/lib/page-audit-input.d.ts.map +1 -1
- package/dist/lib/page-audit-input.js +48 -2
- package/dist/lib/page-audit-input.js.map +1 -1
- package/dist/lib/version-history.d.ts +5 -0
- package/dist/lib/version-history.d.ts.map +1 -1
- package/dist/lib/version-history.js +18 -0
- package/dist/lib/version-history.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/EditorTopBar.js +1 -1
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.d.ts +14 -1
- package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +2 -2
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsForm.d.ts +39 -0
- package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -0
- package/dist/views/post-editor/PostFieldsForm.js +85 -0
- package/dist/views/post-editor/PostFieldsForm.js.map +1 -0
- package/dist/views/post-editor/PostFieldsModal.d.ts +2 -14
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +20 -81
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +122 -34
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostTab.d.ts +17 -0
- package/dist/views/post-editor/PostTab.d.ts.map +1 -0
- package/dist/views/post-editor/PostTab.js +23 -0
- package/dist/views/post-editor/PostTab.js.map +1 -0
- package/dist/views/settings/useAppearanceSettings.js +1 -1
- package/dist/views/settings/useAppearanceSettings.js.map +1 -1
- package/package.json +6 -6
- package/src/__tests__/lib/page-audit-input.test.ts +99 -0
- package/src/__tests__/lib/version-history.test.ts +20 -0
- package/src/__tests__/no-server-barrel-imports.test.ts +3 -4
- package/src/__tests__/views/editor-top-bar.render.test.tsx +6 -0
- package/src/__tests__/views/post-section-editor.test.tsx +59 -0
- package/src/__tests__/views/post-tab.render.test.tsx +116 -0
- package/src/components/CanvasEditTip.tsx +2 -2
- package/src/components/seo/SeoEditorPane.tsx +16 -1
- package/src/hooks/usePageAudit.ts +9 -2
- package/src/lib/page-audit-input.ts +67 -1
- package/src/lib/version-history.ts +23 -0
- package/src/styles/prenta-tokens.css +3 -3
- package/src/views/page-editor/EditorTopBar.tsx +2 -2
- package/src/views/post-editor/PostEditorCanvas.tsx +27 -5
- package/src/views/post-editor/PostFieldsForm.tsx +322 -0
- package/src/views/post-editor/PostFieldsModal.tsx +30 -281
- package/src/views/post-editor/PostSectionEditor.tsx +204 -59
- package/src/views/post-editor/PostTab.tsx +46 -0
- 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.
|
|
13
|
-
//
|
|
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
|
|
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.
|
|
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 {
|
|
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.
|
|
79
|
-
* request
|
|
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:
|
|
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=
|
|
235
|
-
title=
|
|
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
|
-
|
|
67
|
-
|
|
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}
|