@actuate-media/cms-admin 0.49.4 → 0.51.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 +61 -0
- package/dist/__tests__/components/seo-char-count.test.d.ts +2 -0
- package/dist/__tests__/components/seo-char-count.test.d.ts.map +1 -0
- package/dist/__tests__/components/seo-char-count.test.js +25 -0
- package/dist/__tests__/components/seo-char-count.test.js.map +1 -0
- package/dist/__tests__/lib/page-editor-service.test.js +12 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
- package/dist/__tests__/lib/post-editor-service.test.js +4 -0
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js +1 -1
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/MediaPickerModal.d.ts.map +1 -1
- package/dist/components/MediaPickerModal.js +3 -1
- package/dist/components/MediaPickerModal.js.map +1 -1
- package/dist/components/SEOPanel.d.ts +1 -0
- package/dist/components/SEOPanel.d.ts.map +1 -1
- package/dist/components/SEOPanel.js +1 -1
- package/dist/components/SEOPanel.js.map +1 -1
- package/dist/components/seo/SeoCharCount.d.ts +15 -0
- package/dist/components/seo/SeoCharCount.d.ts.map +1 -0
- package/dist/components/seo/SeoCharCount.js +25 -0
- package/dist/components/seo/SeoCharCount.js.map +1 -0
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.js +11 -2
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
- package/dist/fields/SlugField.d.ts.map +1 -1
- package/dist/fields/SlugField.js +6 -2
- package/dist/fields/SlugField.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +6 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +6 -0
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/lib/seo-service.d.ts +16 -1
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/DocumentEdit.d.ts.map +1 -1
- package/dist/views/DocumentEdit.js +1 -0
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +12 -9
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +12 -9
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/components/seo-char-count.test.tsx +27 -0
- package/src/__tests__/lib/page-editor-service.test.ts +14 -0
- package/src/__tests__/lib/post-editor-service.test.ts +9 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +1 -1
- package/src/components/MediaPickerModal.tsx +5 -1
- package/src/components/SEOPanel.tsx +9 -0
- package/src/components/seo/SeoCharCount.tsx +44 -0
- package/src/components/seo/SeoEditorDrawer.tsx +94 -1
- package/src/fields/SlugField.tsx +6 -2
- package/src/lib/page-editor-service.ts +6 -0
- package/src/lib/post-editor-service.ts +6 -0
- package/src/lib/seo-service.ts +23 -1
- package/src/views/DocumentEdit.tsx +1 -0
- package/src/views/page-editor/PageSettingsModal.tsx +33 -14
- package/src/views/post-editor/PostFieldsModal.tsx +33 -14
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
generateSeoField,
|
|
10
10
|
type SeoContentRecord,
|
|
11
11
|
type SeoFieldPayload,
|
|
12
|
+
type LlmsInclude,
|
|
12
13
|
} from '../../lib/seo-service.js'
|
|
13
14
|
import { Drawer } from './Drawer.js'
|
|
14
15
|
import { SeoLoading, SeoErrorState, btnPrimary, btnSecondary, gradeMeta } from './primitives.js'
|
|
@@ -16,7 +17,16 @@ import { SeoLoading, SeoErrorState, btnPrimary, btnSecondary, gradeMeta } from '
|
|
|
16
17
|
const META_TITLE_MAX = 60
|
|
17
18
|
const META_DESC_MAX = 160
|
|
18
19
|
|
|
19
|
-
type AiField =
|
|
20
|
+
type AiField =
|
|
21
|
+
| 'metaTitle'
|
|
22
|
+
| 'metaDescription'
|
|
23
|
+
| 'ogTitle'
|
|
24
|
+
| 'ogDescription'
|
|
25
|
+
| 'focusKeyword'
|
|
26
|
+
| 'keyTakeaway'
|
|
27
|
+
|
|
28
|
+
const KEY_TAKEAWAY_MAX = 240
|
|
29
|
+
const LLMS_DESC_MAX = 160
|
|
20
30
|
|
|
21
31
|
function CharCount({ value, max }: { value: string; max: number }) {
|
|
22
32
|
const over = value.length > max
|
|
@@ -100,10 +110,15 @@ export function SeoEditorDrawer({
|
|
|
100
110
|
metaDescription: rec.metaDescription ?? '',
|
|
101
111
|
canonicalUrl: rec.canonicalUrl ?? '',
|
|
102
112
|
focusKeyword: rec.focusKeyword ?? '',
|
|
113
|
+
keyTakeaway: rec.keyTakeaway ?? '',
|
|
103
114
|
ogTitle: rec.ogTitle ?? '',
|
|
104
115
|
ogDescription: rec.ogDescription ?? '',
|
|
105
116
|
noindex: rec.noindex,
|
|
106
117
|
nofollow: rec.nofollow,
|
|
118
|
+
llmsInclude: rec.llmsInclude ?? 'AUTO',
|
|
119
|
+
llmsDescription: rec.llmsDescription ?? '',
|
|
120
|
+
llmsSection: rec.llmsSection ?? '',
|
|
121
|
+
llmsOptional: rec.llmsOptional ?? false,
|
|
107
122
|
})
|
|
108
123
|
})
|
|
109
124
|
.catch((e) => alive && setError(e instanceof Error ? e.message : 'Failed to load'))
|
|
@@ -248,6 +263,23 @@ export function SeoEditorDrawer({
|
|
|
248
263
|
/>
|
|
249
264
|
</Field>
|
|
250
265
|
|
|
266
|
+
<Field
|
|
267
|
+
label="Key takeaway"
|
|
268
|
+
hint={
|
|
269
|
+
<span className="flex items-center gap-2">
|
|
270
|
+
<CharCount value={form.keyTakeaway ?? ''} max={KEY_TAKEAWAY_MAX} />
|
|
271
|
+
{aiButton('keyTakeaway')}
|
|
272
|
+
</span>
|
|
273
|
+
}
|
|
274
|
+
>
|
|
275
|
+
<textarea
|
|
276
|
+
className={`${inputCls} min-h-16 resize-y`}
|
|
277
|
+
value={form.keyTakeaway ?? ''}
|
|
278
|
+
onChange={(e) => update({ keyTakeaway: e.target.value })}
|
|
279
|
+
placeholder="One or two sentences AI answer engines can quote — what is this page about?"
|
|
280
|
+
/>
|
|
281
|
+
</Field>
|
|
282
|
+
|
|
251
283
|
<Field label="Canonical URL">
|
|
252
284
|
<input
|
|
253
285
|
className={inputCls}
|
|
@@ -339,6 +371,67 @@ export function SeoEditorDrawer({
|
|
|
339
371
|
</label>
|
|
340
372
|
</div>
|
|
341
373
|
|
|
374
|
+
{/* llms.txt curation */}
|
|
375
|
+
<div className="border-border space-y-4 rounded-lg border p-4">
|
|
376
|
+
<div>
|
|
377
|
+
<p className="text-foreground text-sm font-medium">AI discovery (llms.txt)</p>
|
|
378
|
+
<p className="text-muted-foreground text-sm">
|
|
379
|
+
Controls how this page appears in the curated <code>llms.txt</code> guide that AI
|
|
380
|
+
agents read. It does not affect search ranking.
|
|
381
|
+
</p>
|
|
382
|
+
</div>
|
|
383
|
+
<Field label="Include in llms.txt">
|
|
384
|
+
<select
|
|
385
|
+
className={inputCls}
|
|
386
|
+
value={form.llmsInclude ?? 'AUTO'}
|
|
387
|
+
onChange={(e) => update({ llmsInclude: e.target.value as LlmsInclude })}
|
|
388
|
+
>
|
|
389
|
+
<option value="AUTO">Auto — include when eligible</option>
|
|
390
|
+
<option value="ALWAYS">Always — pin it in</option>
|
|
391
|
+
<option value="NEVER">Never — exclude</option>
|
|
392
|
+
</select>
|
|
393
|
+
</Field>
|
|
394
|
+
<Field
|
|
395
|
+
label="Agent description"
|
|
396
|
+
hint={<CharCount value={form.llmsDescription ?? ''} max={LLMS_DESC_MAX} />}
|
|
397
|
+
>
|
|
398
|
+
<textarea
|
|
399
|
+
className={`${inputCls} min-h-16 resize-y`}
|
|
400
|
+
value={form.llmsDescription ?? ''}
|
|
401
|
+
onChange={(e) => update({ llmsDescription: e.target.value })}
|
|
402
|
+
placeholder={
|
|
403
|
+
form.keyTakeaway ||
|
|
404
|
+
form.metaDescription ||
|
|
405
|
+
'One line for an AI agent — what this page covers and why it matters.'
|
|
406
|
+
}
|
|
407
|
+
/>
|
|
408
|
+
</Field>
|
|
409
|
+
<Field label="Section">
|
|
410
|
+
<input
|
|
411
|
+
className={inputCls}
|
|
412
|
+
value={form.llmsSection ?? ''}
|
|
413
|
+
onChange={(e) => update({ llmsSection: e.target.value })}
|
|
414
|
+
placeholder="Defaults to the content type"
|
|
415
|
+
/>
|
|
416
|
+
</Field>
|
|
417
|
+
<label className="flex items-center justify-between gap-3">
|
|
418
|
+
<span className="min-w-0">
|
|
419
|
+
<span className="text-foreground block text-sm font-medium">Optional</span>
|
|
420
|
+
<span className="text-muted-foreground block text-sm">
|
|
421
|
+
Group under “Optional” — agents skip these first when context is tight.
|
|
422
|
+
</span>
|
|
423
|
+
</span>
|
|
424
|
+
<input
|
|
425
|
+
type="checkbox"
|
|
426
|
+
role="switch"
|
|
427
|
+
aria-checked={!!form.llmsOptional}
|
|
428
|
+
checked={!!form.llmsOptional}
|
|
429
|
+
onChange={(e) => update({ llmsOptional: e.target.checked })}
|
|
430
|
+
className="accent-primary h-4 w-4"
|
|
431
|
+
/>
|
|
432
|
+
</label>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
342
435
|
{/* Score summary */}
|
|
343
436
|
{(() => {
|
|
344
437
|
const meta = gradeMeta(record.seoGrade)
|
package/src/fields/SlugField.tsx
CHANGED
|
@@ -11,11 +11,15 @@ export interface SlugFieldProps {
|
|
|
11
11
|
helpText?: string
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// Mirrors `normalizeSlug` in @actuate-media/cms-core so the live-edited value
|
|
15
|
+
// matches what the server will accept (lowercase alphanumerics + single
|
|
16
|
+
// hyphens; underscores and other punctuation are collapsed to hyphens).
|
|
14
17
|
function toSlug(text: string): string {
|
|
15
18
|
return text
|
|
16
19
|
.toLowerCase()
|
|
17
|
-
.
|
|
18
|
-
.replace(/[\
|
|
20
|
+
.normalize('NFKD')
|
|
21
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
22
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
19
23
|
.replace(/^-+|-+$/g, '')
|
|
20
24
|
}
|
|
21
25
|
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
validateSectionContent,
|
|
34
34
|
type PageSection,
|
|
35
35
|
} from '@actuate-media/cms-core/page-sections'
|
|
36
|
+
import { validateSlug } from '@actuate-media/cms-core/client'
|
|
36
37
|
|
|
37
38
|
import { missingCollectionFields } from './collection-schema.js'
|
|
38
39
|
|
|
@@ -108,6 +109,11 @@ export function validatePage(page: EditorPage, forPublish = false): ValidationRe
|
|
|
108
109
|
if (isBlank(page.title)) errors.push('Page title is required.')
|
|
109
110
|
if (isBlank(page.path)) errors.push('Page path is required.')
|
|
110
111
|
|
|
112
|
+
// Mirror the server publish gate so an invalid/reserved slug surfaces in the
|
|
113
|
+
// validation summary rather than only as a toast after the publish attempt.
|
|
114
|
+
const slugError = validateSlug(page.slug)
|
|
115
|
+
if (slugError) errors.push(slugError)
|
|
116
|
+
|
|
111
117
|
if (forPublish) {
|
|
112
118
|
const visible = page.sections.filter((s) => s.visible)
|
|
113
119
|
if (visible.length === 0) {
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type PageSection,
|
|
42
42
|
type PostHeaderConfig,
|
|
43
43
|
} from '@actuate-media/cms-core/page-sections'
|
|
44
|
+
import { validateSlug } from '@actuate-media/cms-core/client'
|
|
44
45
|
|
|
45
46
|
export type { PageSection, PostHeaderConfig } from '@actuate-media/cms-core/page-sections'
|
|
46
47
|
|
|
@@ -139,6 +140,11 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
|
|
|
139
140
|
if (isBlank(post.title)) errors.push('Post title is required.')
|
|
140
141
|
if (isBlank(post.slug)) errors.push('Post slug is required.')
|
|
141
142
|
|
|
143
|
+
// Mirror the server publish gate so an invalid/reserved slug surfaces in the
|
|
144
|
+
// validation summary rather than only as a toast after the publish attempt.
|
|
145
|
+
const slugError = validateSlug(post.slug)
|
|
146
|
+
if (slugError) errors.push(slugError)
|
|
147
|
+
|
|
142
148
|
if (forPublish) {
|
|
143
149
|
const visible = post.sections.filter((s) => s.visible)
|
|
144
150
|
// Unmanaged (externally-managed) sections have no registered field schema,
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -150,6 +150,7 @@ export interface SeoContentRecord {
|
|
|
150
150
|
metaTitle: string | null
|
|
151
151
|
metaDescription: string | null
|
|
152
152
|
focusKeyword: string | null
|
|
153
|
+
keyTakeaway: string | null
|
|
153
154
|
ogTitle: string | null
|
|
154
155
|
ogDescription: string | null
|
|
155
156
|
ogImageId: string | null
|
|
@@ -161,11 +162,21 @@ export interface SeoContentRecord {
|
|
|
161
162
|
nofollow: boolean
|
|
162
163
|
structuredDataType: string | null
|
|
163
164
|
structuredData: Record<string, unknown> | null
|
|
165
|
+
/** llms.txt curation: AUTO (heuristic), ALWAYS (pin), NEVER (exclude). */
|
|
166
|
+
llmsInclude: LlmsInclude
|
|
167
|
+
/** Curated agent-facing description for llms.txt (overrides the fallback). */
|
|
168
|
+
llmsDescription: string | null
|
|
169
|
+
/** Section/grouping override in llms.txt (defaults to the collection). */
|
|
170
|
+
llmsSection: string | null
|
|
171
|
+
/** Route this entry into the reserved `## Optional` section. */
|
|
172
|
+
llmsOptional: boolean
|
|
164
173
|
seoScore: number
|
|
165
174
|
seoGrade: SeoGrade
|
|
166
175
|
updatedAt: string | null
|
|
167
176
|
}
|
|
168
177
|
|
|
178
|
+
export type LlmsInclude = 'AUTO' | 'ALWAYS' | 'NEVER'
|
|
179
|
+
|
|
169
180
|
export type ContentSeoFilter = 'all' | 'pages' | 'posts'
|
|
170
181
|
|
|
171
182
|
export async function fetchSeoContentRecords(): Promise<SeoContentRecord[]> {
|
|
@@ -190,6 +201,7 @@ export interface SeoFieldPayload {
|
|
|
190
201
|
metaDescription?: string
|
|
191
202
|
canonicalUrl?: string
|
|
192
203
|
focusKeyword?: string
|
|
204
|
+
keyTakeaway?: string
|
|
193
205
|
ogTitle?: string
|
|
194
206
|
ogDescription?: string
|
|
195
207
|
ogImage?: string
|
|
@@ -200,6 +212,10 @@ export interface SeoFieldPayload {
|
|
|
200
212
|
nofollow?: boolean
|
|
201
213
|
structuredDataType?: string
|
|
202
214
|
structuredData?: Record<string, unknown>
|
|
215
|
+
llmsInclude?: LlmsInclude
|
|
216
|
+
llmsDescription?: string
|
|
217
|
+
llmsSection?: string
|
|
218
|
+
llmsOptional?: boolean
|
|
203
219
|
}
|
|
204
220
|
|
|
205
221
|
export async function updateSeoFields(
|
|
@@ -620,7 +636,13 @@ function isAiUnavailable(status?: number): boolean {
|
|
|
620
636
|
}
|
|
621
637
|
|
|
622
638
|
export async function generateSeoField(
|
|
623
|
-
field:
|
|
639
|
+
field:
|
|
640
|
+
| 'metaTitle'
|
|
641
|
+
| 'metaDescription'
|
|
642
|
+
| 'ogTitle'
|
|
643
|
+
| 'ogDescription'
|
|
644
|
+
| 'focusKeyword'
|
|
645
|
+
| 'keyTakeaway',
|
|
624
646
|
context: { entityType: string; entityId: string },
|
|
625
647
|
): Promise<SeoAiResult> {
|
|
626
648
|
const res = await cmsApi<SeoAiResult>('/ai/seo/generate-field', {
|
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react'
|
|
4
|
+
import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
|
|
4
5
|
import { Modal } from '../../components/ui/Modal.js'
|
|
5
6
|
import { Button } from '../../components/ui/Button.js'
|
|
7
|
+
import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
|
|
6
8
|
import type { EditorPage } from '../../lib/page-editor-service.js'
|
|
7
9
|
|
|
10
|
+
const SEO_TITLE_MIN = 30
|
|
11
|
+
const SEO_TITLE_MAX = 60
|
|
12
|
+
const SEO_DESC_MIN = 120
|
|
13
|
+
const SEO_DESC_MAX = 160
|
|
14
|
+
|
|
8
15
|
interface PageSettingsModalProps {
|
|
9
16
|
open: boolean
|
|
10
17
|
page: EditorPage
|
|
@@ -17,13 +24,7 @@ const LABEL = 'text-foreground mb-1 block text-xs font-medium'
|
|
|
17
24
|
const INPUT =
|
|
18
25
|
'border-input bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
|
|
19
26
|
|
|
20
|
-
|
|
21
|
-
return value
|
|
22
|
-
.toLowerCase()
|
|
23
|
-
.trim()
|
|
24
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
25
|
-
.replace(/^-+|-+$/g, '')
|
|
26
|
-
}
|
|
27
|
+
const slugify = normalizeSlug
|
|
27
28
|
|
|
28
29
|
/** Edits page-level fields: title, slug, path, and SEO. */
|
|
29
30
|
export function PageSettingsModal({
|
|
@@ -60,7 +61,10 @@ export function PageSettingsModal({
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
const slugError = validateSlug(slug)
|
|
65
|
+
|
|
63
66
|
function handleSave() {
|
|
67
|
+
if (slugError) return
|
|
64
68
|
onSave({ title, slug, path: path || (slug ? `/${slug}` : '/'), seoTitle, seoDescription })
|
|
65
69
|
onClose()
|
|
66
70
|
}
|
|
@@ -75,7 +79,7 @@ export function PageSettingsModal({
|
|
|
75
79
|
<Button variant="ghost" onClick={onClose}>
|
|
76
80
|
Cancel
|
|
77
81
|
</Button>
|
|
78
|
-
<Button variant="primary" onClick={handleSave} disabled={!canEdit}>
|
|
82
|
+
<Button variant="primary" onClick={handleSave} disabled={!canEdit || Boolean(slugError)}>
|
|
79
83
|
Done
|
|
80
84
|
</Button>
|
|
81
85
|
</>
|
|
@@ -104,11 +108,18 @@ export function PageSettingsModal({
|
|
|
104
108
|
className={INPUT}
|
|
105
109
|
value={slug}
|
|
106
110
|
disabled={!canEdit}
|
|
111
|
+
aria-invalid={Boolean(slugError)}
|
|
112
|
+
aria-describedby={slugError ? 'ps-slug-error' : undefined}
|
|
107
113
|
onChange={(e) => {
|
|
108
114
|
setSlugTouched(true)
|
|
109
115
|
setSlug(slugify(e.target.value))
|
|
110
116
|
}}
|
|
111
117
|
/>
|
|
118
|
+
{slugError && (
|
|
119
|
+
<p id="ps-slug-error" className="text-destructive mt-1 text-xs">
|
|
120
|
+
{slugError}
|
|
121
|
+
</p>
|
|
122
|
+
)}
|
|
112
123
|
</div>
|
|
113
124
|
<div>
|
|
114
125
|
<label className={LABEL} htmlFor="ps-path">
|
|
@@ -125,26 +136,34 @@ export function PageSettingsModal({
|
|
|
125
136
|
</div>
|
|
126
137
|
</div>
|
|
127
138
|
<div>
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
139
|
+
<div className="mb-1 flex items-center justify-between">
|
|
140
|
+
<label className={`${LABEL} mb-0`} htmlFor="ps-seo-title">
|
|
141
|
+
SEO title
|
|
142
|
+
</label>
|
|
143
|
+
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
144
|
+
</div>
|
|
131
145
|
<input
|
|
132
146
|
id="ps-seo-title"
|
|
133
147
|
className={INPUT}
|
|
134
148
|
value={seoTitle}
|
|
135
149
|
disabled={!canEdit}
|
|
150
|
+
placeholder={title || 'Defaults to the page title'}
|
|
136
151
|
onChange={(e) => setSeoTitle(e.target.value)}
|
|
137
152
|
/>
|
|
138
153
|
</div>
|
|
139
154
|
<div>
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
155
|
+
<div className="mb-1 flex items-center justify-between">
|
|
156
|
+
<label className={`${LABEL} mb-0`} htmlFor="ps-seo-desc">
|
|
157
|
+
SEO description
|
|
158
|
+
</label>
|
|
159
|
+
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
160
|
+
</div>
|
|
143
161
|
<textarea
|
|
144
162
|
id="ps-seo-desc"
|
|
145
163
|
className={`${INPUT} min-h-[64px] resize-y`}
|
|
146
164
|
value={seoDescription}
|
|
147
165
|
disabled={!canEdit}
|
|
166
|
+
placeholder="Summarize the page for search results (120–160 characters)"
|
|
148
167
|
onChange={(e) => setSeoDescription(e.target.value)}
|
|
149
168
|
/>
|
|
150
169
|
</div>
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react'
|
|
4
|
+
import { normalizeSlug, validateSlug } from '@actuate-media/cms-core/client'
|
|
4
5
|
import { Modal } from '../../components/ui/Modal.js'
|
|
5
6
|
import { Button } from '../../components/ui/Button.js'
|
|
6
7
|
import { MediaPickerModal } from '../../components/MediaPickerModal.js'
|
|
7
8
|
import { TagInput } from '../../components/TagInput.js'
|
|
8
9
|
import { RichTextField } from '../../fields/RichTextField.js'
|
|
10
|
+
import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
|
|
9
11
|
import type { EditorPost } from '../../lib/post-editor-service.js'
|
|
10
12
|
|
|
13
|
+
const SEO_TITLE_MIN = 30
|
|
14
|
+
const SEO_TITLE_MAX = 60
|
|
15
|
+
const SEO_DESC_MIN = 120
|
|
16
|
+
const SEO_DESC_MAX = 160
|
|
17
|
+
|
|
11
18
|
/**
|
|
12
19
|
* The slice of a collection's field config the modal reads to pick controls:
|
|
13
20
|
* a `select` category renders a dropdown (structured vocabulary), an `array`
|
|
@@ -33,13 +40,7 @@ const LABEL = 'text-foreground mb-1 block text-xs font-medium'
|
|
|
33
40
|
const INPUT =
|
|
34
41
|
'border-input bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
return value
|
|
38
|
-
.toLowerCase()
|
|
39
|
-
.trim()
|
|
40
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
41
|
-
.replace(/^-+|-+$/g, '')
|
|
42
|
-
}
|
|
43
|
+
const slugify = normalizeSlug
|
|
43
44
|
|
|
44
45
|
/** Edits post-level fields: title, slug, excerpt, featured image, category, tags, body, SEO. */
|
|
45
46
|
export function PostFieldsModal({
|
|
@@ -89,7 +90,10 @@ export function PostFieldsModal({
|
|
|
89
90
|
if (!slugTouched) setSlug(slugify(next))
|
|
90
91
|
}
|
|
91
92
|
|
|
93
|
+
const slugError = validateSlug(slug)
|
|
94
|
+
|
|
92
95
|
function handleSave() {
|
|
96
|
+
if (slugError) return
|
|
93
97
|
onSave({ title, slug, excerpt, featuredImage, category, tags, body, seoTitle, seoDescription })
|
|
94
98
|
onClose()
|
|
95
99
|
}
|
|
@@ -104,7 +108,7 @@ export function PostFieldsModal({
|
|
|
104
108
|
<Button variant="ghost" onClick={onClose}>
|
|
105
109
|
Cancel
|
|
106
110
|
</Button>
|
|
107
|
-
<Button variant="primary" onClick={handleSave} disabled={!canEdit}>
|
|
111
|
+
<Button variant="primary" onClick={handleSave} disabled={!canEdit || Boolean(slugError)}>
|
|
108
112
|
Done
|
|
109
113
|
</Button>
|
|
110
114
|
</>
|
|
@@ -133,11 +137,18 @@ export function PostFieldsModal({
|
|
|
133
137
|
className={INPUT}
|
|
134
138
|
value={slug}
|
|
135
139
|
disabled={!canEdit}
|
|
140
|
+
aria-invalid={Boolean(slugError)}
|
|
141
|
+
aria-describedby={slugError ? 'pf-slug-error' : undefined}
|
|
136
142
|
onChange={(e) => {
|
|
137
143
|
setSlugTouched(true)
|
|
138
144
|
setSlug(slugify(e.target.value))
|
|
139
145
|
}}
|
|
140
146
|
/>
|
|
147
|
+
{slugError && (
|
|
148
|
+
<p id="pf-slug-error" className="text-destructive mt-1 text-xs">
|
|
149
|
+
{slugError}
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
141
152
|
</div>
|
|
142
153
|
<div>
|
|
143
154
|
<label className={LABEL} htmlFor="pf-category">
|
|
@@ -241,26 +252,34 @@ export function PostFieldsModal({
|
|
|
241
252
|
/>
|
|
242
253
|
</div>
|
|
243
254
|
<div>
|
|
244
|
-
<
|
|
245
|
-
|
|
246
|
-
|
|
255
|
+
<div className="mb-1 flex items-center justify-between">
|
|
256
|
+
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-title">
|
|
257
|
+
SEO title
|
|
258
|
+
</label>
|
|
259
|
+
<SeoCharCount value={seoTitle} min={SEO_TITLE_MIN} max={SEO_TITLE_MAX} />
|
|
260
|
+
</div>
|
|
247
261
|
<input
|
|
248
262
|
id="pf-seo-title"
|
|
249
263
|
className={INPUT}
|
|
250
264
|
value={seoTitle}
|
|
251
265
|
disabled={!canEdit}
|
|
266
|
+
placeholder={title || 'Defaults to the post title'}
|
|
252
267
|
onChange={(e) => setSeoTitle(e.target.value)}
|
|
253
268
|
/>
|
|
254
269
|
</div>
|
|
255
270
|
<div>
|
|
256
|
-
<
|
|
257
|
-
|
|
258
|
-
|
|
271
|
+
<div className="mb-1 flex items-center justify-between">
|
|
272
|
+
<label className={`${LABEL} mb-0`} htmlFor="pf-seo-desc">
|
|
273
|
+
SEO description
|
|
274
|
+
</label>
|
|
275
|
+
<SeoCharCount value={seoDescription} min={SEO_DESC_MIN} max={SEO_DESC_MAX} />
|
|
276
|
+
</div>
|
|
259
277
|
<textarea
|
|
260
278
|
id="pf-seo-desc"
|
|
261
279
|
className={`${INPUT} min-h-[60px] resize-y`}
|
|
262
280
|
value={seoDescription}
|
|
263
281
|
disabled={!canEdit}
|
|
282
|
+
placeholder={excerpt || 'Summarize the post for search results (120–160 characters)'}
|
|
264
283
|
onChange={(e) => setSeoDescription(e.target.value)}
|
|
265
284
|
/>
|
|
266
285
|
</div>
|