@actuate-media/cms-admin 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +13 -9
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/layout/sidebar-posts.test.js +6 -4
  5. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -1
  6. package/dist/__tests__/lib/seo-service-errors.test.d.ts +2 -0
  7. package/dist/__tests__/lib/seo-service-errors.test.d.ts.map +1 -0
  8. package/dist/__tests__/lib/seo-service-errors.test.js +46 -0
  9. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -0
  10. package/dist/__tests__/views/use-seo-resource.test.d.ts +2 -0
  11. package/dist/__tests__/views/use-seo-resource.test.d.ts.map +1 -0
  12. package/dist/__tests__/views/use-seo-resource.test.js +56 -0
  13. package/dist/__tests__/views/use-seo-resource.test.js.map +1 -0
  14. package/dist/actuate-admin.css +1 -1
  15. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  16. package/dist/components/Breadcrumbs.js +4 -1
  17. package/dist/components/Breadcrumbs.js.map +1 -1
  18. package/dist/components/seo/Drawer.d.ts +11 -0
  19. package/dist/components/seo/Drawer.d.ts.map +1 -0
  20. package/dist/components/seo/Drawer.js +17 -0
  21. package/dist/components/seo/Drawer.js.map +1 -0
  22. package/dist/components/seo/RedirectEditorDrawer.d.ts +9 -0
  23. package/dist/components/seo/RedirectEditorDrawer.d.ts.map +1 -0
  24. package/dist/components/seo/RedirectEditorDrawer.js +94 -0
  25. package/dist/components/seo/RedirectEditorDrawer.js.map +1 -0
  26. package/dist/components/seo/SeoCopilotDrawer.d.ts +10 -0
  27. package/dist/components/seo/SeoCopilotDrawer.d.ts.map +1 -0
  28. package/dist/components/seo/SeoCopilotDrawer.js +34 -0
  29. package/dist/components/seo/SeoCopilotDrawer.js.map +1 -0
  30. package/dist/components/seo/SeoEditorDrawer.d.ts +8 -0
  31. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -0
  32. package/dist/components/seo/SeoEditorDrawer.js +120 -0
  33. package/dist/components/seo/SeoEditorDrawer.js.map +1 -0
  34. package/dist/components/seo/SeoIssueFixDrawer.d.ts +8 -0
  35. package/dist/components/seo/SeoIssueFixDrawer.d.ts.map +1 -0
  36. package/dist/components/seo/SeoIssueFixDrawer.js +111 -0
  37. package/dist/components/seo/SeoIssueFixDrawer.js.map +1 -0
  38. package/dist/components/seo/primitives.d.ts +53 -0
  39. package/dist/components/seo/primitives.d.ts.map +1 -0
  40. package/dist/components/seo/primitives.js +101 -0
  41. package/dist/components/seo/primitives.js.map +1 -0
  42. package/dist/layout/Sidebar.d.ts.map +1 -1
  43. package/dist/layout/Sidebar.js +8 -5
  44. package/dist/layout/Sidebar.js.map +1 -1
  45. package/dist/lib/seo-service.d.ts +322 -0
  46. package/dist/lib/seo-service.d.ts.map +1 -0
  47. package/dist/lib/seo-service.js +331 -0
  48. package/dist/lib/seo-service.js.map +1 -0
  49. package/dist/router/index.d.ts.map +1 -1
  50. package/dist/router/index.js +4 -1
  51. package/dist/router/index.js.map +1 -1
  52. package/dist/views/ApiKeys.d.ts +4 -1
  53. package/dist/views/ApiKeys.d.ts.map +1 -1
  54. package/dist/views/ApiKeys.js +5 -3
  55. package/dist/views/ApiKeys.js.map +1 -1
  56. package/dist/views/Dashboard.js +2 -2
  57. package/dist/views/Dashboard.js.map +1 -1
  58. package/dist/views/MediaBrowser.d.ts.map +1 -1
  59. package/dist/views/MediaBrowser.js +36 -25
  60. package/dist/views/MediaBrowser.js.map +1 -1
  61. package/dist/views/Posts/NewPostTypeInstructions.js +2 -2
  62. package/dist/views/Posts/post-type-presets.d.ts +5 -0
  63. package/dist/views/Posts/post-type-presets.d.ts.map +1 -1
  64. package/dist/views/Posts/post-type-presets.js +10 -64
  65. package/dist/views/Posts/post-type-presets.js.map +1 -1
  66. package/dist/views/SEO.d.ts.map +1 -1
  67. package/dist/views/SEO.js +89 -132
  68. package/dist/views/SEO.js.map +1 -1
  69. package/dist/views/Settings.d.ts +4 -1
  70. package/dist/views/Settings.d.ts.map +1 -1
  71. package/dist/views/Settings.js +45 -6
  72. package/dist/views/Settings.js.map +1 -1
  73. package/dist/views/Users.d.ts +4 -1
  74. package/dist/views/Users.d.ts.map +1 -1
  75. package/dist/views/Users.js +5 -3
  76. package/dist/views/Users.js.map +1 -1
  77. package/dist/views/seo/AuditTab.d.ts +6 -0
  78. package/dist/views/seo/AuditTab.d.ts.map +1 -0
  79. package/dist/views/seo/AuditTab.js +57 -0
  80. package/dist/views/seo/AuditTab.js.map +1 -0
  81. package/dist/views/seo/ContentTab.d.ts +5 -0
  82. package/dist/views/seo/ContentTab.d.ts.map +1 -0
  83. package/dist/views/seo/ContentTab.js +27 -0
  84. package/dist/views/seo/ContentTab.js.map +1 -0
  85. package/dist/views/seo/OverviewTab.d.ts +8 -0
  86. package/dist/views/seo/OverviewTab.d.ts.map +1 -0
  87. package/dist/views/seo/OverviewTab.js +35 -0
  88. package/dist/views/seo/OverviewTab.js.map +1 -0
  89. package/dist/views/seo/RedirectsTab.d.ts +2 -0
  90. package/dist/views/seo/RedirectsTab.d.ts.map +1 -0
  91. package/dist/views/seo/RedirectsTab.js +130 -0
  92. package/dist/views/seo/RedirectsTab.js.map +1 -0
  93. package/dist/views/seo/TechnicalTab.d.ts +2 -0
  94. package/dist/views/seo/TechnicalTab.d.ts.map +1 -0
  95. package/dist/views/seo/TechnicalTab.js +105 -0
  96. package/dist/views/seo/TechnicalTab.js.map +1 -0
  97. package/dist/views/seo/useSeoResource.d.ts +8 -0
  98. package/dist/views/seo/useSeoResource.d.ts.map +1 -0
  99. package/dist/views/seo/useSeoResource.js +55 -0
  100. package/dist/views/seo/useSeoResource.js.map +1 -0
  101. package/package.json +2 -2
  102. package/src/AdminRoot.tsx +13 -9
  103. package/src/__tests__/layout/sidebar-posts.test.ts +6 -4
  104. package/src/__tests__/lib/seo-service-errors.test.ts +60 -0
  105. package/src/__tests__/views/use-seo-resource.test.tsx +68 -0
  106. package/src/components/Breadcrumbs.tsx +4 -1
  107. package/src/components/seo/Drawer.tsx +65 -0
  108. package/src/components/seo/RedirectEditorDrawer.tsx +235 -0
  109. package/src/components/seo/SeoCopilotDrawer.tsx +76 -0
  110. package/src/components/seo/SeoEditorDrawer.tsx +359 -0
  111. package/src/components/seo/SeoIssueFixDrawer.tsx +244 -0
  112. package/src/components/seo/primitives.tsx +240 -0
  113. package/src/layout/Sidebar.tsx +7 -10
  114. package/src/lib/seo-service.ts +660 -0
  115. package/src/router/index.ts +4 -1
  116. package/src/views/ApiKeys.tsx +10 -5
  117. package/src/views/Dashboard.tsx +2 -2
  118. package/src/views/MediaBrowser.tsx +89 -67
  119. package/src/views/Posts/NewPostTypeInstructions.tsx +2 -2
  120. package/src/views/Posts/post-type-presets.ts +15 -64
  121. package/src/views/SEO.tsx +186 -863
  122. package/src/views/Settings.tsx +93 -40
  123. package/src/views/Users.tsx +10 -4
  124. package/src/views/seo/AuditTab.tsx +229 -0
  125. package/src/views/seo/ContentTab.tsx +164 -0
  126. package/src/views/seo/OverviewTab.tsx +257 -0
  127. package/src/views/seo/RedirectsTab.tsx +398 -0
  128. package/src/views/seo/TechnicalTab.tsx +315 -0
  129. package/src/views/seo/useSeoResource.ts +66 -0
