@prenta/admin 0.97.5 → 0.98.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 (53) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/__tests__/components/seo-score-bands.test.js +0 -7
  3. package/dist/__tests__/components/seo-score-bands.test.js.map +1 -1
  4. package/dist/__tests__/lib/page-audit-input.test.js +77 -1
  5. package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
  6. package/dist/__tests__/lib/seo-service-errors.test.js +34 -3
  7. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  8. package/dist/__tests__/views/page-builder-seo-panel.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/page-builder-seo-panel.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/page-builder-seo-panel.render.test.js +65 -0
  11. package/dist/__tests__/views/page-builder-seo-panel.render.test.js.map +1 -0
  12. package/dist/__tests__/views/seo-audit-tab.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/seo-audit-tab.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/seo-audit-tab.render.test.js +100 -0
  15. package/dist/__tests__/views/seo-audit-tab.render.test.js.map +1 -0
  16. package/dist/__tests__/views/seo-technical-tab.render.test.d.ts +2 -0
  17. package/dist/__tests__/views/seo-technical-tab.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/views/seo-technical-tab.render.test.js +121 -0
  19. package/dist/__tests__/views/seo-technical-tab.render.test.js.map +1 -0
  20. package/dist/lib/page-audit-input.d.ts +14 -1
  21. package/dist/lib/page-audit-input.d.ts.map +1 -1
  22. package/dist/lib/page-audit-input.js +19 -0
  23. package/dist/lib/page-audit-input.js.map +1 -1
  24. package/dist/lib/seo-service.d.ts +30 -1
  25. package/dist/lib/seo-service.d.ts.map +1 -1
  26. package/dist/lib/seo-service.js.map +1 -1
  27. package/dist/prenta-admin.css +1 -1
  28. package/dist/views/page-builder/ContextPanel.d.ts.map +1 -1
  29. package/dist/views/page-builder/ContextPanel.js +1 -1
  30. package/dist/views/page-builder/ContextPanel.js.map +1 -1
  31. package/dist/views/page-builder/SEOPanel.d.ts +1 -2
  32. package/dist/views/page-builder/SEOPanel.d.ts.map +1 -1
  33. package/dist/views/page-builder/SEOPanel.js +10 -75
  34. package/dist/views/page-builder/SEOPanel.js.map +1 -1
  35. package/dist/views/seo/AuditTab.d.ts.map +1 -1
  36. package/dist/views/seo/AuditTab.js +3 -2
  37. package/dist/views/seo/AuditTab.js.map +1 -1
  38. package/dist/views/seo/TechnicalTab.d.ts.map +1 -1
  39. package/dist/views/seo/TechnicalTab.js +5 -0
  40. package/dist/views/seo/TechnicalTab.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/__tests__/components/seo-score-bands.test.ts +0 -7
  43. package/src/__tests__/lib/page-audit-input.test.ts +91 -1
  44. package/src/__tests__/lib/seo-service-errors.test.ts +55 -1
  45. package/src/__tests__/views/page-builder-seo-panel.render.test.tsx +77 -0
  46. package/src/__tests__/views/seo-audit-tab.render.test.tsx +113 -0
  47. package/src/__tests__/views/seo-technical-tab.render.test.tsx +156 -0
  48. package/src/lib/page-audit-input.ts +36 -1
  49. package/src/lib/seo-service.ts +30 -2
  50. package/src/views/page-builder/ContextPanel.tsx +0 -1
  51. package/src/views/page-builder/SEOPanel.tsx +40 -282
  52. package/src/views/seo/AuditTab.tsx +60 -27
  53. package/src/views/seo/TechnicalTab.tsx +5 -0
