@prenta/admin 1.6.1 → 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 +12 -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/page-editor-service.test.js +7 -0
- package/dist/__tests__/lib/page-editor-service.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__/lib/workflow-chrome.test.js +4 -0
- package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -1
- package/dist/__tests__/views/editor-top-bar.render.test.js +5 -0
- package/dist/__tests__/views/editor-top-bar.render.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/lib/workflow-chrome.d.ts +3 -0
- package/dist/lib/workflow-chrome.d.ts.map +1 -1
- package/dist/lib/workflow-chrome.js +9 -0
- package/dist/lib/workflow-chrome.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/EditorTopBar.js +1 -1
- package/dist/views/page-editor/EditorTopBar.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 +4 -4
- 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/page-editor-service.test.ts +8 -0
- package/src/__tests__/lib/seo-service-errors.test.ts +29 -0
- package/src/__tests__/lib/workflow-chrome.test.ts +5 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +6 -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/lib/workflow-chrome.ts +10 -0
- package/src/views/SEO.tsx +2 -1
- package/src/views/page-editor/EditorTopBar.tsx +1 -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
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
editPathForDoc,
|
|
5
|
+
newPathForCollection,
|
|
6
|
+
sourceEditPath,
|
|
7
|
+
} from '../../lib/collection-routes.js'
|
|
4
8
|
|
|
5
9
|
describe('editPathForDoc', () => {
|
|
6
10
|
it('routes post-type documents to the section editor (not the generic route)', () => {
|
|
@@ -22,6 +26,14 @@ describe('editPathForDoc', () => {
|
|
|
22
26
|
})
|
|
23
27
|
})
|
|
24
28
|
|
|
29
|
+
describe('sourceEditPath', () => {
|
|
30
|
+
it('routes a blog source through CollectionRouteMeta so posts open the post editor', () => {
|
|
31
|
+
expect(sourceEditPath('blog', 'src-2')).toBe('/posts/blog/src-2/edit')
|
|
32
|
+
expect(sourceEditPath('pages', 'abc')).toBe('/pages/abc/edit')
|
|
33
|
+
expect(sourceEditPath(undefined, 'abc')).toBe('/pages/abc/edit')
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
25
37
|
describe('newPathForCollection', () => {
|
|
26
38
|
it('routes new post documents to the post editor', () => {
|
|
27
39
|
expect(newPathForCollection({ type: 'post' }, 'case-studies')).toBe('/posts/case-studies/new')
|
|
@@ -160,6 +160,14 @@ describe('validatePage', () => {
|
|
|
160
160
|
const { errors } = validatePage(page, false)
|
|
161
161
|
expect(errors.some((e) => /reserved/i.test(e))).toBe(true)
|
|
162
162
|
})
|
|
163
|
+
|
|
164
|
+
it('accepts a nested slash slug when path is set', () => {
|
|
165
|
+
const page = basePage([])
|
|
166
|
+
page.slug = 'help/faq'
|
|
167
|
+
page.path = '/help/faq'
|
|
168
|
+
const { errors } = validatePage(page, false)
|
|
169
|
+
expect(errors.some((e) => /slug/i.test(e))).toBe(false)
|
|
170
|
+
})
|
|
163
171
|
})
|
|
164
172
|
|
|
165
173
|
describe('section type registry', () => {
|
|
@@ -8,10 +8,12 @@ vi.mock('../../lib/api.js', () => ({
|
|
|
8
8
|
}))
|
|
9
9
|
|
|
10
10
|
const {
|
|
11
|
+
applySeoInternalLink,
|
|
11
12
|
fetchSeoOverview,
|
|
12
13
|
fetchSeoContentRecords,
|
|
13
14
|
fetchSeoIssues,
|
|
14
15
|
fetchRedirectRules,
|
|
16
|
+
isInlineSeoFixIssue,
|
|
15
17
|
runSeoAudit,
|
|
16
18
|
updateSeoFields,
|
|
17
19
|
} = await import('../../lib/seo-service.js')
|
|
@@ -71,6 +73,33 @@ describe('SEO service read fetchers surface errors', () => {
|
|
|
71
73
|
error: 'forbidden',
|
|
72
74
|
})
|
|
73
75
|
})
|
|
76
|
+
|
|
77
|
+
it('applySeoInternalLink POSTs the apply body and returns { error } on failure', async () => {
|
|
78
|
+
cmsApi.mockResolvedValue({ error: 'Not wrappable', status: 422 })
|
|
79
|
+
await expect(
|
|
80
|
+
applySeoInternalLink({
|
|
81
|
+
sourceEntityId: 'src-1',
|
|
82
|
+
targetEntityId: 'tgt-1',
|
|
83
|
+
anchorText: 'RankMath',
|
|
84
|
+
fingerprint: 'fp-1',
|
|
85
|
+
}),
|
|
86
|
+
).resolves.toEqual({ error: 'Not wrappable' })
|
|
87
|
+
expect(cmsApi).toHaveBeenCalledWith('/seo/links/apply', {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
sourceEntityId: 'src-1',
|
|
91
|
+
targetEntityId: 'tgt-1',
|
|
92
|
+
anchorText: 'RankMath',
|
|
93
|
+
fingerprint: 'fp-1',
|
|
94
|
+
}),
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('inline SEO fix issue types', () => {
|
|
100
|
+
it('treats orphan-page as an inline Approve issue', () => {
|
|
101
|
+
expect(isInlineSeoFixIssue('orphan-page')).toBe(true)
|
|
102
|
+
})
|
|
74
103
|
})
|
|
75
104
|
|
|
76
105
|
describe('SEO audit mutation contract', () => {
|
|
@@ -77,6 +77,11 @@ describe('resolveWorkflowChrome', () => {
|
|
|
77
77
|
expect(chrome.showRequestChanges).toBe(false)
|
|
78
78
|
expect(chrome.showSchedule).toBe(false)
|
|
79
79
|
expect(chrome.statusLabel).toBe('Published')
|
|
80
|
+
expect(chrome.saveLabel).toBe('Update live')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('labels the write-through save as Save Draft on unpublished pages', () => {
|
|
84
|
+
expect(resolveWorkflowChrome({ ...draft, role: 'EDITOR' }).saveLabel).toBe('Save Draft')
|
|
80
85
|
})
|
|
81
86
|
|
|
82
87
|
it('shows Scheduled when status is SCHEDULED', () => {
|
|
@@ -191,6 +191,12 @@ describe('EditorTopBar view site / share', () => {
|
|
|
191
191
|
expect(screen.queryByRole('button', { name: 'View live' })).toBeNull()
|
|
192
192
|
expect(screen.queryByRole('button', { name: 'Share' })).toBeNull()
|
|
193
193
|
})
|
|
194
|
+
|
|
195
|
+
it('labels the save button Update live on a published page', () => {
|
|
196
|
+
render(<EditorTopBar {...baseProps} status="PUBLISHED" dirty />)
|
|
197
|
+
expect(screen.getByRole('button', { name: 'Update live' })).toBeTruthy()
|
|
198
|
+
expect(screen.queryByRole('button', { name: 'Save Draft' })).toBeNull()
|
|
199
|
+
})
|
|
194
200
|
})
|
|
195
201
|
|
|
196
202
|
describe('EditorTopBar history button', () => {
|
|
@@ -69,6 +69,7 @@ vi.mock('../../lib/seo-service.js', () => ({
|
|
|
69
69
|
updateSeoFields: (t: string, i: string, p: unknown) => updateSeoFields(t, i, p),
|
|
70
70
|
generateSeoField: vi.fn(async () => ({ unavailable: true })),
|
|
71
71
|
fetchLinkSuggestions: vi.fn(async () => ({ data: { suggestions: [] } })),
|
|
72
|
+
applySeoInternalLink: vi.fn(async () => ({})),
|
|
72
73
|
SEO_SCHEMA_TYPES: ['Article', 'WebPage'],
|
|
73
74
|
applyContentSeoQuery: (
|
|
74
75
|
records: Array<{ entityType: string; title: string }>,
|
|
@@ -162,6 +162,7 @@ export interface SeoEditorPaneProps {
|
|
|
162
162
|
title?: string
|
|
163
163
|
bodyHtml?: string
|
|
164
164
|
featuredImage?: string
|
|
165
|
+
onNavigate?: (path: string) => void
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
export function SeoEditorPane({
|
|
@@ -178,6 +179,7 @@ export function SeoEditorPane({
|
|
|
178
179
|
title,
|
|
179
180
|
bodyHtml,
|
|
180
181
|
featuredImage,
|
|
182
|
+
onNavigate,
|
|
181
183
|
}: SeoEditorPaneProps) {
|
|
182
184
|
const isDraft = !entityId
|
|
183
185
|
const [record, setRecord] = useState<SeoContentRecord | null>(null)
|
|
@@ -994,6 +996,7 @@ export function SeoEditorPane({
|
|
|
994
996
|
externalLinks={Number(externalLinks)}
|
|
995
997
|
internalLinkOk={internalOk}
|
|
996
998
|
externalLinkOk={externalOk}
|
|
999
|
+
onNavigate={onNavigate}
|
|
997
1000
|
/>
|
|
998
1001
|
</SeoAccordionItem>
|
|
999
1002
|
</SeoAccordion>
|
|
@@ -24,6 +24,38 @@ import { hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
|
|
|
24
24
|
import { PlanUpgradeCallout } from '../PlanUpgradeCallout.js'
|
|
25
25
|
import { SeoErrorState, btnPrimary, btnSecondary } from './primitives.js'
|
|
26
26
|
|
|
27
|
+
function approveButtonLabel(strategy: SeoIssueFixSuggestion['fixStrategy']): string {
|
|
28
|
+
switch (strategy) {
|
|
29
|
+
case 'fix-link':
|
|
30
|
+
return 'Fix link in content'
|
|
31
|
+
case 'insert-link':
|
|
32
|
+
return 'Approve link'
|
|
33
|
+
case 'redirect':
|
|
34
|
+
case undefined:
|
|
35
|
+
return 'Approve redirect'
|
|
36
|
+
default: {
|
|
37
|
+
const _exhaustive: never = strategy
|
|
38
|
+
return _exhaustive
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function approveSuccessToast(strategy: SeoIssueFixSuggestion['fixStrategy']): string {
|
|
44
|
+
switch (strategy) {
|
|
45
|
+
case 'fix-link':
|
|
46
|
+
return 'Link updated in content and issue resolved.'
|
|
47
|
+
case 'insert-link':
|
|
48
|
+
return 'Link added and issue resolved.'
|
|
49
|
+
case 'redirect':
|
|
50
|
+
case undefined:
|
|
51
|
+
return 'Redirect created and issue resolved.'
|
|
52
|
+
default: {
|
|
53
|
+
const _exhaustive: never = strategy
|
|
54
|
+
return _exhaustive
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
27
59
|
function formatValue(value: string | boolean | null): string {
|
|
28
60
|
if (value === null || value === '') return '—'
|
|
29
61
|
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
|
@@ -321,11 +353,7 @@ export function SeoIssueFixPanel({
|
|
|
321
353
|
toast.error(res.error)
|
|
322
354
|
return
|
|
323
355
|
}
|
|
324
|
-
toast.success(
|
|
325
|
-
suggestion?.fixStrategy === 'fix-link'
|
|
326
|
-
? 'Link updated in content and issue resolved.'
|
|
327
|
-
: 'Redirect created and issue resolved.',
|
|
328
|
-
)
|
|
356
|
+
toast.success(approveSuccessToast(suggestion?.fixStrategy))
|
|
329
357
|
onChanged?.()
|
|
330
358
|
}
|
|
331
359
|
|
|
@@ -491,11 +519,7 @@ export function SeoIssueFixPanel({
|
|
|
491
519
|
disabled={busy}
|
|
492
520
|
>
|
|
493
521
|
<Check className="h-4 w-4" aria-hidden />
|
|
494
|
-
{busy
|
|
495
|
-
? 'Applying…'
|
|
496
|
-
: suggestion.fixStrategy === 'fix-link'
|
|
497
|
-
? 'Fix link in content'
|
|
498
|
-
: 'Approve redirect'}
|
|
522
|
+
{busy ? 'Applying…' : approveButtonLabel(suggestion.fixStrategy)}
|
|
499
523
|
</button>
|
|
500
524
|
) : inlineFix && suggestion?.autoFixable && !inlineApplyEnabled ? (
|
|
501
525
|
<PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
|
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react'
|
|
4
4
|
import { Link2, Loader2, Sparkles } from 'lucide-react'
|
|
5
|
-
import {
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { sourceEditPath } from '../../lib/collection-routes.js'
|
|
7
|
+
import {
|
|
8
|
+
applySeoInternalLink,
|
|
9
|
+
fetchLinkSuggestions,
|
|
10
|
+
type LinkSuggestion,
|
|
11
|
+
} from '../../lib/seo-service.js'
|
|
12
|
+
import { fetchPlanInfo, hasPlanFeature, type PlanInfo } from '../../lib/plan.js'
|
|
13
|
+
import { PlanUpgradeCallout } from '../PlanUpgradeCallout.js'
|
|
14
|
+
|
|
15
|
+
const approveBtnCls =
|
|
16
|
+
'inline-flex items-center justify-center gap-1.5 rounded-[7px] bg-primary px-2.5 py-1.5 text-[11.5px] font-medium text-primary-foreground transition-colors hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50'
|
|
17
|
+
|
|
18
|
+
const editorBtnCls =
|
|
19
|
+
'inline-flex items-center justify-center gap-1.5 rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 text-[11.5px] font-medium text-[var(--txt)] transition-colors hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none'
|
|
6
20
|
|
|
7
21
|
export function SeoLinkingSection({
|
|
8
22
|
entityType,
|
|
@@ -11,6 +25,8 @@ export function SeoLinkingSection({
|
|
|
11
25
|
externalLinks,
|
|
12
26
|
internalLinkOk,
|
|
13
27
|
externalLinkOk,
|
|
28
|
+
onNavigate,
|
|
29
|
+
planInfo: planInfoProp,
|
|
14
30
|
}: {
|
|
15
31
|
entityType: 'page' | 'post'
|
|
16
32
|
entityId: string | null
|
|
@@ -18,10 +34,31 @@ export function SeoLinkingSection({
|
|
|
18
34
|
externalLinks: number
|
|
19
35
|
internalLinkOk: boolean
|
|
20
36
|
externalLinkOk: boolean
|
|
37
|
+
onNavigate?: (path: string) => void
|
|
38
|
+
/** When omitted (page/post editors), fetched on mount. */
|
|
39
|
+
planInfo?: PlanInfo | null
|
|
21
40
|
}) {
|
|
22
41
|
const [loading, setLoading] = useState(false)
|
|
23
42
|
const [suggestions, setSuggestions] = useState<LinkSuggestion[]>([])
|
|
24
43
|
const [error, setError] = useState<string | null>(null)
|
|
44
|
+
const [applyingKey, setApplyingKey] = useState<string | null>(null)
|
|
45
|
+
const [planInfo, setPlanInfo] = useState<PlanInfo | null | undefined>(planInfoProp)
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (planInfoProp !== undefined) {
|
|
49
|
+
setPlanInfo(planInfoProp)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
let alive = true
|
|
53
|
+
void fetchPlanInfo().then((info) => {
|
|
54
|
+
if (alive) setPlanInfo(info)
|
|
55
|
+
})
|
|
56
|
+
return () => {
|
|
57
|
+
alive = false
|
|
58
|
+
}
|
|
59
|
+
}, [planInfoProp])
|
|
60
|
+
|
|
61
|
+
const inlineApplyEnabled = hasPlanFeature(planInfo, 'seo.inlineApply')
|
|
25
62
|
|
|
26
63
|
useEffect(() => {
|
|
27
64
|
if (!entityId) {
|
|
@@ -48,6 +85,33 @@ export function SeoLinkingSection({
|
|
|
48
85
|
}
|
|
49
86
|
}, [entityType, entityId])
|
|
50
87
|
|
|
88
|
+
async function handleApprove(suggestion: LinkSuggestion) {
|
|
89
|
+
if (!entityId) return
|
|
90
|
+
const key = `${suggestion.sourceEntityId}-${suggestion.fingerprint}`
|
|
91
|
+
setApplyingKey(key)
|
|
92
|
+
const res = await applySeoInternalLink({
|
|
93
|
+
sourceEntityId: suggestion.sourceEntityId,
|
|
94
|
+
targetEntityId: entityId,
|
|
95
|
+
anchorText: suggestion.anchorText,
|
|
96
|
+
fingerprint: suggestion.fingerprint,
|
|
97
|
+
})
|
|
98
|
+
setApplyingKey(null)
|
|
99
|
+
if (res.error) {
|
|
100
|
+
toast.error(res.error)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
toast.success('Link added.')
|
|
104
|
+
setSuggestions((prev) =>
|
|
105
|
+
prev.filter(
|
|
106
|
+
(row) =>
|
|
107
|
+
!(
|
|
108
|
+
row.fingerprint === suggestion.fingerprint &&
|
|
109
|
+
row.sourceEntityId === suggestion.sourceEntityId
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
51
115
|
return (
|
|
52
116
|
<div className="space-y-4">
|
|
53
117
|
<div className="grid grid-cols-2 gap-3">
|
|
@@ -90,22 +154,56 @@ export function SeoLinkingSection({
|
|
|
90
154
|
<Sparkles className="h-3.5 w-3.5 text-[var(--acc)]" aria-hidden />
|
|
91
155
|
Link opportunities
|
|
92
156
|
</div>
|
|
157
|
+
{!inlineApplyEnabled && suggestions.some((s) => s.wrappable) ? (
|
|
158
|
+
<div className="mb-2">
|
|
159
|
+
<PlanUpgradeCallout feature="seo.inlineApply" upgradeUrl={planInfo?.upgradeUrl} />
|
|
160
|
+
</div>
|
|
161
|
+
) : null}
|
|
93
162
|
<ul className="space-y-2">
|
|
94
|
-
{suggestions.map((s) =>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
163
|
+
{suggestions.map((s) => {
|
|
164
|
+
const key = `${s.sourceEntityId}-${s.fingerprint || s.anchorText}`
|
|
165
|
+
const busy = applyingKey === key
|
|
166
|
+
return (
|
|
167
|
+
<li
|
|
168
|
+
key={key}
|
|
169
|
+
className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-2.5"
|
|
170
|
+
>
|
|
171
|
+
<div className="text-[12.5px] font-medium text-[var(--txt)]">{s.sourceTitle}</div>
|
|
172
|
+
<p className="mt-0.5 text-[11px] text-[var(--muted)]">{s.sourceUrl}</p>
|
|
173
|
+
<p className="mt-1 text-[11.5px] text-[var(--sub)]">
|
|
174
|
+
Suggested anchor: <span className="text-[var(--txt)]">{s.anchorText}</span>
|
|
175
|
+
</p>
|
|
176
|
+
{s.excerpt && (
|
|
177
|
+
<p className="mt-1 line-clamp-2 text-[11px] text-[var(--muted)]">{s.excerpt}</p>
|
|
178
|
+
)}
|
|
179
|
+
{s.wrappable && inlineApplyEnabled ? (
|
|
180
|
+
<div className="mt-2">
|
|
181
|
+
<button
|
|
182
|
+
type="button"
|
|
183
|
+
className={approveBtnCls}
|
|
184
|
+
disabled={busy}
|
|
185
|
+
onClick={() => void handleApprove(s)}
|
|
186
|
+
>
|
|
187
|
+
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
|
|
188
|
+
Approve link
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
) : onNavigate ? (
|
|
192
|
+
<div className="mt-2">
|
|
193
|
+
<button
|
|
194
|
+
type="button"
|
|
195
|
+
className={editorBtnCls}
|
|
196
|
+
onClick={() =>
|
|
197
|
+
onNavigate(sourceEditPath(s.sourceCollection, s.sourceEntityId))
|
|
198
|
+
}
|
|
199
|
+
>
|
|
200
|
+
Open in editor
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
) : null}
|
|
204
|
+
</li>
|
|
205
|
+
)
|
|
206
|
+
})}
|
|
109
207
|
</ul>
|
|
110
208
|
</div>
|
|
111
209
|
) : (
|
|
@@ -118,8 +216,8 @@ export function SeoLinkingSection({
|
|
|
118
216
|
<div className="flex items-start gap-2 rounded-[7px] border border-[var(--bdr)] bg-[var(--bg)] p-2.5 text-[11px] text-[var(--sub)]">
|
|
119
217
|
<Link2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--acc)]" aria-hidden />
|
|
120
218
|
<span>
|
|
121
|
-
|
|
122
|
-
|
|
219
|
+
Wrappable suggestions can be approved here. Others need an editor pass so we do not invent
|
|
220
|
+
a sentence.
|
|
123
221
|
</span>
|
|
124
222
|
</div>
|
|
125
223
|
</div>
|
|
@@ -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> {
|
|
@@ -8,6 +8,8 @@ export const REQUEST_CHANGES_LABEL = 'Request changes'
|
|
|
8
8
|
export const WAITING_FOR_REVIEW_LABEL = 'Waiting for review.'
|
|
9
9
|
export const REVIEWER_NOTE_PREFIX = 'Reviewer note:'
|
|
10
10
|
export const SUBMIT_BEFORE_PUBLISH_ERROR = 'Submit for review before publishing.'
|
|
11
|
+
export const SAVE_DRAFT_LABEL = 'Save Draft'
|
|
12
|
+
export const UPDATE_LIVE_LABEL = 'Update live'
|
|
11
13
|
|
|
12
14
|
const REVIEWER_ROLES = new Set(['ADMIN', 'EDITOR'])
|
|
13
15
|
const SUBMIT_ROLES = new Set(['ADMIN', 'EDITOR', 'AUTHOR'])
|
|
@@ -33,12 +35,14 @@ export function resolveWorkflowChrome(input: {
|
|
|
33
35
|
waitingLabel?: string
|
|
34
36
|
statusLabel: string
|
|
35
37
|
statusClassName: string
|
|
38
|
+
saveLabel: string
|
|
36
39
|
} {
|
|
37
40
|
const role = input.role.toUpperCase()
|
|
38
41
|
const published = input.status === 'PUBLISHED'
|
|
39
42
|
const scheduled = input.status === 'SCHEDULED'
|
|
40
43
|
const pillKey = published ? 'PUBLISHED' : scheduled ? 'SCHEDULED' : input.stage
|
|
41
44
|
const pill = STATUS_PILL[pillKey]
|
|
45
|
+
const saveLabel = published ? UPDATE_LIVE_LABEL : SAVE_DRAFT_LABEL
|
|
42
46
|
|
|
43
47
|
if (!input.canEdit || published) {
|
|
44
48
|
return {
|
|
@@ -47,6 +51,7 @@ export function resolveWorkflowChrome(input: {
|
|
|
47
51
|
showSchedule: false,
|
|
48
52
|
statusLabel: pill.label,
|
|
49
53
|
statusClassName: pill.className,
|
|
54
|
+
saveLabel,
|
|
50
55
|
}
|
|
51
56
|
}
|
|
52
57
|
|
|
@@ -61,6 +66,7 @@ export function resolveWorkflowChrome(input: {
|
|
|
61
66
|
showSchedule: reviewer,
|
|
62
67
|
statusLabel: pill.label,
|
|
63
68
|
statusClassName: pill.className,
|
|
69
|
+
saveLabel,
|
|
64
70
|
}
|
|
65
71
|
}
|
|
66
72
|
|
|
@@ -71,6 +77,7 @@ export function resolveWorkflowChrome(input: {
|
|
|
71
77
|
showSchedule: false,
|
|
72
78
|
statusLabel: pill.label,
|
|
73
79
|
statusClassName: pill.className,
|
|
80
|
+
saveLabel,
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
|
|
@@ -81,6 +88,7 @@ export function resolveWorkflowChrome(input: {
|
|
|
81
88
|
showSchedule: true,
|
|
82
89
|
statusLabel: pill.label,
|
|
83
90
|
statusClassName: pill.className,
|
|
91
|
+
saveLabel,
|
|
84
92
|
}
|
|
85
93
|
}
|
|
86
94
|
|
|
@@ -92,6 +100,7 @@ export function resolveWorkflowChrome(input: {
|
|
|
92
100
|
waitingLabel: WAITING_FOR_REVIEW_LABEL,
|
|
93
101
|
statusLabel: pill.label,
|
|
94
102
|
statusClassName: pill.className,
|
|
103
|
+
saveLabel,
|
|
95
104
|
}
|
|
96
105
|
}
|
|
97
106
|
|
|
@@ -101,5 +110,6 @@ export function resolveWorkflowChrome(input: {
|
|
|
101
110
|
showSchedule: false,
|
|
102
111
|
statusLabel: pill.label,
|
|
103
112
|
statusClassName: pill.className,
|
|
113
|
+
saveLabel,
|
|
104
114
|
}
|
|
105
115
|
}
|
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 />}
|
|
@@ -403,7 +403,7 @@ export function EditorTopBar({
|
|
|
403
403
|
disabled={!canEdit || saveState === 'saving' || (!dirty && saveState !== 'error')}
|
|
404
404
|
className="border-border text-foreground hover:bg-accent rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
405
405
|
>
|
|
406
|
-
{saveState === 'saving' ? 'Saving…' :
|
|
406
|
+
{saveState === 'saving' ? 'Saving…' : workflow.saveLabel}
|
|
407
407
|
</button>
|
|
408
408
|
|
|
409
409
|
{onSchedule && workflow.showSchedule && (
|
|
@@ -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>
|