@@ -0,0 +1,660 @@
1
+ /**
2
+ * SEO service — typed client over the cms-core SEO REST endpoints.
3
+ *
4
+ * Every SEO admin component reads/writes through this module (never raw
5
+ * `cmsApi` / `fetch`), so the network contract lives in one place. Fetchers
6
+ * return typed data with safe fallbacks; mutations return `{ error?: string }`.
7
+ *
8
+ * Endpoint paths here are the contract implemented by cms-core
9
+ * `packages/cms-core/src/api/handlers.ts` across phases 2-3 and 6.
10
+ */
11
+ import { cmsApi } from './api.js'
12
+
13
+ /**
14
+ * `cmsApi` never throws — it returns `{ error, status }` on a network/HTTP
15
+ * failure. Read (GET) fetchers below must re-surface that as a thrown error so
16
+ * `useSeoResource` can distinguish "request failed" from "empty result".
17
+ * Without this, a failed `/seo/overview` would fall through to the empty
18
+ * fallback (a fake score of 0 / grade "Critical") and the error/retry UI would
19
+ * be permanently unreachable. Mutations intentionally return `{ error }` and do
20
+ * not use this.
21
+ */
22
+ function throwIfError(res: { error?: string }): void {
23
+ if (res.error) throw new Error(res.error)
24
+ }
25
+
26
+ // ─── Shared enums ────────────────────────────────────────────────────
27
+
28
+ export type SeoGrade = 'good' | 'fair' | 'poor' | 'critical'
29
+ export type SeoSeverity = 'critical' | 'warning' | 'info'
30
+ export type SeoIssueStatus = 'open' | 'ignored' | 'resolved' | 'recurring'
31
+ export type SeoIssueCategory =
32
+ | 'content'
33
+ | 'technical'
34
+ | 'redirects'
35
+ | 'media'
36
+ | 'performance'
37
+ | 'schema'
38
+ | 'social'
39
+ | 'indexing'
40
+ export type RedirectStatusCode = 301 | 302 | 307 | 308 | 410
41
+
42
+ // ─── Overview ────────────────────────────────────────────────────────
43
+
44
+ export interface SeoSiteScore {
45
+ score: number
46
+ grade: SeoGrade
47
+ /** Change vs the previous audit run, e.g. +6. */
48
+ delta: number | null
49
+ breakdown: { category: string; score: number; weight: number; contribution: number }[]
50
+ }
51
+
52
+ export interface SeoMetric {
53
+ label: string
54
+ value: string
55
+ helper: string
56
+ /** Optional trend direction for the helper colour. */
57
+ trend?: 'up' | 'down' | 'flat'
58
+ }
59
+
60
+ export interface SeoIssue {
61
+ id: string
62
+ auditRunId: string | null
63
+ entityType: string | null
64
+ entityId: string | null
65
+ entityTitle: string | null
66
+ url: string | null
67
+ severity: SeoSeverity
68
+ category: SeoIssueCategory
69
+ type: string
70
+ title: string
71
+ description: string | null
72
+ impact: string | null
73
+ recommendation: string | null
74
+ status: SeoIssueStatus
75
+ fixable: boolean
76
+ fixActionType: string | null
77
+ relatedEntityIds: string[]
78
+ source: string
79
+ firstDetectedAt: string | null
80
+ lastDetectedAt: string | null
81
+ resolvedAt: string | null
82
+ }
83
+
84
+ export interface QuickWin {
85
+ id: string
86
+ title: string
87
+ explanation: string
88
+ impact: 'High' | 'Medium' | 'Low'
89
+ issueId: string | null
90
+ fixActionType: string | null
91
+ }
92
+
93
+ export interface SearchPerformanceRow {
94
+ url: string
95
+ title: string
96
+ impressions: number
97
+ clicks: number
98
+ ctr: number
99
+ averagePosition: number
100
+ }
101
+
102
+ export interface SeoOverview {
103
+ siteScore: SeoSiteScore
104
+ metrics: SeoMetric[]
105
+ issues: SeoIssue[]
106
+ issuesSummary: { total: number; critical: number; warning: number; info: number }
107
+ quickWins: QuickWin[]
108
+ searchPerformance: SearchPerformanceRow[]
109
+ searchConsoleConnected: boolean
110
+ lastAuditRunId: string | null
111
+ }
112
+
113
+ const EMPTY_OVERVIEW: SeoOverview = {
114
+ siteScore: { score: 0, grade: 'critical', delta: null, breakdown: [] },
115
+ metrics: [],
116
+ issues: [],
117
+ issuesSummary: { total: 0, critical: 0, warning: 0, info: 0 },
118
+ quickWins: [],
119
+ searchPerformance: [],
120
+ searchConsoleConnected: false,
121
+ lastAuditRunId: null,
122
+ }
123
+
124
+ export async function fetchSeoOverview(): Promise<SeoOverview> {
125
+ const res = await cmsApi<SeoOverview>('/seo/overview')
126
+ throwIfError(res)
127
+ return res.data ?? EMPTY_OVERVIEW
128
+ }
129
+
130
+ export async function fetchSearchPerformance(
131
+ params: {
132
+ limit?: number
133
+ } = {},
134
+ ): Promise<SearchPerformanceRow[]> {
135
+ const qs = params.limit ? `?limit=${params.limit}` : ''
136
+ const res = await cmsApi<{ rows: SearchPerformanceRow[] }>(`/seo/search-performance${qs}`)
137
+ throwIfError(res)
138
+ return res.data?.rows ?? []
139
+ }
140
+
141
+ // ─── Content SEO ─────────────────────────────────────────────────────
142
+
143
+ export interface SeoContentRecord {
144
+ id: string
145
+ entityType: 'page' | 'post'
146
+ entityId: string
147
+ title: string
148
+ url: string
149
+ canonicalUrl: string | null
150
+ metaTitle: string | null
151
+ metaDescription: string | null
152
+ focusKeyword: string | null
153
+ ogTitle: string | null
154
+ ogDescription: string | null
155
+ ogImageId: string | null
156
+ ogImage: string | null
157
+ twitterTitle: string | null
158
+ twitterDescription: string | null
159
+ twitterImageId: string | null
160
+ noindex: boolean
161
+ nofollow: boolean
162
+ structuredDataType: string | null
163
+ structuredData: Record<string, unknown> | null
164
+ seoScore: number
165
+ seoGrade: SeoGrade
166
+ updatedAt: string | null
167
+ }
168
+
169
+ export type ContentSeoFilter = 'all' | 'pages' | 'posts'
170
+
171
+ export async function fetchSeoContentRecords(): Promise<SeoContentRecord[]> {
172
+ const res = await cmsApi<{ records: SeoContentRecord[] }>('/seo/content')
173
+ throwIfError(res)
174
+ return res.data?.records ?? []
175
+ }
176
+
177
+ export async function fetchSeoContentRecord(
178
+ entityType: string,
179
+ entityId: string,
180
+ ): Promise<SeoContentRecord | null> {
181
+ const res = await cmsApi<SeoContentRecord>(
182
+ `/seo/content/${entityType}/${encodeURIComponent(entityId)}`,
183
+ )
184
+ throwIfError(res)
185
+ return res.data ?? null
186
+ }
187
+
188
+ export interface SeoFieldPayload {
189
+ metaTitle?: string
190
+ metaDescription?: string
191
+ canonicalUrl?: string
192
+ focusKeyword?: string
193
+ ogTitle?: string
194
+ ogDescription?: string
195
+ ogImage?: string
196
+ twitterTitle?: string
197
+ twitterDescription?: string
198
+ twitterImage?: string
199
+ noindex?: boolean
200
+ nofollow?: boolean
201
+ structuredDataType?: string
202
+ structuredData?: Record<string, unknown>
203
+ }
204
+
205
+ export async function updateSeoFields(
206
+ entityType: string,
207
+ entityId: string,
208
+ payload: SeoFieldPayload,
209
+ ): Promise<{ error?: string }> {
210
+ const res = await cmsApi(`/seo/content/${entityType}/${encodeURIComponent(entityId)}`, {
211
+ method: 'PUT',
212
+ body: JSON.stringify(payload),
213
+ })
214
+ return res.error ? { error: res.error } : {}
215
+ }
216
+
217
+ /** Pure search/filter over the content records (decoupled from network for tests). */
218
+ export function applyContentSeoQuery(
219
+ records: SeoContentRecord[],
220
+ query: { search?: string; filter?: ContentSeoFilter },
221
+ ): SeoContentRecord[] {
222
+ const search = (query.search ?? '').trim().toLowerCase()
223
+ const filter = query.filter ?? 'all'
224
+ return records.filter((r) => {
225
+ if (filter === 'pages' && r.entityType !== 'page') return false
226
+ if (filter === 'posts' && r.entityType !== 'post') return false
227
+ if (search) {
228
+ const haystack = [
229
+ r.title,
230
+ r.url,
231
+ r.metaTitle ?? '',
232
+ r.metaDescription ?? '',
233
+ String(r.seoScore),
234
+ r.entityType,
235
+ ]
236
+ .join(' ')
237
+ .toLowerCase()
238
+ if (!haystack.includes(search)) return false
239
+ }
240
+ return true
241
+ })
242
+ }
243
+
244
+ // ─── Audit ───────────────────────────────────────────────────────────
245
+
246
+ export type SeoAuditScope = 'full' | 'content' | 'technical' | 'redirect'
247
+
248
+ export interface SeoAuditRun {
249
+ id: string
250
+ status: 'RUNNING' | 'COMPLETED' | 'FAILED'
251
+ scope: string
252
+ startedAt: string
253
+ finishedAt: string | null
254
+ siteScoreBefore: number | null
255
+ siteScoreAfter: number | null
256
+ pagesScanned: number
257
+ issuesFound: number
258
+ criticalCount: number
259
+ warningCount: number
260
+ infoCount: number
261
+ errorMessage: string | null
262
+ }
263
+
264
+ export async function runSeoAudit(
265
+ scope: SeoAuditScope = 'full',
266
+ ): Promise<{ run?: SeoAuditRun; error?: string }> {
267
+ const res = await cmsApi<SeoAuditRun>('/seo/audit', {
268
+ method: 'POST',
269
+ body: JSON.stringify({ scope }),
270
+ })
271
+ if (res.error) return { error: res.error }
272
+ return { run: res.data }
273
+ }
274
+
275
+ export async function fetchSeoAuditRuns(): Promise<SeoAuditRun[]> {
276
+ const res = await cmsApi<{ runs: SeoAuditRun[] }>('/seo/audits')
277
+ throwIfError(res)
278
+ return res.data?.runs ?? []
279
+ }
280
+
281
+ export async function fetchSeoAuditRun(auditRunId: string): Promise<SeoAuditRun | null> {
282
+ const res = await cmsApi<SeoAuditRun>(`/seo/audits/${encodeURIComponent(auditRunId)}`)
283
+ throwIfError(res)
284
+ return res.data ?? null
285
+ }
286
+
287
+ export interface SeoIssueQuery {
288
+ severity?: SeoSeverity | 'all'
289
+ category?: SeoIssueCategory | 'all'
290
+ status?: SeoIssueStatus | 'all'
291
+ auditRunId?: string
292
+ }
293
+
294
+ export async function fetchSeoIssues(query: SeoIssueQuery = {}): Promise<SeoIssue[]> {
295
+ const params = new URLSearchParams()
296
+ if (query.severity && query.severity !== 'all') params.set('severity', query.severity)
297
+ if (query.category && query.category !== 'all') params.set('category', query.category)
298
+ if (query.status && query.status !== 'all') params.set('status', query.status)
299
+ if (query.auditRunId) params.set('auditRunId', query.auditRunId)
300
+ const qs = params.toString()
301
+ const res = await cmsApi<{ issues: SeoIssue[] }>(`/seo/issues${qs ? `?${qs}` : ''}`)
302
+ throwIfError(res)
303
+ return res.data?.issues ?? []
304
+ }
305
+
306
+ export async function fetchSeoIssue(issueId: string): Promise<SeoIssue | null> {
307
+ const res = await cmsApi<SeoIssue>(`/seo/issues/${encodeURIComponent(issueId)}`)
308
+ throwIfError(res)
309
+ return res.data ?? null
310
+ }
311
+
312
+ export async function resolveSeoIssue(issueId: string): Promise<{ error?: string }> {
313
+ const res = await cmsApi(`/seo/issues/${encodeURIComponent(issueId)}/resolve`, { method: 'POST' })
314
+ return res.error ? { error: res.error } : {}
315
+ }
316
+
317
+ export async function ignoreSeoIssue(issueId: string): Promise<{ error?: string }> {
318
+ const res = await cmsApi(`/seo/issues/${encodeURIComponent(issueId)}/ignore`, { method: 'POST' })
319
+ return res.error ? { error: res.error } : {}
320
+ }
321
+
322
+ export async function reopenSeoIssue(issueId: string): Promise<{ error?: string }> {
323
+ const res = await cmsApi(`/seo/issues/${encodeURIComponent(issueId)}/reopen`, { method: 'POST' })
324
+ return res.error ? { error: res.error } : {}
325
+ }
326
+
327
+ // ─── Technical (sitemap / robots / crawl) ────────────────────────────
328
+
329
+ export interface SitemapStatus {
330
+ sitemapUrl: string
331
+ lastGeneratedAt: string | null
332
+ pagesIncluded: number
333
+ pagesExcluded: number
334
+ autoRegenerateOnPublish: boolean
335
+ errors: string[]
336
+ warnings: string[]
337
+ }
338
+
339
+ export interface CrawlSettings {
340
+ indexEntireSite: boolean
341
+ includeImagesInSitemap: boolean
342
+ includePostsInSitemap: boolean
343
+ noindexPaginatedArchives: boolean
344
+ noindexTagPages: boolean
345
+ }
346
+
347
+ export async function fetchSitemapStatus(): Promise<SitemapStatus | null> {
348
+ const res = await cmsApi<SitemapStatus>('/seo/sitemap/status')
349
+ throwIfError(res)
350
+ return res.data ?? null
351
+ }
352
+
353
+ export async function regenerateSitemap(): Promise<{ status?: SitemapStatus; error?: string }> {
354
+ const res = await cmsApi<SitemapStatus>('/seo/sitemap/regenerate', { method: 'POST' })
355
+ if (res.error) return { error: res.error }
356
+ return { status: res.data }
357
+ }
358
+
359
+ export async function fetchCrawlSettings(): Promise<CrawlSettings | null> {
360
+ const res = await cmsApi<CrawlSettings>('/seo/crawl-settings')
361
+ throwIfError(res)
362
+ return res.data ?? null
363
+ }
364
+
365
+ export async function updateCrawlSettings(
366
+ payload: Partial<CrawlSettings>,
367
+ ): Promise<{ error?: string }> {
368
+ const res = await cmsApi('/seo/crawl-settings', {
369
+ method: 'PUT',
370
+ body: JSON.stringify(payload),
371
+ })
372
+ return res.error ? { error: res.error } : {}
373
+ }
374
+
375
+ export interface RobotsConfig {
376
+ body: string
377
+ generated: boolean
378
+ }
379
+
380
+ export interface RobotsValidation {
381
+ valid: boolean
382
+ blocksEntireSite: boolean
383
+ errors: string[]
384
+ warnings: string[]
385
+ }
386
+
387
+ export async function fetchRobotsTxt(): Promise<RobotsConfig | null> {
388
+ const res = await cmsApi<RobotsConfig>('/seo/robots')
389
+ throwIfError(res)
390
+ return res.data ?? null
391
+ }
392
+
393
+ export async function updateRobotsTxt(body: string): Promise<{ error?: string }> {
394
+ const res = await cmsApi('/seo/robots', { method: 'PUT', body: JSON.stringify({ body }) })
395
+ return res.error ? { error: res.error } : {}
396
+ }
397
+
398
+ /** Client-side robots.txt validation (mirrors server-side checks). */
399
+ export function validateRobotsTxt(body: string): RobotsValidation {
400
+ const errors: string[] = []
401
+ const warnings: string[] = []
402
+ const lines = body.split(/\r?\n/)
403
+ let blocksEntireSite = false
404
+ let sawUserAgent = false
405
+ let starAgent = false
406
+
407
+ for (const raw of lines) {
408
+ const line = raw.trim()
409
+ if (!line || line.startsWith('#')) continue
410
+ const colon = line.indexOf(':')
411
+ if (colon === -1) {
412
+ errors.push(`Invalid directive (missing ":"): "${line}"`)
413
+ continue
414
+ }
415
+ const directive = line.slice(0, colon).trim().toLowerCase()
416
+ const value = line.slice(colon + 1).trim()
417
+ if (directive === 'user-agent') {
418
+ sawUserAgent = true
419
+ starAgent = value === '*'
420
+ } else if (directive === 'disallow') {
421
+ if (value === '/' && starAgent) blocksEntireSite = true
422
+ } else if (!['allow', 'sitemap', 'crawl-delay', 'host'].includes(directive)) {
423
+ warnings.push(`Unknown directive: "${directive}"`)
424
+ }
425
+ }
426
+
427
+ if (!sawUserAgent) errors.push('No User-agent directive found.')
428
+ if (blocksEntireSite) {
429
+ warnings.push('This configuration blocks the entire site from all crawlers.')
430
+ }
431
+ return { valid: errors.length === 0, blocksEntireSite, errors, warnings }
432
+ }
433
+
434
+ // ─── Redirects ───────────────────────────────────────────────────────
435
+
436
+ export interface RedirectRule {
437
+ id: string
438
+ type: RedirectStatusCode
439
+ fromPath: string
440
+ toPath: string
441
+ enabled: boolean
442
+ priority: number
443
+ preserveQuery: boolean
444
+ hits: number
445
+ lastHitAt: string | null
446
+ stale: boolean
447
+ source: string
448
+ notes: string | null
449
+ createdAt: string
450
+ updatedAt: string
451
+ }
452
+
453
+ export interface RedirectOverview {
454
+ activeRedirects: number
455
+ staleRedirects: number
456
+ notFoundCount: number
457
+ notFoundHits: number
458
+ chainsAndLoops: number
459
+ trafficRecovered: number
460
+ }
461
+
462
+ export interface Redirect404 {
463
+ id: string
464
+ path: string
465
+ hits: number
466
+ lastSeenAt: string
467
+ }
468
+
469
+ export interface RedirectSuggestion {
470
+ id: string
471
+ fromPath: string
472
+ toPath: string
473
+ confidence: number
474
+ reason: string | null
475
+ status: 'open' | 'accepted' | 'dismissed'
476
+ hits: number
477
+ trend: string | null
478
+ }
479
+
480
+ export interface RedirectChain {
481
+ id: string
482
+ kind: 'chain' | 'loop'
483
+ hops: string[]
484
+ suggestedFrom: string
485
+ suggestedTo: string
486
+ }
487
+
488
+ export async function fetchRedirectOverview(): Promise<RedirectOverview> {
489
+ const res = await cmsApi<RedirectOverview>('/seo/redirects/overview')
490
+ throwIfError(res)
491
+ return (
492
+ res.data ?? {
493
+ activeRedirects: 0,
494
+ staleRedirects: 0,
495
+ notFoundCount: 0,
496
+ notFoundHits: 0,
497
+ chainsAndLoops: 0,
498
+ trafficRecovered: 0,
499
+ }
500
+ )
501
+ }
502
+
503
+ export async function fetchRedirectRules(): Promise<RedirectRule[]> {
504
+ const res = await cmsApi<{ rules: RedirectRule[] }>('/seo/redirects')
505
+ throwIfError(res)
506
+ return res.data?.rules ?? []
507
+ }
508
+
509
+ export interface RedirectRulePayload {
510
+ type: RedirectStatusCode
511
+ fromPath: string
512
+ toPath: string
513
+ enabled?: boolean
514
+ preserveQuery?: boolean
515
+ notes?: string
516
+ }
517
+
518
+ export async function createRedirectRule(
519
+ payload: RedirectRulePayload,
520
+ ): Promise<{ error?: string }> {
521
+ const res = await cmsApi('/seo/redirects', { method: 'POST', body: JSON.stringify(payload) })
522
+ return res.error ? { error: res.error } : {}
523
+ }
524
+
525
+ export async function updateRedirectRule(
526
+ redirectId: string,
527
+ payload: Partial<RedirectRulePayload>,
528
+ ): Promise<{ error?: string }> {
529
+ const res = await cmsApi(`/seo/redirects/${encodeURIComponent(redirectId)}`, {
530
+ method: 'PUT',
531
+ body: JSON.stringify(payload),
532
+ })
533
+ return res.error ? { error: res.error } : {}
534
+ }
535
+
536
+ export async function deleteRedirectRule(redirectId: string): Promise<{ error?: string }> {
537
+ const res = await cmsApi(`/seo/redirects/${encodeURIComponent(redirectId)}`, {
538
+ method: 'DELETE',
539
+ })
540
+ return res.error ? { error: res.error } : {}
541
+ }
542
+
543
+ export async function fetchRedirectChains(): Promise<RedirectChain[]> {
544
+ const res = await cmsApi<{ chains: RedirectChain[] }>('/seo/redirects/chains')
545
+ throwIfError(res)
546
+ return res.data?.chains ?? []
547
+ }
548
+
549
+ export async function flattenRedirectChain(chainId: string): Promise<{ error?: string }> {
550
+ const res = await cmsApi('/seo/redirects/flatten', {
551
+ method: 'POST',
552
+ body: JSON.stringify({ chainId }),
553
+ })
554
+ return res.error ? { error: res.error } : {}
555
+ }
556
+
557
+ export async function fetchRedirectSuggestions(): Promise<RedirectSuggestion[]> {
558
+ const res = await cmsApi<{ suggestions: RedirectSuggestion[] }>('/seo/redirects/suggestions')
559
+ throwIfError(res)
560
+ return res.data?.suggestions ?? []
561
+ }
562
+
563
+ export async function generate404RecoverySuggestions(): Promise<{ error?: string }> {
564
+ const res = await cmsApi('/seo/redirects/suggest', { method: 'POST' })
565
+ return res.error ? { error: res.error } : {}
566
+ }
567
+
568
+ export async function acceptRedirectSuggestion(suggestionId: string): Promise<{ error?: string }> {
569
+ const res = await cmsApi(
570
+ `/seo/redirects/suggestions/${encodeURIComponent(suggestionId)}/accept`,
571
+ {
572
+ method: 'POST',
573
+ },
574
+ )
575
+ return res.error ? { error: res.error } : {}
576
+ }
577
+
578
+ export async function dismissRedirectSuggestion(suggestionId: string): Promise<{ error?: string }> {
579
+ const res = await cmsApi(
580
+ `/seo/redirects/suggestions/${encodeURIComponent(suggestionId)}/dismiss`,
581
+ { method: 'POST' },
582
+ )
583
+ return res.error ? { error: res.error } : {}
584
+ }
585
+
586
+ export async function exportRedirectsCsv(): Promise<{ csv?: string; error?: string }> {
587
+ const res = await cmsApi<{ csv: string }>('/seo/redirects/export')
588
+ if (res.error) return { error: res.error }
589
+ return { csv: res.data?.csv ?? '' }
590
+ }
591
+
592
+ export async function importRedirectsCsv(
593
+ csv: string,
594
+ ): Promise<{ imported?: number; errors?: string[]; error?: string }> {
595
+ const res = await cmsApi<{ imported: number; errors: string[] }>('/seo/redirects/import', {
596
+ method: 'POST',
597
+ body: JSON.stringify({ csv }),
598
+ })
599
+ if (res.error) return { error: res.error }
600
+ return { imported: res.data?.imported ?? 0, errors: res.data?.errors ?? [] }
601
+ }
602
+
603
+ // ─── AI Copilot ──────────────────────────────────────────────────────
604
+
605
+ export interface SeoAiResult {
606
+ text?: string
607
+ alternatives?: string[]
608
+ structuredData?: Record<string, unknown>
609
+ /** True when the AI provider is not configured (graceful fallback). */
610
+ unavailable?: boolean
611
+ }
612
+
613
+ /**
614
+ * AI SEO endpoints report unavailability with two statuses: 501 (plugin-ai not
615
+ * installed) and 503 (installed but the provider isn't configured). Both map to
616
+ * the same graceful "AI unavailable" UX.
617
+ */
618
+ function isAiUnavailable(status?: number): boolean {
619
+ return status === 501 || status === 503
620
+ }
621
+
622
+ export async function generateSeoField(
623
+ field: 'metaTitle' | 'metaDescription' | 'ogTitle' | 'ogDescription' | 'focusKeyword',
624
+ context: { entityType: string; entityId: string },
625
+ ): Promise<SeoAiResult> {
626
+ const res = await cmsApi<SeoAiResult>('/ai/seo/generate-field', {
627
+ method: 'POST',
628
+ body: JSON.stringify({ field, ...context }),
629
+ })
630
+ if (isAiUnavailable(res.status)) return { unavailable: true }
631
+ return res.data ?? {}
632
+ }
633
+
634
+ export async function explainSeoIssue(issueId: string): Promise<SeoAiResult> {
635
+ const res = await cmsApi<SeoAiResult>('/ai/seo/explain-issue', {
636
+ method: 'POST',
637
+ body: JSON.stringify({ issueId }),
638
+ })
639
+ if (isAiUnavailable(res.status)) return { unavailable: true }
640
+ return res.data ?? {}
641
+ }
642
+
643
+ export async function generateStructuredData(context: {
644
+ entityType: string
645
+ entityId: string
646
+ schemaType: string
647
+ }): Promise<SeoAiResult> {
648
+ const res = await cmsApi<SeoAiResult>('/ai/seo/generate-schema', {
649
+ method: 'POST',
650
+ body: JSON.stringify(context),
651
+ })
652
+ if (isAiUnavailable(res.status)) return { unavailable: true }
653
+ return res.data ?? {}
654
+ }
655
+
656
+ export async function summarizeSeoAudit(): Promise<SeoAiResult> {
657
+ const res = await cmsApi<SeoAiResult>('/ai/seo/summarize', { method: 'POST' })
658
+ if (isAiUnavailable(res.status)) return { unavailable: true }
659
+ return res.data ?? {}
660
+ }
@@ -55,8 +55,11 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
55
55
 
56
56
  const matchRoute = useCallback(
57
57
  (pattern: string): RouteParams | null => {
58
+ // Match against the path only — query string / hash are state-bearing
59
+ // (e.g. `/seo?tab=audit`) and must not defeat route matching.
60
+ const pathname = currentPath.split('?')[0]!.split('#')[0]!
58
61
  const patternParts = pattern.split('/').filter(Boolean)
59
- const pathParts = currentPath.split('/').filter(Boolean)
62
+ const pathParts = pathname.split('/').filter(Boolean)
60
63
 
61
64
  if (pattern === '/' && pathParts.length === 0) {
62
65
  return {}