@@ -0,0 +1,77 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { render, screen } from '@testing-library/react'
4
+ import type { PageNode } from '@prenta/core'
5
+ import { BuilderSEOPanel } from '../../views/page-builder/SEOPanel.js'
6
+
7
+ const tree = {
8
+ id: 'page-1',
9
+ type: 'page',
10
+ children: [
11
+ {
12
+ id: 'sec-1',
13
+ type: 'section',
14
+ settings: {},
15
+ children: [
16
+ {
17
+ id: 'row-1',
18
+ type: 'row',
19
+ settings: {},
20
+ children: [
21
+ {
22
+ id: 'col-1',
23
+ type: 'column',
24
+ settings: { width: 12 },
25
+ children: [
26
+ {
27
+ id: 'blk-hero',
28
+ type: 'block',
29
+ settings: { blockType: 'hero' },
30
+ data: {
31
+ title: 'Welcome to Our Service',
32
+ image: { url: '/hero.webp', alt: 'Hero banner' },
33
+ },
34
+ },
35
+ ],
36
+ },
37
+ ],
38
+ },
39
+ ],
40
+ },
41
+ ],
42
+ } as PageNode
43
+
44
+ const pageSettings = {
45
+ title: 'Home',
46
+ slug: 'home',
47
+ metaTitle: 'Welcome to Our Service',
48
+ metaDescription: 'The public description.',
49
+ }
50
+
51
+ describe('BuilderSEOPanel', () => {
52
+ it('grades the live tree with the shared page audit panel and keeps meta fields', () => {
53
+ render(
54
+ <BuilderSEOPanel tree={tree} pageSettings={pageSettings} onPageSettingsChange={vi.fn()} />,
55
+ )
56
+
57
+ expect(screen.getByRole('img', { name: /SEO score:/i })).toBeTruthy()
58
+ expect(screen.getByRole('img', { name: /GEO score:/i })).toBeTruthy()
59
+ expect(screen.getByText('SEO checks')).toBeTruthy()
60
+ expect(screen.getByText('GEO checks')).toBeTruthy()
61
+ expect(screen.getByLabelText('Meta Title')).toBeTruthy()
62
+ expect(screen.queryByText('Readability')).toBeNull()
63
+ expect(screen.queryByText('Block Hints')).toBeNull()
64
+ })
65
+
66
+ it('keeps the empty-tree copy when the canvas has no children', () => {
67
+ render(
68
+ <BuilderSEOPanel
69
+ tree={{ id: 'page-1', type: 'page', children: [] } as PageNode}
70
+ pageSettings={pageSettings}
71
+ onPageSettingsChange={vi.fn()}
72
+ />,
73
+ )
74
+ expect(screen.getByText('Add content to your page to see SEO analysis')).toBeTruthy()
75
+ expect(screen.queryByRole('img', { name: /SEO score:/i })).toBeNull()
76
+ })
77
+ })
@@ -0,0 +1,113 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { render, screen, waitFor } from '@testing-library/react'
4
+
5
+ const baseRun = {
6
+ id: 'audit-1',
7
+ status: 'COMPLETED',
8
+ scope: 'full',
9
+ startedAt: '2026-08-11T12:00:00.000Z',
10
+ finishedAt: '2026-08-11T12:00:01.000Z',
11
+ siteScoreBefore: 70,
12
+ siteScoreAfter: 82,
13
+ pagesScanned: 10,
14
+ issuesFound: 3,
15
+ criticalCount: 0,
16
+ warningCount: 2,
17
+ infoCount: 1,
18
+ errorMessage: null,
19
+ }
20
+
21
+ let latestRun: Record<string, unknown> = { ...baseRun }
22
+
23
+ const cmsApi = vi.fn(async (endpoint: string) => {
24
+ if (endpoint === '/seo/audits') {
25
+ return { data: { runs: [latestRun] }, status: 200 }
26
+ }
27
+ if (endpoint.startsWith('/seo/issues')) {
28
+ return { data: { issues: [] }, status: 200 }
29
+ }
30
+ return { data: {}, status: 200 }
31
+ })
32
+
33
+ vi.mock('../../lib/api.js', () => ({
34
+ cmsApi: (endpoint: string) => cmsApi(endpoint),
35
+ }))
36
+
37
+ vi.mock('sonner', () => ({
38
+ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), message: vi.fn() },
39
+ }))
40
+
41
+ const { AuditTab } = await import('../../views/seo/AuditTab.js')
42
+
43
+ beforeEach(() => {
44
+ cmsApi.mockClear()
45
+ latestRun = { ...baseRun }
46
+ })
47
+
48
+ describe('AuditTab rendered audit coverage', () => {
49
+ it('shows safe coverage details and a warning for partial runs', async () => {
50
+ latestRun = {
51
+ ...baseRun,
52
+ metadata: {
53
+ renderedAudit: {
54
+ enabled: true,
55
+ originSource: 'override',
56
+ verified: 8,
57
+ unverified: 2,
58
+ notAttempted: 0,
59
+ partial: true,
60
+ bytesRead: 2048,
61
+ durationMs: 50,
62
+ reasons: [{ reason: 'timeout', count: 2 }],
63
+ },
64
+ },
65
+ }
66
+
67
+ render(<AuditTab />)
68
+
69
+ expect(await screen.findByText('8 verified')).toBeTruthy()
70
+ expect(screen.getByText('2 unverified')).toBeTruthy()
71
+ const warning = screen.getByRole('status')
72
+ expect(warning.textContent).toContain('The score covers 8 verified pages.')
73
+ expect(warning.textContent).toContain('their prior rendered-dependent issues were preserved.')
74
+ expect(screen.queryByText(/timeout/i)).toBeNull()
75
+ expect(screen.queryByText(/https?:\/\//i)).toBeNull()
76
+ expect(screen.queryByText(/transport error/i)).toBeNull()
77
+ })
78
+
79
+ it('does not show a partial warning for complete coverage', async () => {
80
+ latestRun = {
81
+ ...baseRun,
82
+ metadata: {
83
+ renderedAudit: {
84
+ enabled: true,
85
+ originSource: 'config',
86
+ verified: 10,
87
+ unverified: 0,
88
+ notAttempted: 0,
89
+ partial: false,
90
+ bytesRead: 4096,
91
+ durationMs: 40,
92
+ reasons: [],
93
+ },
94
+ },
95
+ }
96
+
97
+ render(<AuditTab />)
98
+
99
+ expect(await screen.findByText('10 verified')).toBeTruthy()
100
+ expect(screen.getByText('0 unverified')).toBeTruthy()
101
+ expect(screen.queryByText('Partial rendered audit')).toBeNull()
102
+ expect(screen.queryByRole('status')).toBeNull()
103
+ })
104
+
105
+ it('renders legacy audit runs without coverage or warnings', async () => {
106
+ render(<AuditTab />)
107
+
108
+ await waitFor(() => expect(screen.getByText('10')).toBeTruthy())
109
+ expect(screen.queryByText(/verified$/i)).toBeNull()
110
+ expect(screen.queryByText('Partial rendered audit')).toBeNull()
111
+ expect(screen.queryByRole('status')).toBeNull()
112
+ })
113
+ })
@@ -0,0 +1,156 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { toast } from 'sonner'
5
+
6
+ const initialCrawlSettings = {
7
+ indexEntireSite: true,
8
+ includeImagesInSitemap: true,
9
+ includePostsInSitemap: true,
10
+ sitemapEnabled: true,
11
+ autoRegenerateSitemapOnPublish: true,
12
+ pingSearchEnginesOnPublish: true,
13
+ auditRenderedHtml: true,
14
+ }
15
+
16
+ let crawlSettings = { ...initialCrawlSettings }
17
+ let failNextPut = false
18
+ let pendingPut: Promise<{ data?: unknown; error?: string }> | null = null
19
+
20
+ const cmsApi = vi.fn(async (endpoint: string, options?: RequestInit) => {
21
+ const method = options?.method ?? 'GET'
22
+ if (endpoint === '/seo/crawl-settings' && method === 'GET') {
23
+ return { data: { ...crawlSettings }, status: 200 }
24
+ }
25
+ if (endpoint === '/seo/crawl-settings' && method === 'PUT') {
26
+ if (pendingPut) await pendingPut
27
+ if (failNextPut) {
28
+ failNextPut = false
29
+ return { error: 'Unable to save crawl settings.', status: 500 }
30
+ }
31
+ crawlSettings = {
32
+ ...crawlSettings,
33
+ ...(JSON.parse(String(options?.body)) as Partial<typeof initialCrawlSettings>),
34
+ }
35
+ return { data: { ...crawlSettings }, status: 200 }
36
+ }
37
+ if (endpoint === '/seo/sitemap/status') {
38
+ return {
39
+ data: {
40
+ sitemapUrl: 'https://example.test/sitemap.xml',
41
+ lastGeneratedAt: null,
42
+ pagesIncluded: 10,
43
+ pagesExcluded: 0,
44
+ autoRegenerateOnPublish: true,
45
+ errors: [],
46
+ warnings: [],
47
+ },
48
+ status: 200,
49
+ }
50
+ }
51
+ if (endpoint === '/seo/robots') {
52
+ return { data: { body: 'User-agent: *\nAllow: /', generated: true }, status: 200 }
53
+ }
54
+ if (endpoint === '/ai-crawlers') {
55
+ return {
56
+ data: { enabled: false, rows: [], totals: { hits: 0, bots: 0, honeypot: 0 } },
57
+ status: 200,
58
+ }
59
+ }
60
+ return { data: {}, status: 200 }
61
+ })
62
+
63
+ vi.mock('../../lib/api.js', () => ({
64
+ cmsApi: (endpoint: string, options?: RequestInit) => cmsApi(endpoint, options),
65
+ }))
66
+
67
+ vi.mock('sonner', () => ({
68
+ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), message: vi.fn() },
69
+ }))
70
+
71
+ const { TechnicalTab } = await import('../../views/seo/TechnicalTab.js')
72
+
73
+ beforeEach(() => {
74
+ cmsApi.mockClear()
75
+ vi.mocked(toast.error).mockClear()
76
+ crawlSettings = { ...initialCrawlSettings }
77
+ failNextPut = false
78
+ pendingPut = null
79
+ })
80
+
81
+ describe('TechnicalTab rendered audit setting', () => {
82
+ it('renders the accessible setting and persists changes', async () => {
83
+ render(<TechnicalTab />)
84
+
85
+ const renderedAudit = await screen.findByRole('switch', {
86
+ name: /Audit rendered public HTML/i,
87
+ description: 'Fetch public page output so audits grade crawler-visible headings and content.',
88
+ })
89
+ expect(renderedAudit.getAttribute('aria-checked')).toBe('true')
90
+
91
+ fireEvent.click(renderedAudit)
92
+
93
+ await waitFor(() =>
94
+ expect(cmsApi).toHaveBeenCalledWith(
95
+ '/seo/crawl-settings',
96
+ expect.objectContaining({
97
+ method: 'PUT',
98
+ body: JSON.stringify({ auditRenderedHtml: false }),
99
+ }),
100
+ ),
101
+ )
102
+ })
103
+
104
+ it('disables crawl switches while a setting is saving', async () => {
105
+ let resolvePut: (() => void) | undefined
106
+ pendingPut = new Promise((resolve) => {
107
+ resolvePut = () => resolve({})
108
+ })
109
+ render(<TechnicalTab />)
110
+
111
+ fireEvent.click(await screen.findByRole('switch', { name: /Audit rendered public HTML/i }))
112
+
113
+ await waitFor(() => {
114
+ for (const toggle of screen.getAllByRole('switch')) {
115
+ expect((toggle as HTMLButtonElement).disabled).toBe(true)
116
+ }
117
+ })
118
+
119
+ resolvePut?.()
120
+ await waitFor(() =>
121
+ expect(
122
+ (
123
+ screen.getByRole('switch', {
124
+ name: /Audit rendered public HTML/i,
125
+ }) as HTMLButtonElement
126
+ ).disabled,
127
+ ).toBe(false),
128
+ )
129
+ })
130
+
131
+ it('reports save failures and refetches the server value', async () => {
132
+ failNextPut = true
133
+ render(<TechnicalTab />)
134
+
135
+ const renderedAudit = await screen.findByRole('switch', {
136
+ name: /Audit rendered public HTML/i,
137
+ })
138
+ const getCallsBeforeSave = cmsApi.mock.calls.filter(
139
+ ([endpoint, options]) =>
140
+ endpoint === '/seo/crawl-settings' &&
141
+ ((options as RequestInit | undefined)?.method ?? 'GET') === 'GET',
142
+ ).length
143
+
144
+ fireEvent.click(renderedAudit)
145
+
146
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Unable to save crawl settings.'))
147
+ await waitFor(() => expect(renderedAudit.getAttribute('aria-checked')).toBe('true'))
148
+ expect(
149
+ cmsApi.mock.calls.filter(
150
+ ([endpoint, options]) =>
151
+ endpoint === '/seo/crawl-settings' &&
152
+ ((options as RequestInit | undefined)?.method ?? 'GET') === 'GET',
153
+ ).length,
154
+ ).toBeGreaterThan(getCallsBeforeSave)
155
+ })
156
+ })
@@ -1,5 +1,5 @@
1
1
  import { resolveRenderedMetaTitle } from '@prenta/core/client'
