@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/dist/__tests__/components/seo-char-count.test.d.ts +2 -0
  3. package/dist/__tests__/components/seo-char-count.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/seo-char-count.test.js +25 -0
  5. package/dist/__tests__/components/seo-char-count.test.js.map +1 -0
  6. package/dist/__tests__/lib/page-editor-service.test.js +12 -0
  7. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  8. package/dist/__tests__/lib/post-editor-service.test.js +4 -0
  9. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  10. package/dist/__tests__/views/seo-settings.render.test.js +1 -1
  11. package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  14. package/dist/components/MediaPickerModal.js +3 -1
  15. package/dist/components/MediaPickerModal.js.map +1 -1
  16. package/dist/components/SEOPanel.d.ts +1 -0
  17. package/dist/components/SEOPanel.d.ts.map +1 -1
  18. package/dist/components/SEOPanel.js +1 -1
  19. package/dist/components/SEOPanel.js.map +1 -1
  20. package/dist/components/seo/SeoCharCount.d.ts +15 -0
  21. package/dist/components/seo/SeoCharCount.d.ts.map +1 -0
  22. package/dist/components/seo/SeoCharCount.js +25 -0
  23. package/dist/components/seo/SeoCharCount.js.map +1 -0
  24. package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
  25. package/dist/components/seo/SeoEditorDrawer.js +11 -2
  26. package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
  27. package/dist/fields/SlugField.d.ts.map +1 -1
  28. package/dist/fields/SlugField.js +6 -2
  29. package/dist/fields/SlugField.js.map +1 -1
  30. package/dist/lib/page-editor-service.d.ts.map +1 -1
  31. package/dist/lib/page-editor-service.js +6 -0
  32. package/dist/lib/page-editor-service.js.map +1 -1
  33. package/dist/lib/post-editor-service.d.ts.map +1 -1
  34. package/dist/lib/post-editor-service.js +6 -0
  35. package/dist/lib/post-editor-service.js.map +1 -1
  36. package/dist/lib/seo-service.d.ts +16 -1
  37. package/dist/lib/seo-service.d.ts.map +1 -1
  38. package/dist/lib/seo-service.js.map +1 -1
  39. package/dist/views/DocumentEdit.d.ts.map +1 -1
  40. package/dist/views/DocumentEdit.js +1 -0
  41. package/dist/views/DocumentEdit.js.map +1 -1
  42. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  43. package/dist/views/page-editor/PageSettingsModal.js +12 -9
  44. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  45. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  46. package/dist/views/post-editor/PostFieldsModal.js +12 -9
  47. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/__tests__/components/seo-char-count.test.tsx +27 -0
  50. package/src/__tests__/lib/page-editor-service.test.ts +14 -0
  51. package/src/__tests__/lib/post-editor-service.test.ts +9 -0
  52. package/src/__tests__/views/seo-settings.render.test.tsx +1 -1
  53. package/src/components/MediaPickerModal.tsx +5 -1
  54. package/src/components/SEOPanel.tsx +9 -0
  55. package/src/components/seo/SeoCharCount.tsx +44 -0
  56. package/src/components/seo/SeoEditorDrawer.tsx +94 -1
  57. package/src/fields/SlugField.tsx +6 -2
  58. package/src/lib/page-editor-service.ts +6 -0
  59. package/src/lib/post-editor-service.ts +6 -0
  60. package/src/lib/seo-service.ts +23 -1
  61. package/src/views/DocumentEdit.tsx +1 -0
  62. package/src/views/page-editor/PageSettingsModal.tsx +33 -14
  63. 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 = 'metaTitle' | 'metaDescription' | 'ogTitle' | 'ogDescription' | 'focusKeyword'
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)
@@ -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
- .replace(/[^\w\s-]/g, '')
18
- .replace(/[\s_]+/g, '-')
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,
@@ -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: 'metaTitle' | 'metaDescription' | 'ogTitle' | 'ogDescription' | 'focusKeyword',
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', {
@@ -114,6 +114,7 @@ export function DocumentEdit({
114
114
  'metaTitle',
115
115
  'metaDescription',
116
116
  'focusKeyphrase',
117
+ 'keyTakeaway',
117
118
  'canonical',
118
119
  'robotsPolicy',
119
120
  'noIndex',
@@ -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
- function slugify(value: string): string {
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
- <label className={LABEL} htmlFor="ps-seo-title">
129
- SEO title
130
- </label>
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
- <label className={LABEL} htmlFor="ps-seo-desc">
141
- SEO description
142
- </label>
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
- function slugify(value: string): string {
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
- <label className={LABEL} htmlFor="pf-seo-title">
245
- SEO title
246
- </label>
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
- <label className={LABEL} htmlFor="pf-seo-desc">
257
- SEO description
258
- </label>
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>