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