@prenta/admin 1.10.1 → 1.12.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.
- package/CHANGELOG.md +32 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +3 -1
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/seo-editor-pane.render.test.js +15 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
- package/dist/__tests__/lib/page-editor-service.test.js +23 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +54 -1
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/views/ai-settings.render.test.js +26 -8
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.js +20 -2
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +40 -2
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoCopilotDrawer.js +4 -0
- package/dist/components/seo/SeoCopilotDrawer.js.map +1 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +8 -1
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixDrawer.js +4 -0
- package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -1
- package/dist/components/seo/SeoSearchStats.d.ts +4 -0
- package/dist/components/seo/SeoSearchStats.d.ts.map +1 -0
- package/dist/components/seo/SeoSearchStats.js +91 -0
- package/dist/components/seo/SeoSearchStats.js.map +1 -0
- package/dist/lib/editor-seo-content.d.ts.map +1 -1
- package/dist/lib/editor-seo-content.js +2 -2
- package/dist/lib/editor-seo-content.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +2 -2
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +3 -3
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +41 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +22 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +3 -2
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +5 -1
- package/dist/views/page-editor/SectionsPanel.js.map +1 -1
- package/dist/views/page-editor/section-types.d.ts +1 -1
- package/dist/views/page-editor/section-types.d.ts.map +1 -1
- package/dist/views/page-editor/section-types.js +1 -1
- package/dist/views/page-editor/section-types.js.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +2 -2
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/dist/views/seo/OverviewTab.js +1 -1
- package/dist/views/seo/OverviewTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +6 -0
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
- package/dist/views/settings/AISettingsTab.js +1 -1
- package/dist/views/settings/AISettingsTab.js.map +1 -1
- package/dist/views/settings/AiFeaturesCard.d.ts +8 -3
- package/dist/views/settings/AiFeaturesCard.d.ts.map +1 -1
- package/dist/views/settings/AiFeaturesCard.js +7 -6
- package/dist/views/settings/AiFeaturesCard.js.map +1 -1
- package/dist/views/settings/SearchDataCard.d.ts +10 -0
- package/dist/views/settings/SearchDataCard.d.ts.map +1 -0
- package/dist/views/settings/SearchDataCard.js +126 -0
- package/dist/views/settings/SearchDataCard.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +2 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/useAiSettings.d.ts +12 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -1
- package/dist/views/settings/useAiSettings.js +29 -1
- package/dist/views/settings/useAiSettings.js.map +1 -1
- package/package.json +3 -3
- package/src/AdminRoot.tsx +4 -0
- package/src/__tests__/components/seo-editor-pane.render.test.tsx +25 -3
- package/src/__tests__/lib/page-editor-service.test.ts +23 -0
- package/src/__tests__/lib/seo-service-errors.test.ts +73 -0
- package/src/__tests__/views/ai-settings.render.test.tsx +38 -8
- package/src/__tests__/views/redirects-pane.render.test.tsx +28 -2
- package/src/__tests__/views/seo-settings.render.test.tsx +55 -2
- package/src/components/seo/SeoCopilotDrawer.tsx +4 -0
- package/src/components/seo/SeoEditorPane.tsx +13 -0
- package/src/components/seo/SeoIssueFixDrawer.tsx +4 -0
- package/src/components/seo/SeoSearchStats.tsx +183 -0
- package/src/lib/editor-seo-content.ts +6 -2
- package/src/lib/page-editor-service.ts +2 -1
- package/src/lib/post-editor-service.ts +5 -2
- package/src/lib/seo-service.ts +64 -2
- package/src/views/page-editor/SectionInspector.tsx +23 -20
- package/src/views/page-editor/SectionsPanel.tsx +27 -17
- package/src/views/page-editor/section-types.ts +2 -0
- package/src/views/page-editor/sections/SectionRenderer.tsx +2 -2
- package/src/views/seo/OverviewTab.tsx +1 -1
- package/src/views/seo/RedirectsTab.tsx +6 -0
- package/src/views/settings/AISettingsTab.tsx +1 -0
- package/src/views/settings/AiFeaturesCard.tsx +59 -2
- package/src/views/settings/SearchDataCard.tsx +390 -0
- package/src/views/settings/SeoSettingsTab.tsx +2 -0
- package/src/views/settings/useAiSettings.ts +71 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { registerSectionTypes, resetCustomSectionTypes } from '@prenta/core/page-sections'
|
|
2
3
|
import {
|
|
3
4
|
addSection,
|
|
4
5
|
createSection,
|
|
@@ -116,6 +117,28 @@ describe('validatePage', () => {
|
|
|
116
117
|
expect(errors.some((e) => e.includes('at least one visible section'))).toBe(true)
|
|
117
118
|
})
|
|
118
119
|
|
|
120
|
+
it('treats hideable: false leftovers as visible for publish', () => {
|
|
121
|
+
registerSectionTypes([
|
|
122
|
+
{
|
|
123
|
+
id: 'am-fat',
|
|
124
|
+
name: 'Fat',
|
|
125
|
+
scope: ['page'],
|
|
126
|
+
hideable: false,
|
|
127
|
+
fields: [{ key: 'heading', label: 'Heading', type: 'text' }],
|
|
128
|
+
},
|
|
129
|
+
])
|
|
130
|
+
try {
|
|
131
|
+
const leftover = {
|
|
132
|
+
...createSection('am-fat'),
|
|
133
|
+
visible: false,
|
|
134
|
+
}
|
|
135
|
+
const { errors } = validatePage(basePage([leftover]), true)
|
|
136
|
+
expect(errors.some((e) => e.includes('at least one visible section'))).toBe(false)
|
|
137
|
+
} finally {
|
|
138
|
+
resetCustomSectionTypes()
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
119
142
|
it('flags missing required section fields on publish', () => {
|
|
120
143
|
const hero = createSection('hero')
|
|
121
144
|
hero.content.heading = '' // required
|
|
@@ -9,12 +9,17 @@ vi.mock('../../lib/api.js', () => ({
|
|
|
9
9
|
|
|
10
10
|
const {
|
|
11
11
|
applySeoInternalLink,
|
|
12
|
+
explainSeoIssue,
|
|
12
13
|
fetchSeoOverview,
|
|
13
14
|
fetchSeoContentRecords,
|
|
14
15
|
fetchSeoIssues,
|
|
15
16
|
fetchRedirectRules,
|
|
17
|
+
generate404RecoverySuggestions,
|
|
18
|
+
generateSeoField,
|
|
19
|
+
generateStructuredData,
|
|
16
20
|
isInlineSeoFixIssue,
|
|
17
21
|
runSeoAudit,
|
|
22
|
+
summarizeSeoAudit,
|
|
18
23
|
updateSeoFields,
|
|
19
24
|
} = await import('../../lib/seo-service.js')
|
|
20
25
|
|
|
@@ -105,6 +110,74 @@ describe('SEO service read fetchers surface errors', () => {
|
|
|
105
110
|
})
|
|
106
111
|
})
|
|
107
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Regression tests for the "swallowed governance errors" gap: the governed AI
|
|
115
|
+
* runtime returns crafted 429/403 messages (daily request cap, monthly budget,
|
|
116
|
+
* disabled feature) and the copilot helpers must surface them, not collapse to
|
|
117
|
+
* `{}` — which the UI renders as the misleading "No suggestion was returned."
|
|
118
|
+
* Unavailability (501/503) stays a distinct graceful state.
|
|
119
|
+
*/
|
|
120
|
+
describe('AI copilot helpers surface governance errors', () => {
|
|
121
|
+
const capMsg =
|
|
122
|
+
'The "SEO meta generation" feature reached its daily limit of 5 AI requests. Raise the cap in Settings > AI.'
|
|
123
|
+
|
|
124
|
+
it('generateSeoField returns the server message on 429', async () => {
|
|
125
|
+
cmsApi.mockResolvedValue({ error: capMsg, status: 429 })
|
|
126
|
+
await expect(
|
|
127
|
+
generateSeoField('metaTitle', { entityType: 'page', entityId: 'p1' }),
|
|
128
|
+
).resolves.toEqual({ error: capMsg })
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('generateSeoField still maps 501/503 to { unavailable: true }', async () => {
|
|
132
|
+
cmsApi.mockResolvedValue({ error: 'AI provider is not configured', status: 503 })
|
|
133
|
+
await expect(
|
|
134
|
+
generateSeoField('metaTitle', { entityType: 'page', entityId: 'p1' }),
|
|
135
|
+
).resolves.toEqual({ unavailable: true })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('explainSeoIssue returns the server message on 429', async () => {
|
|
139
|
+
cmsApi.mockResolvedValue({ error: capMsg, status: 429 })
|
|
140
|
+
await expect(explainSeoIssue('issue-1')).resolves.toEqual({ error: capMsg })
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('generateStructuredData returns the server message on 403', async () => {
|
|
144
|
+
cmsApi.mockResolvedValue({ error: 'This AI feature is disabled.', status: 403 })
|
|
145
|
+
await expect(
|
|
146
|
+
generateStructuredData({ entityType: 'page', entityId: 'p1', schemaType: 'Article' }),
|
|
147
|
+
).resolves.toEqual({ error: 'This AI feature is disabled.' })
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('summarizeSeoAudit returns the server message on 429', async () => {
|
|
151
|
+
cmsApi.mockResolvedValue({ error: capMsg, status: 429 })
|
|
152
|
+
await expect(summarizeSeoAudit()).resolves.toEqual({ error: capMsg })
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('generate404RecoverySuggestions governance passthrough', () => {
|
|
157
|
+
it('surfaces governanceBlocked from the suggest response', async () => {
|
|
158
|
+
cmsApi.mockResolvedValue({
|
|
159
|
+
data: {
|
|
160
|
+
created: 3,
|
|
161
|
+
autoApplied: 0,
|
|
162
|
+
governanceBlocked: 'Auto-apply requires human approval.',
|
|
163
|
+
},
|
|
164
|
+
status: 200,
|
|
165
|
+
})
|
|
166
|
+
await expect(generate404RecoverySuggestions()).resolves.toEqual({
|
|
167
|
+
autoApplied: 0,
|
|
168
|
+
governanceBlocked: 'Auto-apply requires human approval.',
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('normalizes a null governanceBlocked to undefined', async () => {
|
|
173
|
+
cmsApi.mockResolvedValue({
|
|
174
|
+
data: { created: 1, autoApplied: 1, governanceBlocked: null },
|
|
175
|
+
status: 200,
|
|
176
|
+
})
|
|
177
|
+
await expect(generate404RecoverySuggestions()).resolves.toEqual({ autoApplied: 1 })
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
108
181
|
describe('inline SEO fix issue types', () => {
|
|
109
182
|
it('treats orphan-page as an inline Approve issue', () => {
|
|
110
183
|
expect(isInlineSeoFixIssue('orphan-page')).toBe(true)
|
|
@@ -19,14 +19,9 @@ const SETTINGS_RESPONSE = {
|
|
|
19
19
|
enabled: true,
|
|
20
20
|
category: 'content',
|
|
21
21
|
requiredCapabilities: ['text'],
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
label: 'Redirect chain flattening',
|
|
26
|
-
description: 'Suggest single-hop redirects.',
|
|
27
|
-
enabled: false,
|
|
28
|
-
category: 'seo',
|
|
29
|
-
requiredCapabilities: ['text'],
|
|
22
|
+
approvalRequired: true,
|
|
23
|
+
allowBulkApply: false,
|
|
24
|
+
maxRequestsPerDay: 40,
|
|
30
25
|
},
|
|
31
26
|
],
|
|
32
27
|
budget: { monthlyTokenLimit: 1000000 },
|
|
@@ -249,6 +244,38 @@ describe('AISettingsTab', () => {
|
|
|
249
244
|
expect(cw.modelId).toBe('anthropic:claude-haiku')
|
|
250
245
|
})
|
|
251
246
|
|
|
247
|
+
it('renders per-feature governance controls and buffers edits into the save payload', async () => {
|
|
248
|
+
render(<AISettingsTab role="ADMIN" />)
|
|
249
|
+
await screen.findByText('AI Features')
|
|
250
|
+
|
|
251
|
+
const approval = screen.getByRole('switch', { name: 'Require approval' })
|
|
252
|
+
expect(screen.getByRole('switch', { name: 'Allow bulk apply' })).toBeTruthy()
|
|
253
|
+
const capInput = screen.getByLabelText('Max requests per day') as HTMLInputElement
|
|
254
|
+
expect(capInput.value).toBe('40')
|
|
255
|
+
|
|
256
|
+
fireEvent.click(approval)
|
|
257
|
+
fireEvent.change(capInput, { target: { value: '15' } })
|
|
258
|
+
|
|
259
|
+
const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
|
|
260
|
+
expect(save.disabled).toBe(false)
|
|
261
|
+
fireEvent.click(save)
|
|
262
|
+
|
|
263
|
+
await waitFor(() =>
|
|
264
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
265
|
+
'/ai/settings',
|
|
266
|
+
expect.objectContaining({ method: 'PUT' }),
|
|
267
|
+
),
|
|
268
|
+
)
|
|
269
|
+
const putCall = cmsApi.mock.calls.find(
|
|
270
|
+
([e, o]) => e === '/ai/settings' && (o as { method?: string })?.method === 'PUT',
|
|
271
|
+
)
|
|
272
|
+
const body = JSON.parse((putCall![1] as { body: string }).body)
|
|
273
|
+
const cw = body.features.find((f: { key: string }) => f.key === 'ai.features.contentWriting')
|
|
274
|
+
expect(cw.approvalRequired).toBe(false)
|
|
275
|
+
expect(cw.allowBulkApply).toBe(false)
|
|
276
|
+
expect(cw.maxRequestsPerDay).toBe(15)
|
|
277
|
+
})
|
|
278
|
+
|
|
252
279
|
it('warns when a feature would run on a model missing a required capability', async () => {
|
|
253
280
|
settingsResponse = deepClone(SETTINGS_RESPONSE)
|
|
254
281
|
// Alt-text needs vision; route it through the text-only Haiku model.
|
|
@@ -260,6 +287,9 @@ describe('AISettingsTab', () => {
|
|
|
260
287
|
category: 'media',
|
|
261
288
|
requiredCapabilities: ['vision'],
|
|
262
289
|
modelId: 'anthropic:claude-haiku',
|
|
290
|
+
approvalRequired: true,
|
|
291
|
+
allowBulkApply: false,
|
|
292
|
+
maxRequestsPerDay: 100,
|
|
263
293
|
} as (typeof SETTINGS_RESPONSE)['settings']['features'][number])
|
|
264
294
|
|
|
265
295
|
render(<AISettingsTab role="ADMIN" />)
|
|
@@ -36,6 +36,9 @@ const fetchRedirectRules = vi.fn(async () => RULES)
|
|
|
36
36
|
const createRedirectRule = vi.fn(async (_p: unknown) => ({}))
|
|
37
37
|
const updateRedirectRule = vi.fn(async (_id: string, _p: unknown) => ({}))
|
|
38
38
|
const deleteRedirectRule = vi.fn(async (_id: string) => ({}))
|
|
39
|
+
const generate404RecoverySuggestions = vi.fn(
|
|
40
|
+
async (): Promise<{ error?: string; autoApplied?: number; governanceBlocked?: string }> => ({}),
|
|
41
|
+
)
|
|
39
42
|
|
|
40
43
|
vi.mock('../../lib/seo-service.js', () => ({
|
|
41
44
|
fetchRedirectOverview: vi.fn(async () => ({
|
|
@@ -52,7 +55,7 @@ vi.mock('../../lib/seo-service.js', () => ({
|
|
|
52
55
|
updateRedirectRule: (id: string, p: unknown) => updateRedirectRule(id, p),
|
|
53
56
|
deleteRedirectRule: (id: string) => deleteRedirectRule(id),
|
|
54
57
|
flattenRedirectChain: vi.fn(async () => ({})),
|
|
55
|
-
generate404RecoverySuggestions:
|
|
58
|
+
generate404RecoverySuggestions: () => generate404RecoverySuggestions(),
|
|
56
59
|
acceptRedirectSuggestion: vi.fn(async () => ({})),
|
|
57
60
|
dismissRedirectSuggestion: vi.fn(async () => ({})),
|
|
58
61
|
exportRedirectsCsv: vi.fn(async () => ({ csv: '' })),
|
|
@@ -65,7 +68,9 @@ vi.mock('../../lib/seo-service.js', () => ({
|
|
|
65
68
|
saveRedirectRecoverySettings: vi.fn(async () => ({})),
|
|
66
69
|
}))
|
|
67
70
|
|
|
68
|
-
vi.mock('sonner', () => ({
|
|
71
|
+
vi.mock('sonner', () => ({
|
|
72
|
+
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), loading: vi.fn() },
|
|
73
|
+
}))
|
|
69
74
|
|
|
70
75
|
const { RedirectsTab } = await import('../../views/seo/RedirectsTab.js')
|
|
71
76
|
|
|
@@ -162,3 +167,24 @@ describe('Redirects inline editor pane', () => {
|
|
|
162
167
|
expect(createRedirectRule).not.toHaveBeenCalled()
|
|
163
168
|
})
|
|
164
169
|
})
|
|
170
|
+
|
|
171
|
+
describe('AI 404 recovery governance feedback', () => {
|
|
172
|
+
it('explains why auto-apply was skipped when governance blocked it', async () => {
|
|
173
|
+
generate404RecoverySuggestions.mockResolvedValueOnce({
|
|
174
|
+
autoApplied: 0,
|
|
175
|
+
governanceBlocked:
|
|
176
|
+
'The "404 Recovery suggestions" feature requires human approval before AI results are applied.',
|
|
177
|
+
})
|
|
178
|
+
const { toast } = await import('sonner')
|
|
179
|
+
|
|
180
|
+
render(<RedirectsTab />)
|
|
181
|
+
fireEvent.click(await screen.findByRole('button', { name: /Generate suggestions/ }))
|
|
182
|
+
|
|
183
|
+
// Generation succeeded — suggestions exist — but the operator is told the
|
|
184
|
+
// governance reason instead of silently seeing nothing auto-applied.
|
|
185
|
+
await waitFor(() =>
|
|
186
|
+
expect(toast.info).toHaveBeenCalledWith(expect.stringContaining('requires human approval')),
|
|
187
|
+
)
|
|
188
|
+
expect(toast.success).toHaveBeenCalledWith('Generated 404 recovery suggestions.')
|
|
189
|
+
})
|
|
190
|
+
})
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
3
|
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
4
4
|
|
|
5
|
-
// The SEO defaults tab reads
|
|
5
|
+
// The SEO defaults tab reads five shared backends: /seo/config (meta defaults +
|
|
6
6
|
// verification), /seo/crawl-settings (sitemap toggles), /seo/sitemap/status
|
|
7
|
-
// (read-only summary),
|
|
7
|
+
// (read-only summary), /seo/index-status (environment), and
|
|
8
|
+
// /seo/search-console (Search data card).
|
|
8
9
|
const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) => {
|
|
9
10
|
const method = options?.method ?? 'GET'
|
|
10
11
|
if (endpoint === '/seo/config' && method === 'GET') {
|
|
@@ -51,6 +52,21 @@ const cmsApi = vi.fn(async (endpoint: string, options?: { method?: string }) =>
|
|
|
51
52
|
if (endpoint === '/seo/index-status' && method === 'GET') {
|
|
52
53
|
return { data: { environment: 'preview' }, status: 200 }
|
|
53
54
|
}
|
|
55
|
+
if (endpoint === '/seo/search-console' && method === 'GET') {
|
|
56
|
+
return {
|
|
57
|
+
data: {
|
|
58
|
+
propertyUrl: '',
|
|
59
|
+
hasServiceAccountKey: false,
|
|
60
|
+
serviceAccountEmail: null,
|
|
61
|
+
hasCruxApiKey: false,
|
|
62
|
+
connected: false,
|
|
63
|
+
lastSyncAt: null,
|
|
64
|
+
syncStatus: null,
|
|
65
|
+
suggestedProperty: 'sc-domain:acme.test',
|
|
66
|
+
},
|
|
67
|
+
status: 200,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
54
70
|
return { data: {}, status: 200 }
|
|
55
71
|
})
|
|
56
72
|
|
|
@@ -247,4 +263,41 @@ describe('SeoSettingsTab', () => {
|
|
|
247
263
|
true,
|
|
248
264
|
)
|
|
249
265
|
})
|
|
266
|
+
|
|
267
|
+
it('renders the Search data card in its not-connected state with gated actions', async () => {
|
|
268
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
269
|
+
expect(await screen.findByText('Not connected')).toBeTruthy()
|
|
270
|
+
|
|
271
|
+
// Test/sync require a stored key or a working connection respectively.
|
|
272
|
+
expect(
|
|
273
|
+
(screen.getByRole('button', { name: /Test connection/ }) as HTMLButtonElement).disabled,
|
|
274
|
+
).toBe(true)
|
|
275
|
+
expect((screen.getByRole('button', { name: /Sync now/ }) as HTMLButtonElement).disabled).toBe(
|
|
276
|
+
true,
|
|
277
|
+
)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('saves Search Console credentials via PUT /seo/search-console', async () => {
|
|
281
|
+
render(<SeoSettingsTab role="ADMIN" />)
|
|
282
|
+
fireEvent.change(await screen.findByLabelText(/Search Console property/), {
|
|
283
|
+
target: { value: 'sc-domain:acme.test' },
|
|
284
|
+
})
|
|
285
|
+
fireEvent.change(screen.getByLabelText(/Service account key/), {
|
|
286
|
+
target: { value: '{"client_email":"svc@acme.iam.gserviceaccount.com","private_key":"k"}' },
|
|
287
|
+
})
|
|
288
|
+
fireEvent.click(screen.getByRole('button', { name: /Save credentials/ }))
|
|
289
|
+
|
|
290
|
+
await waitFor(() =>
|
|
291
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
292
|
+
'/seo/search-console',
|
|
293
|
+
expect.objectContaining({ method: 'PUT' }),
|
|
294
|
+
),
|
|
295
|
+
)
|
|
296
|
+
const putCall = cmsApi.mock.calls.find(
|
|
297
|
+
([e, o]) => e === '/seo/search-console' && (o as { method?: string })?.method === 'PUT',
|
|
298
|
+
)
|
|
299
|
+
const body = JSON.parse((putCall![1] as { body: string }).body)
|
|
300
|
+
expect(body.propertyUrl).toBe('sc-domain:acme.test')
|
|
301
|
+
expect(body.serviceAccountKey).toContain('svc@acme.iam.gserviceaccount.com')
|
|
302
|
+
})
|
|
250
303
|
})
|
|
@@ -51,6 +51,7 @@ import { SeoAccordion, SeoAccordionBadge, SeoAccordionItem } from './SeoAccordio
|
|
|
51
51
|
import { SeoAnalysisList } from './SeoAnalysisList.js'
|
|
52
52
|
import { SeoInsightsMetrics } from './SeoInsightsMetrics.js'
|
|
53
53
|
import { SeoLinkingSection } from './SeoLinkingSection.js'
|
|
54
|
+
import { SeoSearchStats } from './SeoSearchStats.js'
|
|
54
55
|
|
|
55
56
|
// Ranges the audit's title/description checks score against — one definition,
|
|
56
57
|
// so the meter cannot tell an editor they are in range while the check fails.
|
|
@@ -340,6 +341,12 @@ export function SeoEditorPane({
|
|
|
340
341
|
toast.error('AI is not configured. Add an AI provider to enable suggestions.')
|
|
341
342
|
return
|
|
342
343
|
}
|
|
344
|
+
if (result.error) {
|
|
345
|
+
// Governed refusal (daily cap, budget, disabled feature) — the server
|
|
346
|
+
// message says which limit was hit and where to raise it.
|
|
347
|
+
toast.error(result.error)
|
|
348
|
+
return
|
|
349
|
+
}
|
|
343
350
|
if (result.text) {
|
|
344
351
|
// Write the suggestion into the field the editor actually asked to
|
|
345
352
|
// generate. Previously an OG title/description generate was remapped
|
|
@@ -988,6 +995,12 @@ export function SeoEditorPane({
|
|
|
988
995
|
<SeoInsightsMetrics readability={readability} plainText={plainText} />
|
|
989
996
|
</SeoAccordionItem>
|
|
990
997
|
|
|
998
|
+
{!isDraft && entityId && (
|
|
999
|
+
<SeoAccordionItem value="search-performance" title="Search performance">
|
|
1000
|
+
<SeoSearchStats entityId={entityId} />
|
|
1001
|
+
</SeoAccordionItem>
|
|
1002
|
+
)}
|
|
1003
|
+
|
|
991
1004
|
<SeoAccordionItem value="linking-data" title="Linking data">
|
|
992
1005
|
<SeoLinkingSection
|
|
993
1006
|
entityType={entityType}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Read-only search-performance block for the SEO editor pane: synced Google
|
|
5
|
+
* Search Console 28-day stats, top queries, and CrUX Core Web Vitals for one
|
|
6
|
+
* document. Purely informational — editing happens in the fields above it.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState } from 'react'
|
|
9
|
+
import { fetchSearchInsight, type SearchInsight } from '../../lib/seo-service.js'
|
|
10
|
+
import { SeoLoading } from './primitives.js'
|
|
11
|
+
|
|
12
|
+
function formatCompact(n: number): string {
|
|
13
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
|
|
14
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}K`
|
|
15
|
+
return String(n)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function DeltaHint({
|
|
19
|
+
current,
|
|
20
|
+
previous,
|
|
21
|
+
lowerIsBetter = false,
|
|
22
|
+
}: {
|
|
23
|
+
current: number | null
|
|
24
|
+
previous: number | null
|
|
25
|
+
lowerIsBetter?: boolean
|
|
26
|
+
}) {
|
|
27
|
+
if (current == null || previous == null || previous === 0) return null
|
|
28
|
+
const diff = current - previous
|
|
29
|
+
if (diff === 0) return null
|
|
30
|
+
const improved = lowerIsBetter ? diff < 0 : diff > 0
|
|
31
|
+
return (
|
|
32
|
+
<span className={improved ? 'text-[var(--suc)]' : 'text-[var(--err)]'}>
|
|
33
|
+
{diff > 0 ? '+' : ''}
|
|
34
|
+
{Number.isInteger(diff) ? diff : diff.toFixed(1)}
|
|
35
|
+
</span>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cwvTone(status: SearchInsight['cwvStatus']): { label: string; cls: string } {
|
|
40
|
+
switch (status) {
|
|
41
|
+
case 'good':
|
|
42
|
+
return { label: 'Good', cls: 'bg-[var(--suc-l)] text-[var(--suc)]' }
|
|
43
|
+
case 'needs-improvement':
|
|
44
|
+
return { label: 'Needs improvement', cls: 'bg-[var(--warn-l)] text-[var(--warn)]' }
|
|
45
|
+
case 'poor':
|
|
46
|
+
return { label: 'Poor', cls: 'bg-[var(--err-l)] text-[var(--err)]' }
|
|
47
|
+
default:
|
|
48
|
+
return { label: 'No field data', cls: 'bg-[var(--bg)] text-[var(--muted)]' }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function SeoSearchStats({ entityId }: { entityId: string }) {
|
|
53
|
+
const [loading, setLoading] = useState(true)
|
|
54
|
+
const [connected, setConnected] = useState(false)
|
|
55
|
+
const [insight, setInsight] = useState<SearchInsight | null>(null)
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
let alive = true
|
|
59
|
+
setLoading(true)
|
|
60
|
+
fetchSearchInsight(entityId)
|
|
61
|
+
.then((res) => {
|
|
62
|
+
if (!alive) return
|
|
63
|
+
setConnected(res.connected)
|
|
64
|
+
setInsight(res.insight)
|
|
65
|
+
})
|
|
66
|
+
.catch(() => {
|
|
67
|
+
if (!alive) return
|
|
68
|
+
setConnected(false)
|
|
69
|
+
setInsight(null)
|
|
70
|
+
})
|
|
71
|
+
.finally(() => alive && setLoading(false))
|
|
72
|
+
return () => {
|
|
73
|
+
alive = false
|
|
74
|
+
}
|
|
75
|
+
}, [entityId])
|
|
76
|
+
|
|
77
|
+
if (loading) return <SeoLoading />
|
|
78
|
+
|
|
79
|
+
if (!connected) {
|
|
80
|
+
return (
|
|
81
|
+
<p className="text-[11.5px] text-[var(--sub)]">
|
|
82
|
+
Connect Google Search Console in Settings → SEO → Search data to see real clicks,
|
|
83
|
+
impressions, and queries for this page.
|
|
84
|
+
</p>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!insight) {
|
|
89
|
+
return (
|
|
90
|
+
<p className="text-[11.5px] text-[var(--sub)]">
|
|
91
|
+
No search data synced for this page yet. Data appears after the next nightly sync once the
|
|
92
|
+
page has impressions in Google Search.
|
|
93
|
+
</p>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const cwv = cwvTone(insight.cwvStatus)
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="space-y-3">
|
|
101
|
+
<div className="grid grid-cols-3 gap-2">
|
|
102
|
+
{[
|
|
103
|
+
{
|
|
104
|
+
label: 'Clicks',
|
|
105
|
+
value: formatCompact(insight.clicks28d),
|
|
106
|
+
delta: <DeltaHint current={insight.clicks28d} previous={insight.clicksPrev28d} />,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
label: 'Impressions',
|
|
110
|
+
value: formatCompact(insight.impressions28d),
|
|
111
|
+
delta: (
|
|
112
|
+
<DeltaHint current={insight.impressions28d} previous={insight.impressionsPrev28d} />
|
|
113
|
+
),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
label: 'Avg. position',
|
|
117
|
+
value: insight.avgPosition28d != null ? insight.avgPosition28d.toFixed(1) : '—',
|
|
118
|
+
delta: (
|
|
119
|
+
<DeltaHint
|
|
120
|
+
current={insight.avgPosition28d}
|
|
121
|
+
previous={insight.avgPositionPrev28d}
|
|
122
|
+
lowerIsBetter
|
|
123
|
+
/>
|
|
124
|
+
),
|
|
125
|
+
},
|
|
126
|
+
].map((stat) => (
|
|
127
|
+
<div key={stat.label} className="rounded-[7px] border border-[var(--bdr)] px-2.5 py-2">
|
|
128
|
+
<div className="text-[10.5px] text-[var(--muted)]">{stat.label}</div>
|
|
129
|
+
<div className="flex items-baseline gap-1.5">
|
|
130
|
+
<span className="text-[15px] font-semibold text-[var(--txt)]">{stat.value}</span>
|
|
131
|
+
<span className="text-[10.5px]">{stat.delta}</span>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
<p className="text-[10.5px] text-[var(--muted)]">
|
|
137
|
+
Last 28 days vs the prior 28 — synced from Google Search Console.
|
|
138
|
+
</p>
|
|
139
|
+
|
|
140
|
+
{insight.topQueries.length > 0 && (
|
|
141
|
+
<div>
|
|
142
|
+
<p className="mb-1.5 text-[11px] font-medium text-[var(--sub)]">Top queries</p>
|
|
143
|
+
<table className="w-full text-[11.5px]" aria-label="Top search queries for this page">
|
|
144
|
+
<thead>
|
|
145
|
+
<tr className="text-left text-[10.5px] text-[var(--muted)]">
|
|
146
|
+
<th className="pb-1 font-medium">Query</th>
|
|
147
|
+
<th className="pb-1 text-right font-medium">Clicks</th>
|
|
148
|
+
<th className="pb-1 text-right font-medium">Pos.</th>
|
|
149
|
+
</tr>
|
|
150
|
+
</thead>
|
|
151
|
+
<tbody>
|
|
152
|
+
{insight.topQueries.slice(0, 8).map((q) => (
|
|
153
|
+
<tr key={q.query} className="border-t border-[var(--bdr)]">
|
|
154
|
+
<td className="max-w-0 truncate py-1 pr-2 text-[var(--txt)]" title={q.query}>
|
|
155
|
+
{q.query}
|
|
156
|
+
</td>
|
|
157
|
+
<td className="py-1 text-right text-[var(--sub)]">{formatCompact(q.clicks)}</td>
|
|
158
|
+
<td className="py-1 text-right text-[var(--sub)]">{q.position.toFixed(1)}</td>
|
|
159
|
+
</tr>
|
|
160
|
+
))}
|
|
161
|
+
</tbody>
|
|
162
|
+
</table>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
<div className="flex items-center justify-between gap-2 border-t border-[var(--bdr)] pt-2.5">
|
|
167
|
+
<span className="text-[11px] font-medium text-[var(--sub)]">
|
|
168
|
+
Core Web Vitals (real users)
|
|
169
|
+
</span>
|
|
170
|
+
<span className={`rounded-full px-2 py-0.5 text-[10.5px] font-medium ${cwv.cls}`}>
|
|
171
|
+
{cwv.label}
|
|
172
|
+
</span>
|
|
173
|
+
</div>
|
|
174
|
+
{insight.cwvStatus !== null && (
|
|
175
|
+
<div className="grid grid-cols-3 gap-2 text-[11px] text-[var(--sub)]">
|
|
176
|
+
<span>LCP {insight.lcpMs != null ? `${(insight.lcpMs / 1000).toFixed(1)}s` : '—'}</span>
|
|
177
|
+
<span>INP {insight.inpMs != null ? `${insight.inpMs}ms` : '—'}</span>
|
|
178
|
+
<span>CLS {insight.cls != null ? insight.cls : '—'}</span>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
getSectionType,
|
|
3
|
+
isSectionEffectivelyVisible,
|
|
4
|
+
type PageSection,
|
|
5
|
+
} from '@prenta/core/page-sections'
|
|
2
6
|
import { analyzeContent } from '@prenta/core/seo/analysis'
|
|
3
7
|
|
|
4
8
|
const HTML_LIKE = /<\/?[a-z][\s\S]*>/i
|
|
@@ -8,7 +12,7 @@ export function buildEditorContentHtml(sections: PageSection[], body = ''): stri
|
|
|
8
12
|
const parts: string[] = []
|
|
9
13
|
if (body.trim()) parts.push(body)
|
|
10
14
|
for (const section of sections) {
|
|
11
|
-
if (section
|
|
15
|
+
if (!isSectionEffectivelyVisible(section)) continue
|
|
12
16
|
const chunk = extractSectionContentHtml(section)
|
|
13
17
|
if (chunk) parts.push(chunk)
|
|
14
18
|
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
updateSectionMeta,
|
|
33
33
|
coerceSections as coerceSectionsCore,
|
|
34
34
|
validateSectionContent,
|
|
35
|
+
isSectionEffectivelyVisible,
|
|
35
36
|
isPageBuilderTree,
|
|
36
37
|
layoutTreeToSections,
|
|
37
38
|
type PageSection,
|
|
@@ -135,7 +136,7 @@ export function validatePage(page: EditorPage, forPublish = false): ValidationRe
|
|
|
135
136
|
if (slugError) errors.push(slugError)
|
|
136
137
|
|
|
137
138
|
if (forPublish) {
|
|
138
|
-
const visible = page.sections.filter((s) => s
|
|
139
|
+
const visible = page.sections.filter((s) => isSectionEffectivelyVisible(s))
|
|
139
140
|
if (visible.length === 0) {
|
|
140
141
|
errors.push('Add at least one visible section before publishing.')
|
|
141
142
|
}
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
coercePostHeader,
|
|
41
41
|
createPostTemplate,
|
|
42
42
|
validateSectionContent,
|
|
43
|
+
isSectionEffectivelyVisible,
|
|
43
44
|
type PageSection,
|
|
44
45
|
type PostHeaderConfig,
|
|
45
46
|
} from '@prenta/core/page-sections'
|
|
@@ -164,7 +165,7 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
|
|
|
164
165
|
if (slugError) errors.push(slugError)
|
|
165
166
|
|
|
166
167
|
if (forPublish) {
|
|
167
|
-
const visible = post.sections.filter((s) => s
|
|
168
|
+
const visible = post.sections.filter((s) => isSectionEffectivelyVisible(s))
|
|
168
169
|
// Unmanaged (externally-managed) sections have no registered field schema,
|
|
169
170
|
// so they can't be content-validated here and must not block publish.
|
|
170
171
|
for (const section of visible.filter((s) => !s.unmanaged)) {
|
|
@@ -187,7 +188,9 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
|
|
|
187
188
|
export function validateTemplate(template: EditorPostTemplate): ValidationResult {
|
|
188
189
|
const errors: string[] = []
|
|
189
190
|
const warnings: string[] = []
|
|
190
|
-
for (const section of template.sections.filter(
|
|
191
|
+
for (const section of template.sections.filter(
|
|
192
|
+
(s) => isSectionEffectivelyVisible(s) && !s.unmanaged,
|
|
193
|
+
)) {
|
|
191
194
|
const def = getSectionType(section.sectionType)
|
|
192
195
|
const label = section.name || def?.name || 'Section'
|
|
193
196
|
const result = validateSectionContent(section.sectionType, section.content, section.settings)
|