@actuate-media/cms-admin 0.74.0 → 0.75.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 (81) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/views/post-fields-modal.render.test.js +3 -6
  3. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  4. package/dist/__tests__/views/seo-content-pane.render.test.js +2 -0
  5. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
  6. package/dist/actuate-admin.css +1 -1
  7. package/dist/components/EditorSeoAside.d.ts +7 -4
  8. package/dist/components/EditorSeoAside.d.ts.map +1 -1
  9. package/dist/components/EditorSeoAside.js +7 -16
  10. package/dist/components/EditorSeoAside.js.map +1 -1
  11. package/dist/components/seo/EditorSeoScoreBadge.d.ts +7 -0
  12. package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -0
  13. package/dist/components/seo/EditorSeoScoreBadge.js +9 -0
  14. package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -0
  15. package/dist/components/seo/SeoAccordion.d.ts +16 -0
  16. package/dist/components/seo/SeoAccordion.d.ts.map +1 -0
  17. package/dist/components/seo/SeoAccordion.js +21 -0
  18. package/dist/components/seo/SeoAccordion.js.map +1 -0
  19. package/dist/components/seo/SeoAnalysisList.d.ts +7 -0
  20. package/dist/components/seo/SeoAnalysisList.d.ts.map +1 -0
  21. package/dist/components/seo/SeoAnalysisList.js +32 -0
  22. package/dist/components/seo/SeoAnalysisList.js.map +1 -0
  23. package/dist/components/seo/SeoEditorPane.d.ts +15 -4
  24. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  25. package/dist/components/seo/SeoEditorPane.js +131 -31
  26. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  27. package/dist/components/seo/SeoInsightsMetrics.d.ts +6 -0
  28. package/dist/components/seo/SeoInsightsMetrics.d.ts.map +1 -0
  29. package/dist/components/seo/SeoInsightsMetrics.js +16 -0
  30. package/dist/components/seo/SeoInsightsMetrics.js.map +1 -0
  31. package/dist/components/seo/SeoLinkingSection.d.ts +9 -0
  32. package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -0
  33. package/dist/components/seo/SeoLinkingSection.js +44 -0
  34. package/dist/components/seo/SeoLinkingSection.js.map +1 -0
  35. package/dist/lib/editor-seo-content.d.ts +16 -0
  36. package/dist/lib/editor-seo-content.d.ts.map +1 -0
  37. package/dist/lib/editor-seo-content.js +139 -0
  38. package/dist/lib/editor-seo-content.js.map +1 -0
  39. package/dist/lib/seo-service.d.ts +2 -0
  40. package/dist/lib/seo-service.d.ts.map +1 -1
  41. package/dist/lib/seo-service.js +5 -0
  42. package/dist/lib/seo-service.js.map +1 -1
  43. package/dist/views/page-editor/EditorTopBar.d.ts +3 -1
  44. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  45. package/dist/views/page-editor/EditorTopBar.js +5 -4
  46. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  47. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  48. package/dist/views/page-editor/PageSectionEditor.js +13 -1
  49. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  50. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  51. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  52. package/dist/views/page-editor/PageSettingsModal.js +2 -13
  53. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  54. package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
  55. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  56. package/dist/views/post-editor/PostFieldsModal.js +17 -79
  57. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  58. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  59. package/dist/views/post-editor/PostSectionEditor.js +14 -1
  60. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  61. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  62. package/dist/views/seo/ContentTab.js +7 -1
  63. package/dist/views/seo/ContentTab.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/__tests__/views/post-fields-modal.render.test.tsx +3 -9
  66. package/src/__tests__/views/seo-content-pane.render.test.tsx +2 -0
  67. package/src/components/EditorSeoAside.tsx +27 -108
  68. package/src/components/seo/EditorSeoScoreBadge.tsx +26 -0
  69. package/src/components/seo/SeoAccordion.tsx +76 -0
  70. package/src/components/seo/SeoAnalysisList.tsx +113 -0
  71. package/src/components/seo/SeoEditorPane.tsx +497 -244
  72. package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
  73. package/src/components/seo/SeoLinkingSection.tsx +151 -0
  74. package/src/lib/editor-seo-content.ts +144 -0
  75. package/src/lib/seo-service.ts +7 -0
  76. package/src/views/page-editor/EditorTopBar.tsx +7 -4
  77. package/src/views/page-editor/PageSectionEditor.tsx +15 -0
  78. package/src/views/page-editor/PageSettingsModal.tsx +1 -45
  79. package/src/views/post-editor/PostFieldsModal.tsx +19 -138
  80. package/src/views/post-editor/PostSectionEditor.tsx +17 -0
  81. package/src/views/seo/ContentTab.tsx +11 -5
