@prenta/admin 1.6.2 → 1.7.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 +6 -0
- package/dist/__tests__/components/seo-editor-pane.render.test.js +2 -1
- package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.d.ts +2 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +60 -0
- package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -0
- package/dist/__tests__/components/seo-linking-section.render.test.d.ts +2 -0
- package/dist/__tests__/components/seo-linking-section.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/seo-linking-section.render.test.js +94 -0
- package/dist/__tests__/components/seo-linking-section.render.test.js.map +1 -0
- package/dist/__tests__/lib/collection-routes.test.js +8 -1
- package/dist/__tests__/lib/collection-routes.test.js.map +1 -1
- package/dist/__tests__/lib/seo-service-errors.test.js +24 -1
- package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
- package/dist/components/seo/SeoEditorPane.d.ts +2 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +2 -2
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
- package/dist/components/seo/SeoIssueFixPanel.js +32 -8
- package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
- package/dist/components/seo/SeoLinkingSection.d.ts +5 -1
- package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -1
- package/dist/components/seo/SeoLinkingSection.js +50 -3
- package/dist/components/seo/SeoLinkingSection.js.map +1 -1
- package/dist/lib/collection-routes.d.ts +2 -0
- package/dist/lib/collection-routes.d.ts.map +1 -1
- package/dist/lib/collection-routes.js +5 -0
- package/dist/lib/collection-routes.js.map +1 -1
- package/dist/lib/seo-service.d.ts +12 -1
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +10 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +1 -1
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +1 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +1 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts +2 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +2 -2
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/seo/LinksTab.d.ts +3 -1
- package/dist/views/seo/LinksTab.d.ts.map +1 -1
- package/dist/views/seo/LinksTab.js +36 -6
- package/dist/views/seo/LinksTab.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/components/seo-editor-pane.render.test.tsx +4 -1
- package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +65 -0
- package/src/__tests__/components/seo-linking-section.render.test.tsx +146 -0
- package/src/__tests__/lib/collection-routes.test.ts +13 -1
- package/src/__tests__/lib/seo-service-errors.test.ts +29 -0
- package/src/__tests__/views/seo-content-pane.render.test.tsx +1 -0
- package/src/components/seo/SeoEditorPane.tsx +3 -0
- package/src/components/seo/SeoIssueFixPanel.tsx +34 -10
- package/src/components/seo/SeoLinkingSection.tsx +116 -18
- package/src/lib/collection-routes.ts +13 -0
- package/src/lib/seo-service.ts +19 -1
- package/src/views/SEO.tsx +2 -1
- package/src/views/page-editor/PageSectionEditor.tsx +1 -0
- package/src/views/post-editor/PostSectionEditor.tsx +1 -0
- package/src/views/seo/ContentTab.tsx +3 -0
- package/src/views/seo/LinksTab.tsx +108 -19
|
@@ -24,6 +24,19 @@ export function editPathForDoc(
|
|
|
24
24
|
return `/${collection}/${id}`
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Editor route for an inbound-link suggestion source document. */
|
|
28
|
+
export function sourceEditPath(
|
|
29
|
+
sourceCollection: string | undefined,
|
|
30
|
+
sourceEntityId: string,
|
|
31
|
+
): string {
|
|
32
|
+
const collection = sourceCollection ?? 'pages'
|
|
33
|
+
return editPathForDoc(
|
|
34
|
+
{ type: collection === 'pages' ? 'page' : 'post' },
|
|
35
|
+
collection,
|
|
36
|
+
sourceEntityId,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
/** The route that starts a new document in the correct editor. */
|
|
28
41
|
export function newPathForCollection(
|
|
29
42
|
col: CollectionRouteMeta | undefined,
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -417,7 +417,7 @@ export interface SeoFixFieldChange {
|
|
|
417
417
|
export interface SeoIssueFixSuggestion {
|
|
418
418
|
autoFixable: boolean
|
|
419
419
|
source?: 'deterministic' | 'ai'
|
|
420
|
-
fixStrategy?: 'redirect' | 'fix-link'
|
|
420
|
+
fixStrategy?: 'redirect' | 'fix-link' | 'insert-link'
|
|
421
421
|
aiUnavailable?: boolean
|
|
422
422
|
fingerprint: string
|
|
423
423
|
patch: Record<string, unknown>
|
|
@@ -498,6 +498,7 @@ export const SEO_INLINE_FIX_ISSUE_TYPES = new Set([
|
|
|
498
498
|
'duplicate-meta-description',
|
|
499
499
|
'missing-structured-data',
|
|
500
500
|
'broken-internal-link',
|
|
501
|
+
'orphan-page',
|
|
501
502
|
])
|
|
502
503
|
|
|
503
504
|
export function isInlineSeoFixIssue(type: string): boolean {
|
|
@@ -1125,6 +1126,23 @@ export interface LinkSuggestion {
|
|
|
1125
1126
|
anchorText: string
|
|
1126
1127
|
confidence: number
|
|
1127
1128
|
excerpt: string
|
|
1129
|
+
fingerprint: string
|
|
1130
|
+
wrappable: boolean
|
|
1131
|
+
sourceCollection?: string
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
export async function applySeoInternalLink(input: {
|
|
1135
|
+
sourceEntityId: string
|
|
1136
|
+
targetEntityId: string
|
|
1137
|
+
anchorText: string
|
|
1138
|
+
fingerprint: string
|
|
1139
|
+
}): Promise<{ error?: string }> {
|
|
1140
|
+
const res = await cmsApi('/seo/links/apply', {
|
|
1141
|
+
method: 'POST',
|
|
1142
|
+
body: JSON.stringify(input),
|
|
1143
|
+
})
|
|
1144
|
+
if (res.error) return { error: res.error }
|
|
1145
|
+
return {}
|
|
1128
1146
|
}
|
|
1129
1147
|
|
|
1130
1148
|
export async function fetchLinkGraphSnapshot(): Promise<LinkGraphSnapshot> {
|
package/src/views/SEO.tsx
CHANGED
|
@@ -249,11 +249,12 @@ export function SEO({ onNavigate, initialTab = 'overview' }: SEOProps) {
|
|
|
249
249
|
<ContentTab
|
|
250
250
|
initialEntityType={deepLink.entityType}
|
|
251
251
|
initialEntityId={deepLink.entityId}
|
|
252
|
+
onNavigate={onNavigate}
|
|
252
253
|
/>
|
|
253
254
|
)}
|
|
254
255
|
</Tabs.Content>
|
|
255
256
|
<Tabs.Content value="links" tabIndex={-1}>
|
|
256
|
-
{activeTab === 'links' && <LinksTab onNavigate={onNavigate} />}
|
|
257
|
+
{activeTab === 'links' && <LinksTab onNavigate={onNavigate} planInfo={planInfo} />}
|
|
257
258
|
</Tabs.Content>
|
|
258
259
|
<Tabs.Content value="technical" tabIndex={-1}>
|
|
259
260
|
{activeTab === 'technical' && <TechnicalTab />}
|
|
@@ -44,9 +44,11 @@ function parseEntityType(value: string | null | undefined): SeoEntityType | null
|
|
|
44
44
|
export function ContentTab({
|
|
45
45
|
initialEntityType,
|
|
46
46
|
initialEntityId,
|
|
47
|
+
onNavigate,
|
|
47
48
|
}: {
|
|
48
49
|
initialEntityType?: string | null
|
|
49
50
|
initialEntityId?: string | null
|
|
51
|
+
onNavigate?: (path: string) => void
|
|
50
52
|
}) {
|
|
51
53
|
const { data, loading, error, refetch } = useSeoResource(fetchSeoContentRecords, [])
|
|
52
54
|
const [search, setSearch] = useState('')
|
|
@@ -194,6 +196,7 @@ export function ContentTab({
|
|
|
194
196
|
entityId={selection.id}
|
|
195
197
|
onClose={() => setSelection(null)}
|
|
196
198
|
onSaved={refetch}
|
|
199
|
+
onNavigate={onNavigate}
|
|
197
200
|
/>
|
|
198
201
|
)}
|
|
199
202
|
</InspectorPane>
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
} from 'lucide-react'
|
|
18
18
|
import { toast } from 'sonner'
|
|
19
19
|
import {
|
|
20
|
+
applySeoInternalLink,
|
|
20
21
|
fetchLinkGraphSnapshot,
|
|
21
22
|
fetchLinkHealthIssues,
|
|
22
23
|
fetchLinkSuggestions,
|
|
@@ -24,7 +25,9 @@ import {
|
|
|
24
25
|
type LinkGraphEntityRef,
|
|
25
26
|
type LinkSuggestion,
|
|
26
27
|
} from '../../lib/seo-service.js'
|
|
27
|
-
import { editPathForDoc } from '../../lib/collection-routes.js'
|
|
28
|
+
import { editPathForDoc, sourceEditPath } from '../../lib/collection-routes.js'
|
|
29
|
+
import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
|
|
30
|
+
import { PlanUpgradeCallout } from '../../components/PlanUpgradeCallout.js'
|
|
28
31
|
import {
|
|
29
32
|
SectionCard,
|
|
30
33
|
SeoLoading,
|
|
@@ -88,10 +91,39 @@ function ActionCard({
|
|
|
88
91
|
function SuggestionList({
|
|
89
92
|
suggestions,
|
|
90
93
|
loading,
|
|
94
|
+
targetEntityId,
|
|
95
|
+
onNavigate,
|
|
96
|
+
onApplied,
|
|
97
|
+
planInfo,
|
|
91
98
|
}: {
|
|
92
99
|
suggestions: LinkSuggestion[]
|
|
93
100
|
loading: boolean
|
|
101
|
+
targetEntityId: string
|
|
102
|
+
onNavigate?: (path: string) => void
|
|
103
|
+
onApplied?: (suggestion: LinkSuggestion) => void
|
|
104
|
+
planInfo?: PlanInfo | null
|
|
94
105
|
}) {
|
|
106
|
+
const [applyingKey, setApplyingKey] = useState<string | null>(null)
|
|
107
|
+
const inlineApplyEnabled = hasPlanFeature(planInfo, 'seo.inlineApply')
|
|
108
|
+
|
|
109
|
+
async function handleApprove(suggestion: LinkSuggestion) {
|
|
110
|
+
const key = `${suggestion.sourceEntityId}-${suggestion.fingerprint}`
|
|
111
|
+
setApplyingKey(key)
|
|
112
|
+
const res = await applySeoInternalLink({
|
|
113
|
+
sourceEntityId: suggestion.sourceEntityId,
|
|
114
|
+
targetEntityId,
|
|
115
|
+
anchorText: suggestion.anchorText,
|
|
116
|
+
fingerprint: suggestion.fingerprint,
|
|
117
|
+
})
|
|
118
|
+
setApplyingKey(null)
|
|
119
|
+
if (res.error) {
|
|
120
|
+
toast.error(res.error)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
toast.success('Link added.')
|
|
124
|
+
onApplied?.(suggestion)
|
|
125
|
+
}
|
|
126
|
+
|
|
95
127
|
if (loading) {
|
|
96
128
|
return (
|
|
97
129
|
<p className="text-muted-foreground flex items-center gap-2 text-sm">
|
|
@@ -108,26 +140,64 @@ function SuggestionList({
|
|
|
108
140
|
)
|
|
109
141
|
}
|
|
110
142
|
return (
|
|
111
|
-
<
|
|
112
|
-
{suggestions.
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
143
|
+
<div className="space-y-3">
|
|
144
|
+
{!inlineApplyEnabled && suggestions.some((s) => s.wrappable) ? (
|
|
145
|
+
<PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
|
|
146
|
+
) : null}
|
|
147
|
+
<ul className="divide-border divide-y rounded-lg border">
|
|
148
|
+
{suggestions.map((s) => {
|
|
149
|
+
const key = `${s.sourceEntityId}-${s.fingerprint || s.anchorText}`
|
|
150
|
+
const busy = applyingKey === key
|
|
151
|
+
return (
|
|
152
|
+
<li key={key} className="px-4 py-3">
|
|
153
|
+
<p className="text-foreground text-sm font-medium">{s.sourceTitle}</p>
|
|
154
|
+
<p className="text-muted-foreground mt-0.5 text-xs">
|
|
155
|
+
Link “{s.anchorText}” → {s.sourceUrl || 'target page'}{' '}
|
|
156
|
+
<span className="text-muted-foreground/80 tabular-nums">
|
|
157
|
+
({Math.round(s.confidence * 100)}% match)
|
|
158
|
+
</span>
|
|
159
|
+
</p>
|
|
160
|
+
{s.excerpt && (
|
|
161
|
+
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs">{s.excerpt}</p>
|
|
162
|
+
)}
|
|
163
|
+
{s.wrappable && inlineApplyEnabled ? (
|
|
164
|
+
<div className="mt-2">
|
|
165
|
+
<button
|
|
166
|
+
type="button"
|
|
167
|
+
className={btnPrimary}
|
|
168
|
+
disabled={busy}
|
|
169
|
+
onClick={() => void handleApprove(s)}
|
|
170
|
+
>
|
|
171
|
+
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : null}
|
|
172
|
+
Approve link
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
) : onNavigate ? (
|
|
176
|
+
<div className="mt-2">
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
className={btnSecondary}
|
|
180
|
+
onClick={() => onNavigate(sourceEditPath(s.sourceCollection, s.sourceEntityId))}
|
|
181
|
+
>
|
|
182
|
+
Open in editor
|
|
183
|
+
</button>
|
|
184
|
+
</div>
|
|
185
|
+
) : null}
|
|
186
|
+
</li>
|
|
187
|
+
)
|
|
188
|
+
})}
|
|
189
|
+
</ul>
|
|
190
|
+
</div>
|
|
127
191
|
)
|
|
128
192
|
}
|
|
129
193
|
|
|
130
|
-
export function LinksTab({
|
|
194
|
+
export function LinksTab({
|
|
195
|
+
onNavigate,
|
|
196
|
+
planInfo,
|
|
197
|
+
}: {
|
|
198
|
+
onNavigate?: (path: string) => void
|
|
199
|
+
planInfo?: PlanInfo | null
|
|
200
|
+
}) {
|
|
131
201
|
const { data, loading, error, refetch } = useSeoResource(fetchLinkGraphSnapshot, [])
|
|
132
202
|
const [indexing, setIndexing] = useState(false)
|
|
133
203
|
const [healthLoading, setHealthLoading] = useState(false)
|
|
@@ -379,7 +449,26 @@ export function LinksTab({ onNavigate }: { onNavigate?: (path: string) => void }
|
|
|
379
449
|
<p className="text-foreground mb-2 text-sm font-medium">
|
|
380
450
|
AI suggestions for “{selectedOrphan.title}”
|
|
381
451
|
</p>
|
|
382
|
-
<SuggestionList
|
|
452
|
+
<SuggestionList
|
|
453
|
+
suggestions={suggestions}
|
|
454
|
+
loading={suggestLoading}
|
|
455
|
+
targetEntityId={selectedOrphan.id}
|
|
456
|
+
onNavigate={onNavigate}
|
|
457
|
+
planInfo={planInfo}
|
|
458
|
+
onApplied={(applied) => {
|
|
459
|
+
setSuggestions((prev) =>
|
|
460
|
+
prev.filter(
|
|
461
|
+
(row) =>
|
|
462
|
+
!(
|
|
463
|
+
row.fingerprint === applied.fingerprint &&
|
|
464
|
+
row.sourceEntityId === applied.sourceEntityId
|
|
465
|
+
),
|
|
466
|
+
),
|
|
467
|
+
)
|
|
468
|
+
refetch()
|
|
469
|
+
void loadSuggestions(selectedOrphan)
|
|
470
|
+
}}
|
|
471
|
+
/>
|
|
383
472
|
</div>
|
|
384
473
|
)}
|
|
385
474
|
</div>
|