@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/components/seo-editor-pane.render.test.js +2 -1
  3. package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -1
  4. package/dist/__tests__/components/seo-issue-fix-panel.render.test.d.ts +2 -0
  5. package/dist/__tests__/components/seo-issue-fix-panel.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js +60 -0
  7. package/dist/__tests__/components/seo-issue-fix-panel.render.test.js.map +1 -0
  8. package/dist/__tests__/components/seo-linking-section.render.test.d.ts +2 -0
  9. package/dist/__tests__/components/seo-linking-section.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/components/seo-linking-section.render.test.js +94 -0
  11. package/dist/__tests__/components/seo-linking-section.render.test.js.map +1 -0
  12. package/dist/__tests__/lib/collection-routes.test.js +8 -1
  13. package/dist/__tests__/lib/collection-routes.test.js.map +1 -1
  14. package/dist/__tests__/lib/page-editor-service.test.js +7 -0
  15. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  16. package/dist/__tests__/lib/seo-service-errors.test.js +24 -1
  17. package/dist/__tests__/lib/seo-service-errors.test.js.map +1 -1
  18. package/dist/__tests__/lib/workflow-chrome.test.js +4 -0
  19. package/dist/__tests__/lib/workflow-chrome.test.js.map +1 -1
  20. package/dist/__tests__/views/editor-top-bar.render.test.js +5 -0
  21. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  22. package/dist/__tests__/views/seo-content-pane.render.test.js +1 -0
  23. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
  24. package/dist/components/seo/SeoEditorPane.d.ts +2 -1
  25. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  26. package/dist/components/seo/SeoEditorPane.js +2 -2
  27. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  28. package/dist/components/seo/SeoIssueFixPanel.d.ts.map +1 -1
  29. package/dist/components/seo/SeoIssueFixPanel.js +32 -8
  30. package/dist/components/seo/SeoIssueFixPanel.js.map +1 -1
  31. package/dist/components/seo/SeoLinkingSection.d.ts +5 -1
  32. package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -1
  33. package/dist/components/seo/SeoLinkingSection.js +50 -3
  34. package/dist/components/seo/SeoLinkingSection.js.map +1 -1
  35. package/dist/lib/collection-routes.d.ts +2 -0
  36. package/dist/lib/collection-routes.d.ts.map +1 -1
  37. package/dist/lib/collection-routes.js +5 -0
  38. package/dist/lib/collection-routes.js.map +1 -1
  39. package/dist/lib/seo-service.d.ts +12 -1
  40. package/dist/lib/seo-service.d.ts.map +1 -1
  41. package/dist/lib/seo-service.js +10 -0
  42. package/dist/lib/seo-service.js.map +1 -1
  43. package/dist/lib/workflow-chrome.d.ts +3 -0
  44. package/dist/lib/workflow-chrome.d.ts.map +1 -1
  45. package/dist/lib/workflow-chrome.js +9 -0
  46. package/dist/lib/workflow-chrome.js.map +1 -1
  47. package/dist/views/SEO.d.ts.map +1 -1
  48. package/dist/views/SEO.js +1 -1
  49. package/dist/views/SEO.js.map +1 -1
  50. package/dist/views/page-editor/EditorTopBar.js +1 -1
  51. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  52. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  53. package/dist/views/page-editor/PageSectionEditor.js +1 -1
  54. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  55. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  56. package/dist/views/post-editor/PostSectionEditor.js +1 -1
  57. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  58. package/dist/views/seo/ContentTab.d.ts +2 -1
  59. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  60. package/dist/views/seo/ContentTab.js +2 -2
  61. package/dist/views/seo/ContentTab.js.map +1 -1
  62. package/dist/views/seo/LinksTab.d.ts +3 -1
  63. package/dist/views/seo/LinksTab.d.ts.map +1 -1
  64. package/dist/views/seo/LinksTab.js +36 -6
  65. package/dist/views/seo/LinksTab.js.map +1 -1
  66. package/package.json +4 -4
  67. package/src/__tests__/components/seo-editor-pane.render.test.tsx +4 -1
  68. package/src/__tests__/components/seo-issue-fix-panel.render.test.tsx +65 -0
  69. package/src/__tests__/components/seo-linking-section.render.test.tsx +146 -0
  70. package/src/__tests__/lib/collection-routes.test.ts +13 -1
  71. package/src/__tests__/lib/page-editor-service.test.ts +8 -0
  72. package/src/__tests__/lib/seo-service-errors.test.ts +29 -0
  73. package/src/__tests__/lib/workflow-chrome.test.ts +5 -0
  74. package/src/__tests__/views/editor-top-bar.render.test.tsx +6 -0
  75. package/src/__tests__/views/seo-content-pane.render.test.tsx +1 -0
  76. package/src/components/seo/SeoEditorPane.tsx +3 -0
  77. package/src/components/seo/SeoIssueFixPanel.tsx +34 -10
  78. package/src/components/seo/SeoLinkingSection.tsx +116 -18
  79. package/src/lib/collection-routes.ts +13 -0
  80. package/src/lib/seo-service.ts +19 -1
  81. package/src/lib/workflow-chrome.ts +10 -0
  82. package/src/views/SEO.tsx +2 -1
  83. package/src/views/page-editor/EditorTopBar.tsx +1 -1
  84. package/src/views/page-editor/PageSectionEditor.tsx +1 -0
  85. package/src/views/post-editor/PostSectionEditor.tsx +1 -0
  86. package/src/views/seo/ContentTab.tsx +3 -0
  87. package/src/views/seo/LinksTab.tsx +108 -19
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import { editPathForDoc, newPathForCollection } from '../../lib/collection-routes.js'
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 { fetchLinkSuggestions, type LinkSuggestion } from '../../lib/seo-service.js'
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
- <li
96
- key={`${s.sourceEntityId}-${s.anchorText}`}
97
- className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-2.5"
98
- >
99
- <div className="text-[12.5px] font-medium text-[var(--txt)]">{s.sourceTitle}</div>
100
- <p className="mt-0.5 text-[11px] text-[var(--muted)]">{s.sourceUrl}</p>
101
- <p className="mt-1 text-[11.5px] text-[var(--sub)]">
102
- Suggested anchor: <span className="text-[var(--txt)]">{s.anchorText}</span>
103
- </p>
104
- {s.excerpt && (
105
- <p className="mt-1 line-clamp-2 text-[11px] text-[var(--muted)]">{s.excerpt}</p>
106
- )}
107
- </li>
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
- Best practice: mix internal links (site structure + crawl paths) with selective outbound
122
- links (trust signals). We flag counts here; apply links in the content editor.
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,
@@ -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…' : 'Save Draft'}
406
+ {saveState === 'saving' ? 'Saving…' : workflow.saveLabel}
407
407
  </button>
408
408
 
409
409
  {onSchedule && workflow.showSchedule && (
@@ -1013,6 +1013,7 @@ export function PageSectionEditor({
1013
1013
  onDraftChange={handlePageSettings}
1014
1014
  onClose={() => setRailTab('sections')}
1015
1015
  onSaved={handleSeoSaved}
1016
+ onNavigate={onNavigate}
1016
1017
  />
1017
1018
  )}
1018
1019
  </div>
@@ -849,6 +849,7 @@ export function PostSectionEditor({
849
849
  onDraftChange={handleFields}
850
850
  onClose={() => setRailTab('sections')}
851
851
  onSaved={handleSeoSaved}
852
+ onNavigate={onNavigate}
852
853
  />
853
854
  )}
854
855
  </div>
@@ -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>