@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.
- package/CHANGELOG.md +12 -0
- package/dist/__tests__/views/post-fields-modal.render.test.js +3 -6
- package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.js +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/EditorSeoAside.d.ts +7 -4
- package/dist/components/EditorSeoAside.d.ts.map +1 -1
- package/dist/components/EditorSeoAside.js +7 -16
- package/dist/components/EditorSeoAside.js.map +1 -1
- package/dist/components/seo/EditorSeoScoreBadge.d.ts +7 -0
- package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -0
- package/dist/components/seo/EditorSeoScoreBadge.js +9 -0
- package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -0
- package/dist/components/seo/SeoAccordion.d.ts +16 -0
- package/dist/components/seo/SeoAccordion.d.ts.map +1 -0
- package/dist/components/seo/SeoAccordion.js +21 -0
- package/dist/components/seo/SeoAccordion.js.map +1 -0
- package/dist/components/seo/SeoAnalysisList.d.ts +7 -0
- package/dist/components/seo/SeoAnalysisList.d.ts.map +1 -0
- package/dist/components/seo/SeoAnalysisList.js +32 -0
- package/dist/components/seo/SeoAnalysisList.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +15 -4
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +131 -31
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/seo/SeoInsightsMetrics.d.ts +6 -0
- package/dist/components/seo/SeoInsightsMetrics.d.ts.map +1 -0
- package/dist/components/seo/SeoInsightsMetrics.js +16 -0
- package/dist/components/seo/SeoInsightsMetrics.js.map +1 -0
- package/dist/components/seo/SeoLinkingSection.d.ts +9 -0
- package/dist/components/seo/SeoLinkingSection.d.ts.map +1 -0
- package/dist/components/seo/SeoLinkingSection.js +44 -0
- package/dist/components/seo/SeoLinkingSection.js.map +1 -0
- package/dist/lib/editor-seo-content.d.ts +16 -0
- package/dist/lib/editor-seo-content.d.ts.map +1 -0
- package/dist/lib/editor-seo-content.js +139 -0
- package/dist/lib/editor-seo-content.js.map +1 -0
- package/dist/lib/seo-service.d.ts +2 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +5 -0
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +3 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +5 -4
- 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 +13 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +2 -13
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +17 -79
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +14 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +7 -1
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/views/post-fields-modal.render.test.tsx +3 -9
- package/src/__tests__/views/seo-content-pane.render.test.tsx +2 -0
- package/src/components/EditorSeoAside.tsx +27 -108
- package/src/components/seo/EditorSeoScoreBadge.tsx +26 -0
- package/src/components/seo/SeoAccordion.tsx +76 -0
- package/src/components/seo/SeoAnalysisList.tsx +113 -0
- package/src/components/seo/SeoEditorPane.tsx +497 -244
- package/src/components/seo/SeoInsightsMetrics.tsx +109 -0
- package/src/components/seo/SeoLinkingSection.tsx +151 -0
- package/src/lib/editor-seo-content.ts +144 -0
- package/src/lib/seo-service.ts +7 -0
- package/src/views/page-editor/EditorTopBar.tsx +7 -4
- package/src/views/page-editor/PageSectionEditor.tsx +15 -0
- package/src/views/page-editor/PageSettingsModal.tsx +1 -45
- package/src/views/post-editor/PostFieldsModal.tsx +19 -138
- package/src/views/post-editor/PostSectionEditor.tsx +17 -0
- 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'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, '&')
|
|
84
|
+
.replace(/</g, '<')
|
|
85
|
+
.replace(/>/g, '>')
|
|
86
|
+
.replace(/"/g, '"')
|
|
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
|
+
}
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -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=
|
|
207
|
-
title=
|
|
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
|
-
<
|
|
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
|
|
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
|
)
|