@@ -0,0 +1,109 @@
1
+ 'use client'
2
+
3
+ import type { ReadabilityResult } from '@actuate-media/cms-core/seo/analysis'
4
+ import { prominentWordsFromText } from '../../lib/editor-seo-content.js'
5
+
6
+ export function SeoInsightsMetrics({
7
+ readability,
8
+ plainText,
9
+ }: {
10
+ readability: ReadabilityResult
11
+ plainText: string
12
+ }) {
13
+ const prominent = prominentWordsFromText(plainText)
14
+ const needsMoreCopy = readability.wordCount < 50
15
+
16
+ return (
17
+ <div className="space-y-4">
18
+ <div>
19
+ <h4 className="mb-1.5 text-[12.5px] font-medium text-[var(--txt)]">Prominent words</h4>
20
+ {needsMoreCopy ? (
21
+ <p className="text-[11.5px] text-[var(--sub)]">
22
+ Once you add more copy, we&apos;ll list the words that occur most in the content — a
23
+ quick read on what the page focuses on.
24
+ </p>
25
+ ) : prominent.length > 0 ? (
26
+ <div className="flex flex-wrap gap-1.5">
27
+ {prominent.map((word) => (
28
+ <span
29
+ key={word}
30
+ className="rounded-full border border-[var(--bdr)] bg-[var(--card)] px-2 py-0.5 text-[11.5px] text-[var(--txt)]"
31
+ >
32
+ {word}
33
+ </span>
34
+ ))}
35
+ </div>
36
+ ) : (
37
+ <p className="text-[11.5px] text-[var(--sub)]">No recurring terms yet.</p>
38
+ )}
39
+ </div>
40
+
41
+ <div className="grid grid-cols-2 gap-3">
42
+ <MetricCard
43
+ label="Reading time"
44
+ value={String(readability.readingTimeMinutes)}
45
+ unit="minutes"
46
+ hint={
47
+ needsMoreCopy
48
+ ? 'Continue writing to estimate reading time.'
49
+ : `${readability.wordCount} words at ~200 wpm`
50
+ }
51
+ />
52
+ <MetricCard
53
+ label="Word count"
54
+ value={String(readability.wordCount)}
55
+ unit="words"
56
+ hint={needsMoreCopy ? 'Add body content to unlock metrics.' : undefined}
57
+ />
58
+ </div>
59
+
60
+ <div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
61
+ <div className="mb-1 text-[12.5px] font-medium text-[var(--txt)]">Flesch reading ease</div>
62
+ {needsMoreCopy ? (
63
+ <p className="text-[11.5px] text-[var(--sub)]">
64
+ Your text should be slightly longer to calculate readability. Keep writing to unlock
65
+ this score.
66
+ </p>
67
+ ) : (
68
+ <>
69
+ <div className="flex items-baseline gap-2">
70
+ <span className="text-2xl font-medium text-[var(--acc)]">
71
+ {readability.fleschScore}
72
+ </span>
73
+ <span className="text-[11.5px] text-[var(--muted)]">out of 100</span>
74
+ </div>
75
+ <p className="mt-1 text-[11.5px] text-[var(--sub)]">
76
+ {readability.fleschLabel} —{' '}
77
+ {readability.fleschScore >= 60
78
+ ? 'Content is fairly easy to read.'
79
+ : 'Try shorter sentences and simpler words.'}
80
+ </p>
81
+ </>
82
+ )}
83
+ </div>
84
+ </div>
85
+ )
86
+ }
87
+
88
+ function MetricCard({
89
+ label,
90
+ value,
91
+ unit,
92
+ hint,
93
+ }: {
94
+ label: string
95
+ value: string
96
+ unit: string
97
+ hint?: string
98
+ }) {
99
+ return (
100
+ <div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
101
+ <div className="text-[11.5px] font-medium text-[var(--sub)]">{label}</div>
102
+ <div className="mt-1 flex items-baseline gap-1">
103
+ <span className="text-xl font-medium text-[var(--acc)]">{value}</span>
104
+ <span className="text-[11px] text-[var(--muted)]">{unit}</span>
105
+ </div>
106
+ {hint && <p className="mt-1 text-[10.5px] text-[var(--muted)]">{hint}</p>}
107
+ </div>
108
+ )
109
+ }
@@ -0,0 +1,151 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+ import { Link2, Loader2, Sparkles } from 'lucide-react'
5
+ import { fetchLinkSuggestions, type LinkSuggestion } from '../../lib/seo-service.js'
6
+
7
+ export function SeoLinkingSection({
8
+ entityType,
9
+ entityId,
10
+ internalLinks,
11
+ externalLinks,
12
+ internalLinkOk,
13
+ externalLinkOk,
14
+ }: {
15
+ entityType: 'page' | 'post'
16
+ entityId: string | null
17
+ internalLinks: number
18
+ externalLinks: number
19
+ internalLinkOk: boolean
20
+ externalLinkOk: boolean
21
+ }) {
22
+ const [loading, setLoading] = useState(false)
23
+ const [suggestions, setSuggestions] = useState<LinkSuggestion[]>([])
24
+ const [error, setError] = useState<string | null>(null)
25
+
26
+ useEffect(() => {
27
+ if (!entityId) {
28
+ setSuggestions([])
29
+ setError(null)
30
+ return
31
+ }
32
+ let alive = true
33
+ setLoading(true)
34
+ setError(null)
35
+ fetchLinkSuggestions({ targetEntityType: entityType, targetEntityId: entityId, limit: 5 })
36
+ .then((res) => {
37
+ if (!alive) return
38
+ if (res.error) {
39
+ setError(res.error)
40
+ setSuggestions([])
41
+ return
42
+ }
43
+ setSuggestions(res.data?.suggestions ?? [])
44
+ })
45
+ .finally(() => alive && setLoading(false))
46
+ return () => {
47
+ alive = false
48
+ }
49
+ }, [entityType, entityId])
50
+
51
+ return (
52
+ <div className="space-y-4">
53
+ <div className="grid grid-cols-2 gap-3">
54
+ <LinkCountCard
55
+ label="Internal links"
56
+ count={internalLinks}
57
+ ok={internalLinkOk}
58
+ hint={
59
+ internalLinkOk
60
+ ? 'Links to other pages on your site.'
61
+ : 'Add links to related content — aim for at least one.'
62
+ }
63
+ />
64
+ <LinkCountCard
65
+ label="Outbound links"
66
+ count={externalLinks}
67
+ ok={externalLinkOk}
68
+ hint={
69
+ externalLinkOk
70
+ ? 'Links to external sources.'
71
+ : 'Consider linking to authoritative external sources.'
72
+ }
73
+ />
74
+ </div>
75
+
76
+ {!entityId ? (
77
+ <p className="rounded-[7px] border border-[var(--bdr)] bg-[var(--bg)] p-3 text-[11.5px] text-[var(--sub)]">
78
+ Save a draft first to load AI link opportunities for this page.
79
+ </p>
80
+ ) : loading ? (
81
+ <div className="flex items-center gap-2 text-[12px] text-[var(--muted)]">
82
+ <Loader2 className="h-4 w-4 animate-spin" aria-hidden />
83
+ Finding link opportunities…
84
+ </div>
85
+ ) : error ? (
86
+ <p className="text-[11.5px] text-[var(--err)]">{error}</p>
87
+ ) : suggestions.length > 0 ? (
88
+ <div>
89
+ <div className="mb-2 flex items-center gap-1.5 text-[12px] font-medium text-[var(--txt)]">
90
+ <Sparkles className="h-3.5 w-3.5 text-[var(--acc)]" aria-hidden />
91
+ Link opportunities
92
+ </div>
93
+ <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
+ ))}
109
+ </ul>
110
+ </div>
111
+ ) : (
112
+ <p className="text-[11.5px] text-[var(--sub)]">
113
+ No inbound link suggestions yet. Run the site link index from SEO → Links to improve AI
114
+ recommendations.
115
+ </p>
116
+ )}
117
+
118
+ <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
+ <Link2 className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--acc)]" aria-hidden />
120
+ <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.
123
+ </span>
124
+ </div>
125
+ </div>
126
+ )
127
+ }
128
+
129
+ function LinkCountCard({
130
+ label,
131
+ count,
132
+ ok,
133
+ hint,
134
+ }: {
135
+ label: string
136
+ count: number
137
+ ok: boolean
138
+ hint: string
139
+ }) {
140
+ return (
141
+ <div className="rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] p-3">
142
+ <div className="flex items-center justify-between gap-2">
143
+ <span className="text-[11.5px] font-medium text-[var(--sub)]">{label}</span>
144
+ <span className={`text-lg font-medium ${ok ? 'text-[var(--suc)]' : 'text-[var(--warn)]'}`}>
145
+ {count}
146
+ </span>
147
+ </div>
148
+ <p className="mt-1 text-[10.5px] text-[var(--muted)]">{hint}</p>
149
+ </div>
150
+ )
151
+ }
@@ -0,0 +1,144 @@
1
+ import { getSectionType, type PageSection } from '@actuate-media/cms-core/page-sections'
2
+ import { analyzeContent } from '@actuate-media/cms-core/seo/analysis'
3
+
4
+ const HTML_LIKE = /<\/?[a-z][\s\S]*>/i
5
+
6
+ /** Build an HTML-ish string from editor sections (+ optional post body) for SEO analysis. */
7
+ export function buildEditorContentHtml(sections: PageSection[], body = ''): string {
8
+ const parts: string[] = []
9
+ if (body.trim()) parts.push(body)
10
+ for (const section of sections) {
11
+ if (section.visible === false) continue
12
+ const chunk = extractSectionContentHtml(section)
13
+ if (chunk) parts.push(chunk)
14
+ }
15
+ return parts.join('\n')
16
+ }
17
+
18
+ /** Live 0–100 SEO score from the current in-editor document state. */
19
+ export function computeLiveSeoScore(input: {
20
+ title: string
21
+ slug: string
22
+ seoTitle: string
23
+ seoDescription: string
24
+ focusKeyword?: string
25
+ sections: PageSection[]
26
+ bodyHtml?: string
27
+ }): number {
28
+ const content = buildEditorContentHtml(input.sections, input.bodyHtml ?? '')
29
+ return analyzeContent({
30
+ title: input.title,
31
+ slug: input.slug,
32
+ content,
33
+ metaTitle: input.seoTitle,
34
+ metaDescription: input.seoDescription,
35
+ focusKeyphrase: input.focusKeyword,
36
+ }).score
37
+ }
38
+
39
+ function extractSectionContentHtml(section: PageSection): string {
40
+ const content = section.content
41
+ if (!content || typeof content !== 'object') return ''
42
+ const def = getSectionType(section.sectionType)
43
+ const chunks: string[] = []
44
+
45
+ if (def) {
46
+ for (const field of def.fields) {
47
+ const val = content[field.key]
48
+ if (typeof val !== 'string' || !val.trim()) continue
49
+ if (field.type === 'richText' || HTML_LIKE.test(val)) {
50
+ chunks.push(val)
51
+ } else if (/heading|title|subtitle|eyebrow/i.test(field.key)) {
52
+ chunks.push(`<h2>${escapeHtml(val.trim())}</h2>`)
53
+ } else if (/body|content|text|copy|description|excerpt|quote|answer/i.test(field.key)) {
54
+ chunks.push(`<p>${escapeHtml(val.trim())}</p>`)
55
+ }
56
+ }
57
+ } else {
58
+ collectStringValues(content, chunks, 0)
59
+ }
60
+
61
+ return chunks.join('\n')
62
+ }
63
+
64
+ function collectStringValues(obj: Record<string, unknown>, chunks: string[], depth: number): void {
65
+ if (depth > 6) return
66
+ for (const value of Object.values(obj)) {
67
+ if (typeof value === 'string' && value.trim()) {
68
+ chunks.push(HTML_LIKE.test(value) ? value : `<p>${escapeHtml(value.trim())}</p>`)
69
+ } else if (Array.isArray(value)) {
70
+ for (const item of value) {
71
+ if (item && typeof item === 'object') {
72
+ collectStringValues(item as Record<string, unknown>, chunks, depth + 1)
73
+ }
74
+ }
75
+ } else if (value && typeof value === 'object') {
76
+ collectStringValues(value as Record<string, unknown>, chunks, depth + 1)
77
+ }
78
+ }
79
+ }
80
+
81
+ function escapeHtml(text: string): string {
82
+ return text
83
+ .replace(/&/g, '&amp;')
84
+ .replace(/</g, '&lt;')
85
+ .replace(/>/g, '&gt;')
86
+ .replace(/"/g, '&quot;')
87
+ }
88
+
89
+ const STOP_WORDS = new Set([
90
+ 'about',
91
+ 'after',
92
+ 'also',
93
+ 'been',
94
+ 'before',
95
+ 'being',
96
+ 'between',
97
+ 'both',
98
+ 'could',
99
+ 'each',
100
+ 'from',
101
+ 'have',
102
+ 'into',
103
+ 'more',
104
+ 'most',
105
+ 'other',
106
+ 'over',
107
+ 'some',
108
+ 'such',
109
+ 'than',
110
+ 'that',
111
+ 'their',
112
+ 'them',
113
+ 'then',
114
+ 'there',
115
+ 'these',
116
+ 'they',
117
+ 'this',
118
+ 'through',
119
+ 'under',
120
+ 'very',
121
+ 'were',
122
+ 'what',
123
+ 'when',
124
+ 'where',
125
+ 'which',
126
+ 'while',
127
+ 'with',
128
+ 'would',
129
+ 'your',
130
+ ])
131
+
132
+ /** Top content words for the Insights accordion (excluding common stop words). */
133
+ export function prominentWordsFromText(plainText: string, limit = 8): string[] {
134
+ const counts = new Map<string, number>()
135
+ for (const raw of plainText.toLowerCase().split(/\s+/)) {
136
+ const word = raw.replace(/[^a-z0-9'-]/g, '')
137
+ if (word.length < 4 || STOP_WORDS.has(word)) continue
138
+ counts.set(word, (counts.get(word) ?? 0) + 1)
139
+ }
140
+ return [...counts.entries()]
141
+ .sort((a, b) => b[1] - a[1])
142
+ .slice(0, limit)
143
+ .map(([word]) => word)
144
+ }
@@ -9,6 +9,7 @@
9
9
  * `packages/cms-core/src/api/handlers.ts` across phases 2-3 and 6.
10
10
  */
11
11
  import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
12
+ import type { SEOAnalysisResult } from '@actuate-media/cms-core/seo/analysis'
12
13
  import { cmsApi } from './api.js'
13
14
 
14
15
  /**
@@ -239,6 +240,12 @@ export interface SeoFieldPayload {
239
240
  llmsOptional?: boolean
240
241
  }
241
242
 
243
+ export async function fetchDocumentSeoAnalysis(documentId: string): Promise<SEOAnalysisResult> {
244
+ const res = await cmsApi<SEOAnalysisResult>(`/seo/analysis/${encodeURIComponent(documentId)}`)
245
+ throwIfError(res)
246
+ return res.data ?? { score: 0, checks: [] }
247
+ }
248
+
242
249
  export async function updateSeoFields(
243
250
  entityType: string,
244
251
  entityId: string,
@@ -10,12 +10,12 @@ import {
10
10
  Loader2,
11
11
  type LucideIcon,
12
12
  Monitor,
13
- Search,
14
13
  Settings2,
15
14
  Smartphone,
16
15
  Tablet,
17
16
  TriangleAlert,
18
17
  } from 'lucide-react'
18
+ import { EditorSeoScoreBadge } from '../../components/seo/EditorSeoScoreBadge.js'
19
19
  import type { PageStatus } from '../../lib/page-editor-service.js'
20
20
  import { VIEWPORTS, type ViewportId } from './viewports.js'
21
21
 
@@ -61,6 +61,8 @@ interface EditorTopBarProps {
61
61
  /** Open the document SEO panel (meta, social, schema, analysis). */
62
62
  onOpenSeo?: () => void
63
63
  seoOpen?: boolean
64
+ /** Live 0–100 SEO score shown left of the SEO label. */
65
+ seoScore?: number
64
66
  onSaveDraft: () => void
65
67
  onPublish: () => void
66
68
  /**
@@ -108,6 +110,7 @@ export function EditorTopBar({
108
110
  onOpenSettings,
109
111
  onOpenSeo,
110
112
  seoOpen = false,
113
+ seoScore = 0,
111
114
  onSaveDraft,
112
115
  onPublish,
113
116
  structuralTypes = [],
@@ -203,15 +206,15 @@ export function EditorTopBar({
203
206
  type="button"
204
207
  onClick={onOpenSeo}
205
208
  aria-pressed={seoOpen}
206
- aria-label="SEO"
207
- title="SEO — meta tags, social preview, schema, and analysis"
209
+ aria-label={`SEO, score ${seoScore} out of 100`}
210
+ title={`SEO — score ${seoScore}/100. Meta tags, social preview, schema, and analysis.`}
208
211
  className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors ${
209
212
  seoOpen
210
213
  ? 'bg-primary text-primary-foreground'
211
214
  : 'text-muted-foreground hover:text-foreground hover:bg-accent'
212
215
  }`}
213
216
  >
214
- <Search className="h-4 w-4 shrink-0" aria-hidden />
217
+ <EditorSeoScoreBadge score={seoScore} inverted={seoOpen} />
215
218
  <span className="hidden sm:inline">SEO</span>
216
219
  </button>
217
220
  )}
@@ -45,6 +45,7 @@ import { AddSectionModal } from './AddSectionModal.js'
45
45
  import { PageSettingsModal } from './PageSettingsModal.js'
46
46
  import { ValidationSummary } from './ValidationSummary.js'
47
47
  import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
48
+ import { computeLiveSeoScore } from '../../lib/editor-seo-content.js'
48
49
 
49
50
  interface PageSectionEditorProps {
50
51
  /** undefined when creating a new page (`/pages/new`) */
@@ -128,6 +129,17 @@ export function PageSectionEditor({
128
129
  [page],
129
130
  )
130
131
  const publishReady = Boolean(page) && publishCheck.errors.length === 0
132
+
133
+ const liveSeoScore = useMemo(() => {
134
+ if (!page) return 0
135
+ return computeLiveSeoScore({
136
+ title: page.title,
137
+ slug: page.slug,
138
+ seoTitle: page.seoTitle,
139
+ seoDescription: page.seoDescription,
140
+ sections: page.sections,
141
+ })
142
+ }, [page])
131
143
  const publishDisabledReason = publishCheck.errors[0]
132
144
 
133
145
  // ─── Load ──────────────────────────────────────────────────────────
@@ -518,6 +530,7 @@ export function PageSectionEditor({
518
530
  onOpenSettings={() => setSettingsOpen(true)}
519
531
  onOpenSeo={handleToggleSeo}
520
532
  seoOpen={seoOpen}
533
+ seoScore={liveSeoScore}
521
534
  onSaveDraft={handleSaveDraft}
522
535
  onPublish={requestPublish}
523
536
  structuralTypes={structuralTypes}
@@ -622,8 +635,10 @@ export function PageSectionEditor({
622
635
  entityType="page"
623
636
  entityId={page.id}
624
637
  title={page.title}
638
+ slug={page.slug}
625
639
  seoTitle={page.seoTitle}
626
640
  seoDescription={page.seoDescription}
641
+ sections={page.sections}
627
642
  canEdit={effectiveCanEdit}
628
643
  onClose={() => setSeoOpen(false)}
629
644
  onDraftChange={handlePageSettings}
@@ -4,15 +4,9 @@ import { useEffect, useRef, useState } from 'react'
4
4
  import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
5
5
  import { Modal } from '../../components/ui/Modal.js'
6
6
  import { Button } from '../../components/ui/Button.js'
7
- import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
8
7
  import { cmsApi } from '../../lib/api.js'
9
8
  import type { EditorPage } from '../../lib/page-editor-service.js'
10
9
 
11
- const SEO_TITLE_MIN = 30
12
- const SEO_TITLE_MAX = 60
13
- const SEO_DESC_MIN = 120
14
- const SEO_DESC_MAX = 160
15
-
16
10
  interface PageSettingsModalProps {
17
11
  open: boolean
18
12
  page: EditorPage
@@ -32,7 +26,7 @@ interface ParentOption {
32
26
  title: string
33
27
  }
34
28
 
35
- /** Edits page-level fields: title, slug, path, parent page, and SEO. */
29
+ /** Edits page-level fields: title, slug, path, and parent page. SEO lives in the SEO tab. */
36
30
  export function PageSettingsModal({
37
31
  open,
38
32
  page,
@@ -44,8 +38,6 @@ export function PageSettingsModal({
44
38
  const [slug, setSlug] = useState(page.slug)
45
39
  const [path, setPath] = useState(page.path)
46
40
  const [parentPageId, setParentPageId] = useState(page.parentPageId ?? '')
47
- const [seoTitle, setSeoTitle] = useState(page.seoTitle)
48
- const [seoDescription, setSeoDescription] = useState(page.seoDescription)
49
41
  // Auto-derive slug/path from title until the user edits slug manually.
50
42
  const [slugTouched, setSlugTouched] = useState(Boolean(page.slug))
51
43
  const [parentOptions, setParentOptions] = useState<ParentOption[] | null>(null)
@@ -59,8 +51,6 @@ export function PageSettingsModal({
59
51
  setSlug(page.slug)
60
52
  setPath(page.path)
61
53
  setParentPageId(page.parentPageId ?? '')
62
- setSeoTitle(page.seoTitle)
63
- setSeoDescription(page.seoDescription)
64
54
  setSlugTouched(Boolean(page.slug))
65
55
  }, [open, page])
66
56
 
@@ -110,8 +100,6 @@ export function PageSettingsModal({
110
100
  slug,
111
101
  path: path || (slug ? `/${slug}` : '/'),
112
102
  parentPageId: parentPageId || null,
113
- seoTitle,
114
- seoDescription,
115
103
  })
116
104
  onClose()
117
105
  }
@@ -231,38 +219,6 @@ export function PageSettingsModal({
231
219
  Nests this page under a parent in the Pages tree view.
232
220
  </p>
233
221
  </div>
234
- <div>
235
- <div className="mb-1 flex items-center justify-between">
236
- <label className={`${LABEL} mb-0`} htmlFor="ps-seo-title">
237
- SEO title
238
- </label>
239
- <SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
240
- </div>
241
- <input
242
- id="ps-seo-title"
243
- className={INPUT}
244
- value={seoTitle}
245
- disabled={!canEdit}
246
- placeholder={title || 'Defaults to the page title'}
247
- onChange={(e) => setSeoTitle(e.target.value)}
248
- />
249
- </div>
250
- <div>
251
- <div className="mb-1 flex items-center justify-between">
252
- <label className={`${LABEL} mb-0`} htmlFor="ps-seo-desc">
253
- SEO description
254
- </label>
255
- <SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
256
- </div>
257
- <textarea
258
- id="ps-seo-desc"
259
- className={`${INPUT} min-h-[64px] resize-y`}
260
- value={seoDescription}
261
- disabled={!canEdit}
262
- placeholder="Summarize the page for search results (120–160 characters)"
263
- onChange={(e) => setSeoDescription(e.target.value)}
264
- />
265
- </div>
266
222
  </form>
267
223
  </Modal>
268
224
  )