@actuate-media/cms-admin 0.74.0 → 0.76.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 +18 -0
- package/dist/__tests__/lib/post-editor-service.test.js +2 -0
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/editor-top-bar.render.test.js +12 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/post-fields-modal.render.test.js +5 -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/page-editor-service.d.ts +2 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +4 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts +3 -0
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +4 -0
- package/dist/lib/post-editor-service.js.map +1 -1
- 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 +9 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +38 -6
- 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 +47 -2
- 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 +48 -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 +3 -3
- package/src/__tests__/lib/post-editor-service.test.ts +2 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +24 -0
- package/src/__tests__/views/post-fields-modal.render.test.tsx +5 -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/page-editor-service.ts +8 -0
- package/src/lib/post-editor-service.ts +8 -0
- package/src/lib/seo-service.ts +7 -0
- package/src/views/page-editor/EditorTopBar.tsx +76 -4
- package/src/views/page-editor/PageSectionEditor.tsx +70 -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 +72 -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
|
+
}
|
|
@@ -75,6 +75,8 @@ export interface EditorPage {
|
|
|
75
75
|
seoDescription: string
|
|
76
76
|
publishedAt: string | null
|
|
77
77
|
updatedAt: string | null
|
|
78
|
+
scheduledAt: string | null
|
|
79
|
+
scheduledUnpublishAt: string | null
|
|
78
80
|
/**
|
|
79
81
|
* True when the document was authored in the visual Page Builder (it carries
|
|
80
82
|
* a `_layout` tree). Such a page's `sections` are *compiled* from the layout
|
|
@@ -176,6 +178,8 @@ interface DocumentApiRow {
|
|
|
176
178
|
slug: string | null
|
|
177
179
|
status: PageStatus
|
|
178
180
|
publishedAt: string | null
|
|
181
|
+
scheduledAt?: string | null
|
|
182
|
+
scheduledUnpublishAt?: string | null
|
|
179
183
|
updatedAt: string | null
|
|
180
184
|
data: Record<string, unknown> | null
|
|
181
185
|
/** The page-builder tree, lifted out of `data` onto the wire envelope by the API. */
|
|
@@ -207,6 +211,8 @@ function rowToEditorPage(doc: DocumentApiRow): EditorPage {
|
|
|
207
211
|
seoDescription: typeof data.metaDescription === 'string' ? data.metaDescription : '',
|
|
208
212
|
publishedAt: doc.publishedAt ?? null,
|
|
209
213
|
updatedAt: doc.updatedAt ?? null,
|
|
214
|
+
scheduledAt: doc.scheduledAt ?? null,
|
|
215
|
+
scheduledUnpublishAt: doc.scheduledUnpublishAt ?? null,
|
|
210
216
|
builderOwned,
|
|
211
217
|
}
|
|
212
218
|
}
|
|
@@ -302,6 +308,8 @@ export function emptyPage(parentPageId: string | null = null): EditorPage {
|
|
|
302
308
|
seoDescription: '',
|
|
303
309
|
publishedAt: null,
|
|
304
310
|
updatedAt: null,
|
|
311
|
+
scheduledAt: null,
|
|
312
|
+
scheduledUnpublishAt: null,
|
|
305
313
|
builderOwned: false,
|
|
306
314
|
}
|
|
307
315
|
}
|
|
@@ -78,6 +78,9 @@ export interface EditorPost {
|
|
|
78
78
|
tags: string[]
|
|
79
79
|
status: PostStatus
|
|
80
80
|
publishDate: string | null
|
|
81
|
+
/** When set and status is SCHEDULED, the cron publishes at this time. */
|
|
82
|
+
scheduledAt: string | null
|
|
83
|
+
scheduledUnpublishAt: string | null
|
|
81
84
|
sections: PageSection[]
|
|
82
85
|
seoTitle: string
|
|
83
86
|
seoDescription: string
|
|
@@ -189,6 +192,7 @@ interface DocumentApiRow {
|
|
|
189
192
|
status: PostStatus
|
|
190
193
|
publishedAt: string | null
|
|
191
194
|
scheduledAt?: string | null
|
|
195
|
+
scheduledUnpublishAt?: string | null
|
|
192
196
|
updatedAt: string | null
|
|
193
197
|
data: Record<string, unknown> | null
|
|
194
198
|
}
|
|
@@ -235,6 +239,8 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
|
|
|
235
239
|
tags: parseTags(data.tags),
|
|
236
240
|
status: doc.status ?? 'DRAFT',
|
|
237
241
|
publishDate: (data.publishDate as string) ?? doc.scheduledAt ?? null,
|
|
242
|
+
scheduledAt: doc.scheduledAt ?? null,
|
|
243
|
+
scheduledUnpublishAt: doc.scheduledUnpublishAt ?? null,
|
|
238
244
|
// Preserve unknown (externally-managed) sections so the editor can show
|
|
239
245
|
// them read-only and round-trip them on save instead of dropping them.
|
|
240
246
|
sections: coerceSections(data.sections, { onUnknown: 'preserve' }),
|
|
@@ -417,6 +423,8 @@ export async function emptyPostFromTemplate(postType: string): Promise<EditorPos
|
|
|
417
423
|
tags: [],
|
|
418
424
|
status: 'DRAFT',
|
|
419
425
|
publishDate: null,
|
|
426
|
+
scheduledAt: null,
|
|
427
|
+
scheduledUnpublishAt: null,
|
|
420
428
|
sections,
|
|
421
429
|
seoTitle: '',
|
|
422
430
|
seoDescription: '',
|
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,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
Calendar,
|
|
4
5
|
Check,
|
|
5
6
|
ExternalLink,
|
|
6
7
|
Eye,
|
|
@@ -10,12 +11,12 @@ import {
|
|
|
10
11
|
Loader2,
|
|
11
12
|
type LucideIcon,
|
|
12
13
|
Monitor,
|
|
13
|
-
Search,
|
|
14
14
|
Settings2,
|
|
15
15
|
Smartphone,
|
|
16
16
|
Tablet,
|
|
17
17
|
TriangleAlert,
|
|
18
18
|
} from 'lucide-react'
|
|
19
|
+
import { EditorSeoScoreBadge } from '../../components/seo/EditorSeoScoreBadge.js'
|
|
19
20
|
import type { PageStatus } from '../../lib/page-editor-service.js'
|
|
20
21
|
import { VIEWPORTS, type ViewportId } from './viewports.js'
|
|
21
22
|
|
|
@@ -61,8 +62,16 @@ interface EditorTopBarProps {
|
|
|
61
62
|
/** Open the document SEO panel (meta, social, schema, analysis). */
|
|
62
63
|
onOpenSeo?: () => void
|
|
63
64
|
seoOpen?: boolean
|
|
65
|
+
/** Live 0–100 SEO score shown left of the SEO label. */
|
|
66
|
+
seoScore?: number
|
|
64
67
|
onSaveDraft: () => void
|
|
65
68
|
onPublish: () => void
|
|
69
|
+
/** Open the schedule-publish dialog. Omitted when the document is unsaved. */
|
|
70
|
+
onSchedule?: () => void
|
|
71
|
+
/** When false, Schedule stays disabled (e.g. unsaved document). Defaults to true. */
|
|
72
|
+
scheduleReady?: boolean
|
|
73
|
+
scheduledAt?: string | null
|
|
74
|
+
scheduledUnpublishAt?: string | null
|
|
66
75
|
/**
|
|
67
76
|
* Section types the canvas renders as placeholders (custom / unmanaged).
|
|
68
77
|
* When non-empty the editor is in "structure" mode: the eye preview is
|
|
@@ -108,8 +117,13 @@ export function EditorTopBar({
|
|
|
108
117
|
onOpenSettings,
|
|
109
118
|
onOpenSeo,
|
|
110
119
|
seoOpen = false,
|
|
120
|
+
seoScore = 0,
|
|
111
121
|
onSaveDraft,
|
|
112
122
|
onPublish,
|
|
123
|
+
onSchedule,
|
|
124
|
+
scheduleReady = true,
|
|
125
|
+
scheduledAt = null,
|
|
126
|
+
scheduledUnpublishAt = null,
|
|
113
127
|
structuralTypes = [],
|
|
114
128
|
}: EditorTopBarProps) {
|
|
115
129
|
const statusInfo = STATUS_TEXT[status]
|
|
@@ -132,6 +146,26 @@ export function EditorTopBar({
|
|
|
132
146
|
? 'You do not have permission to publish'
|
|
133
147
|
: undefined
|
|
134
148
|
|
|
149
|
+
const scheduleBlocked =
|
|
150
|
+
!onSchedule ||
|
|
151
|
+
!canPublish ||
|
|
152
|
+
!publishReady ||
|
|
153
|
+
!scheduleReady ||
|
|
154
|
+
publishing ||
|
|
155
|
+
saveState === 'saving'
|
|
156
|
+
const scheduleTitle =
|
|
157
|
+
saveState === 'saving'
|
|
158
|
+
? 'Wait for the current save to finish'
|
|
159
|
+
: !scheduleReady
|
|
160
|
+
? 'Save before scheduling'
|
|
161
|
+
: canPublish && !publishReady && publishDisabledReason
|
|
162
|
+
? publishDisabledReason
|
|
163
|
+
: !canPublish
|
|
164
|
+
? 'You do not have permission to schedule'
|
|
165
|
+
: scheduledAt
|
|
166
|
+
? `Reschedule — currently publishes ${formatScheduleLabel(scheduledAt)}`
|
|
167
|
+
: 'Schedule a future publish date'
|
|
168
|
+
|
|
135
169
|
return (
|
|
136
170
|
<header className="bg-card border-border flex h-14 shrink-0 items-center justify-between gap-4 border-b px-4">
|
|
137
171
|
{/* Breadcrumb + status */}
|
|
@@ -155,6 +189,18 @@ export function EditorTopBar({
|
|
|
155
189
|
>
|
|
156
190
|
{statusInfo.label}
|
|
157
191
|
</span>
|
|
192
|
+
{scheduledAt && (
|
|
193
|
+
<span
|
|
194
|
+
className="text-muted-foreground ml-1 hidden shrink-0 text-xs sm:inline"
|
|
195
|
+
title={
|
|
196
|
+
scheduledUnpublishAt
|
|
197
|
+
? `Publishes ${formatScheduleLabel(scheduledAt)} · Unpublishes ${formatScheduleLabel(scheduledUnpublishAt)}`
|
|
198
|
+
: `Publishes ${formatScheduleLabel(scheduledAt)}`
|
|
199
|
+
}
|
|
200
|
+
>
|
|
201
|
+
· {formatScheduleLabel(scheduledAt)}
|
|
202
|
+
</span>
|
|
203
|
+
)}
|
|
158
204
|
<SaveStatus state={saveState} dirty={dirty} />
|
|
159
205
|
</div>
|
|
160
206
|
|
|
@@ -203,15 +249,15 @@ export function EditorTopBar({
|
|
|
203
249
|
type="button"
|
|
204
250
|
onClick={onOpenSeo}
|
|
205
251
|
aria-pressed={seoOpen}
|
|
206
|
-
aria-label=
|
|
207
|
-
title=
|
|
252
|
+
aria-label={`SEO, score ${seoScore} out of 100`}
|
|
253
|
+
title={`SEO — score ${seoScore}/100. Meta tags, social preview, schema, and analysis.`}
|
|
208
254
|
className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium transition-colors ${
|
|
209
255
|
seoOpen
|
|
210
256
|
? 'bg-primary text-primary-foreground'
|
|
211
257
|
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
|
|
212
258
|
}`}
|
|
213
259
|
>
|
|
214
|
-
<
|
|
260
|
+
<EditorSeoScoreBadge score={seoScore} inverted={seoOpen} />
|
|
215
261
|
<span className="hidden sm:inline">SEO</span>
|
|
216
262
|
</button>
|
|
217
263
|
)}
|
|
@@ -291,6 +337,20 @@ export function EditorTopBar({
|
|
|
291
337
|
{saveState === 'saving' ? 'Saving…' : 'Save Draft'}
|
|
292
338
|
</button>
|
|
293
339
|
|
|
340
|
+
{onSchedule && (
|
|
341
|
+
<button
|
|
342
|
+
type="button"
|
|
343
|
+
onClick={onSchedule}
|
|
344
|
+
disabled={scheduleBlocked}
|
|
345
|
+
title={scheduleTitle}
|
|
346
|
+
aria-label={scheduledAt ? 'Reschedule publish' : 'Schedule publish'}
|
|
347
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
348
|
+
>
|
|
349
|
+
<Calendar className="h-3.5 w-3.5" aria-hidden />
|
|
350
|
+
<span className="hidden sm:inline">{scheduledAt ? 'Reschedule' : 'Schedule'}</span>
|
|
351
|
+
</button>
|
|
352
|
+
)}
|
|
353
|
+
|
|
294
354
|
<button
|
|
295
355
|
type="button"
|
|
296
356
|
onClick={onPublish}
|
|
@@ -350,3 +410,15 @@ function SaveStatus({ state, dirty }: { state: SaveState; dirty: boolean }) {
|
|
|
350
410
|
}
|
|
351
411
|
return null
|
|
352
412
|
}
|
|
413
|
+
|
|
414
|
+
function formatScheduleLabel(iso: string): string {
|
|
415
|
+
const d = new Date(iso)
|
|
416
|
+
if (Number.isNaN(d.getTime())) return iso
|
|
417
|
+
return d.toLocaleString(undefined, {
|
|
418
|
+
month: 'short',
|
|
419
|
+
day: 'numeric',
|
|
420
|
+
year: 'numeric',
|
|
421
|
+
hour: 'numeric',
|
|
422
|
+
minute: '2-digit',
|
|
423
|
+
})
|
|
424
|
+
}
|