2
- import type { PageSection } from '@prenta/core/page-sections'
2
+ import { layoutTreeToSections, type PageSection } from '@prenta/core/page-sections'
3
3
  import type { PageAuditInput } from '@prenta/core/seo/page-audit'
4
4
  import type { SeoContentRecord } from './seo-service.js'
5
5
 
@@ -102,3 +102,38 @@ export function metaFromRecord(record: SeoContentRecord): AuditMetaSource {
102
102
  hasOgImage: Boolean(record.ogImage),
103
103
  }
104
104
  }
105
+
106
+ export interface BuilderAuditSource {
107
+ tree: unknown
108
+ pageSettings: {
109
+ title: string
110
+ slug: string
111
+ metaTitle?: string
112
+ metaDescription?: string
113
+ ogImage?: string
114
+ focusKeyphrase?: string
115
+ schemaType?: string
116
+ }
117
+ }
118
+
119
+ export function buildPageAuditInputFromBuilder({
120
+ tree,
121
+ pageSettings,
122
+ }: BuilderAuditSource): PageAuditInput {
123
+ return buildPageAuditInput({
124
+ sections: layoutTreeToSections(tree),
125
+ meta: {
126
+ metaTitle: pageSettings.metaTitle ?? '',
127
+ metaDescription: pageSettings.metaDescription ?? '',
128
+ focusKeyword: pageSettings.focusKeyphrase ?? '',
129
+ structuredDataType: pageSettings.schemaType ?? '',
130
+ noindex: false,
131
+ nosnippet: false,
132
+ llmsInclude: 'AUTO',
133
+ hasOgImage: Boolean(pageSettings.ogImage),
134
+ },
135
+ slug: pageSettings.slug,
136
+ record: null,
137
+ fallbackTitle: pageSettings.title,
138
+ })
139
+ }
@@ -299,6 +299,18 @@ export function applyContentSeoQuery(
299
299
 
300
300
  export type SeoAuditScope = 'full' | 'content' | 'technical' | 'redirect'
301
301
 
302
+ export interface RenderedAuditSummary {
303
+ enabled: boolean
304
+ originSource: 'override' | 'config' | null
305
+ verified: number
306
+ unverified: number
307
+ notAttempted: number
308
+ partial: boolean
309
+ bytesRead: number
310
+ durationMs: number
311
+ reasons: Array<{ reason: string; count: number }>
312
+ }
313
+
302
314
  export interface SeoAuditRun {
303
315
  id: string
304
316
  status: 'RUNNING' | 'COMPLETED' | 'FAILED'
@@ -313,12 +325,27 @@ export interface SeoAuditRun {
313
325
  warningCount: number
314
326
  infoCount: number
315
327
  errorMessage: string | null
328
+ metadata: {
329
+ renderedAudit?: RenderedAuditSummary
330
+ } | null
331
+ }
332
+
333
+ export interface SeoAuditRunResponse {
334
+ id: string
335
+ status: 'COMPLETED'
336
+ siteScoreAfter: number
337
+ issuesFound: number
338
+ criticalCount: number
339
+ warningCount: number
340
+ infoCount: number
341
+ pagesScanned: number
342
+ renderedAudit: RenderedAuditSummary
316
343
  }
317
344
 
318
345
  export async function runSeoAudit(
319
346
  scope: SeoAuditScope = 'full',
320
- ): Promise<{ run?: SeoAuditRun; error?: string }> {
321
- const res = await cmsApi<SeoAuditRun>('/seo/audit', {
347
+ ): Promise<{ run?: SeoAuditRunResponse; error?: string }> {
348
+ const res = await cmsApi<SeoAuditRunResponse>('/seo/audit', {
322
349
  method: 'POST',
323
350
  body: JSON.stringify({ scope }),
324
351
  })
@@ -662,6 +689,7 @@ export interface CrawlSettings {
662
689
  sitemapEnabled: boolean
663
690
  autoRegenerateSitemapOnPublish: boolean
664
691
  pingSearchEnginesOnPublish: boolean
692
+ auditRenderedHtml: boolean
665
693
  }
666
694
 
667
695
  export async function fetchSitemapStatus(): Promise<SitemapStatus | null> {
@@ -132,7 +132,6 @@ export function ContextPanel({
132
132
  tree={tree}
133
133
  pageSettings={pageSettings}
134
134
  onPageSettingsChange={onPageSettingsChange}
135
- selectedNodeId={selectedNode?.id ?? null}
136
135
  />
137
136
  </Tabs.Content>
138
137