@actuate-media/cms-admin 0.60.0 → 0.62.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 +16 -0
- package/dist/__tests__/lib/page-editor-service.test.js +1 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/editor-top-bar.render.test.d.ts +2 -0
- package/dist/__tests__/views/editor-top-bar.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js +46 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -0
- package/dist/__tests__/views/seo-settings.render.test.js +21 -0
- package/dist/__tests__/views/seo-settings.render.test.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +5 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +12 -2
- 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 +4 -1
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +4 -0
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/dist/views/page-editor/sections/WidgetEmbedSection.d.ts +9 -0
- package/dist/views/page-editor/sections/WidgetEmbedSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/WidgetEmbedSection.js +18 -0
- package/dist/views/page-editor/sections/WidgetEmbedSection.js.map +1 -0
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +4 -1
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +22 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/useSeoSettings.d.ts +8 -0
- package/dist/views/settings/useSeoSettings.d.ts.map +1 -1
- package/dist/views/settings/useSeoSettings.js +61 -1
- package/dist/views/settings/useSeoSettings.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/lib/page-editor-service.test.ts +1 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +65 -0
- package/src/__tests__/views/seo-settings.render.test.tsx +31 -0
- package/src/views/page-editor/EditorTopBar.tsx +20 -1
- package/src/views/page-editor/PageSectionEditor.tsx +9 -0
- package/src/views/page-editor/sections/SectionRenderer.tsx +4 -0
- package/src/views/page-editor/sections/WidgetEmbedSection.tsx +32 -0
- package/src/views/post-editor/PostSectionEditor.tsx +9 -0
- package/src/views/settings/SeoSettingsTab.tsx +145 -0
- package/src/views/settings/useSeoSettings.ts +74 -1
|
@@ -114,6 +114,13 @@ export function PageSectionEditor({
|
|
|
114
114
|
)
|
|
115
115
|
}, [config])
|
|
116
116
|
|
|
117
|
+
const publishCheck = useMemo(
|
|
118
|
+
() => (page ? validatePage(page, true) : { errors: [''], warnings: [] }),
|
|
119
|
+
[page],
|
|
120
|
+
)
|
|
121
|
+
const publishReady = Boolean(page) && publishCheck.errors.length === 0
|
|
122
|
+
const publishDisabledReason = publishCheck.errors[0]
|
|
123
|
+
|
|
117
124
|
// ─── Load ──────────────────────────────────────────────────────────
|
|
118
125
|
useEffect(() => {
|
|
119
126
|
let cancelled = false
|
|
@@ -463,6 +470,8 @@ export function PageSectionEditor({
|
|
|
463
470
|
publishing={publishing}
|
|
464
471
|
canEdit={effectiveCanEdit}
|
|
465
472
|
canPublish={canPublish && effectiveCanEdit}
|
|
473
|
+
publishReady={publishReady}
|
|
474
|
+
publishDisabledReason={publishDisabledReason}
|
|
466
475
|
viewport={viewport}
|
|
467
476
|
onViewport={setViewport}
|
|
468
477
|
onBack={() => guardedNavigate('/pages')}
|
|
@@ -17,6 +17,7 @@ import { AuthorBioSection } from './AuthorBioSection.js'
|
|
|
17
17
|
import { RelatedPostsSection } from './RelatedPostsSection.js'
|
|
18
18
|
import { GallerySection } from './GallerySection.js'
|
|
19
19
|
import { EmbedSection } from './EmbedSection.js'
|
|
20
|
+
import { WidgetEmbedSection } from './WidgetEmbedSection.js'
|
|
20
21
|
|
|
21
22
|
/**
|
|
22
23
|
* Section types with a real canvas body component below. Anything else —
|
|
@@ -36,6 +37,7 @@ const CANVAS_RENDERED_TYPES: ReadonlySet<string> = new Set([
|
|
|
36
37
|
'related-posts',
|
|
37
38
|
'gallery',
|
|
38
39
|
'embed',
|
|
40
|
+
'widget-embed',
|
|
39
41
|
])
|
|
40
42
|
|
|
41
43
|
/** True when the canvas renders this type for real (not as a placeholder). */
|
|
@@ -70,6 +72,8 @@ function SectionBody({ section, context }: { section: PageSection; context?: Pos
|
|
|
70
72
|
return <GallerySection section={section} />
|
|
71
73
|
case 'embed':
|
|
72
74
|
return <EmbedSection section={section} />
|
|
75
|
+
case 'widget-embed':
|
|
76
|
+
return <WidgetEmbedSection section={section} />
|
|
73
77
|
default: {
|
|
74
78
|
// Custom (config-declared) and unmanaged types: use a consumer-supplied
|
|
75
79
|
// canvas renderer when one is registered (see admin-renderers.tsx).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Puzzle } from 'lucide-react'
|
|
2
|
+
import { parseWidgetEmbed, WIDGET_PROVIDER_LABEL } from '@actuate-media/cms-core/page-sections'
|
|
3
|
+
import type { PageSection } from '../../../lib/page-editor-service.js'
|
|
4
|
+
import { str } from './parts.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Canvas body for the `widget-embed` section. Shows a reserved-height placeholder
|
|
8
|
+
* instead of loading third-party scripts inside the editor.
|
|
9
|
+
*/
|
|
10
|
+
export function WidgetEmbedSection({ section }: { section: PageSection }) {
|
|
11
|
+
const c = section.content
|
|
12
|
+
const parsed = parseWidgetEmbed(c)
|
|
13
|
+
const title = str(c, 'title')
|
|
14
|
+
const minHeight = parsed?.minHeightPx ?? 280
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<section className="mx-auto max-w-3xl px-6" aria-label={title || 'Third-party widget'}>
|
|
18
|
+
<div
|
|
19
|
+
className="bg-muted text-muted-foreground relative flex w-full flex-col items-center justify-center gap-2 overflow-hidden rounded-xl"
|
|
20
|
+
style={{ minHeight: `${minHeight}px` }}
|
|
21
|
+
>
|
|
22
|
+
<Puzzle className="h-8 w-8" aria-hidden />
|
|
23
|
+
<p className="text-foreground text-sm font-medium">
|
|
24
|
+
{parsed
|
|
25
|
+
? `${WIDGET_PROVIDER_LABEL[parsed.provider]} widget`
|
|
26
|
+
: 'Configure widget provider + ids'}
|
|
27
|
+
</p>
|
|
28
|
+
{title && <p className="text-muted-foreground px-4 text-center text-xs">{title}</p>}
|
|
29
|
+
</div>
|
|
30
|
+
</section>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -151,6 +151,13 @@ export function PostSectionEditor({
|
|
|
151
151
|
)
|
|
152
152
|
}, [config, postType])
|
|
153
153
|
|
|
154
|
+
const publishCheck = useMemo(
|
|
155
|
+
() => (post ? validatePost(post, true) : { errors: [''], warnings: [] }),
|
|
156
|
+
[post],
|
|
157
|
+
)
|
|
158
|
+
const publishReady = Boolean(post) && publishCheck.errors.length === 0
|
|
159
|
+
const publishDisabledReason = publishCheck.errors[0]
|
|
160
|
+
|
|
154
161
|
// ─── Load ──────────────────────────────────────────────────────────
|
|
155
162
|
useEffect(() => {
|
|
156
163
|
let cancelled = false
|
|
@@ -504,6 +511,8 @@ export function PostSectionEditor({
|
|
|
504
511
|
publishing={publishing}
|
|
505
512
|
canEdit={canEdit}
|
|
506
513
|
canPublish={canPublish && canEdit}
|
|
514
|
+
publishReady={publishReady}
|
|
515
|
+
publishDisabledReason={publishDisabledReason}
|
|
507
516
|
viewport={viewport}
|
|
508
517
|
backLabel="Posts"
|
|
509
518
|
onViewport={setViewport}
|
|
@@ -126,6 +126,7 @@ export function SeoSettingsTab({ onNavigate, role }: SeoSettingsTabProps) {
|
|
|
126
126
|
<SettingsValidationSummary issues={seo.issues} />
|
|
127
127
|
|
|
128
128
|
<MetaDefaultsCard seo={seo} canEdit={canEdit} />
|
|
129
|
+
<OrganizationCard seo={seo} canEdit={canEdit} />
|
|
129
130
|
<SearchEngineVerificationCard seo={seo} canEdit={canEdit} />
|
|
130
131
|
<SitemapDefaultsCard seo={seo} canEdit={canEdit} onNavigate={onNavigate} />
|
|
131
132
|
|
|
@@ -409,6 +410,150 @@ function SeoPreviewCard({
|
|
|
409
410
|
)
|
|
410
411
|
}
|
|
411
412
|
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Organization (Schema.org)
|
|
415
|
+
// ---------------------------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
function OrganizationCard({ seo, canEdit }: { seo: UseSeoSettings; canEdit: boolean }) {
|
|
418
|
+
const nameId = useId()
|
|
419
|
+
const urlId = useId()
|
|
420
|
+
const sameAsId = useId()
|
|
421
|
+
const [pickerOpen, setPickerOpen] = useState(false)
|
|
422
|
+
|
|
423
|
+
const { form, setField, issues, sources, siteName, siteUrl } = seo
|
|
424
|
+
const nameIssue = issues.find((i) => i.field === 'organizationName')
|
|
425
|
+
const urlIssue = issues.find((i) => i.field === 'organizationUrl')
|
|
426
|
+
const logoIssue = issues.find((i) => i.field === 'organizationLogo')
|
|
427
|
+
const sameAsIssue = issues.find((i) => i.field === 'organizationSameAs')
|
|
428
|
+
|
|
429
|
+
const nameEditable = sources.organizationName?.editable !== false && canEdit
|
|
430
|
+
const urlEditable = sources.organizationUrl?.editable !== false && canEdit
|
|
431
|
+
const logoEditable = sources.organizationLogo?.editable !== false && canEdit
|
|
432
|
+
const sameAsEditable = sources.organizationSameAs?.editable !== false && canEdit
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<SettingsCard
|
|
436
|
+
title="Organization"
|
|
437
|
+
description="Schema.org Organization data injected sitewide in JSON-LD. Logo and social profiles should use absolute production URLs."
|
|
438
|
+
>
|
|
439
|
+
<div className="space-y-4">
|
|
440
|
+
<div>
|
|
441
|
+
<label htmlFor={nameId} className={LABEL_CLASS}>
|
|
442
|
+
Organization name
|
|
443
|
+
<SettingsSourceBadge meta={sources.organizationName} />
|
|
444
|
+
</label>
|
|
445
|
+
<input
|
|
446
|
+
id={nameId}
|
|
447
|
+
type="text"
|
|
448
|
+
value={form.organizationName}
|
|
449
|
+
disabled={!nameEditable}
|
|
450
|
+
placeholder={siteName || 'Your organization'}
|
|
451
|
+
onChange={(e) => setField('organizationName', e.target.value)}
|
|
452
|
+
className={INPUT_CLASS}
|
|
453
|
+
/>
|
|
454
|
+
<p className="text-muted-foreground mt-1 text-sm">Falls back to site name when empty.</p>
|
|
455
|
+
{nameIssue && <p className="text-destructive mt-1 text-sm">{nameIssue.message}</p>}
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
<div>
|
|
459
|
+
<label htmlFor={urlId} className={LABEL_CLASS}>
|
|
460
|
+
Organization URL
|
|
461
|
+
<SettingsSourceBadge meta={sources.organizationUrl} />
|
|
462
|
+
</label>
|
|
463
|
+
<input
|
|
464
|
+
id={urlId}
|
|
465
|
+
type="url"
|
|
466
|
+
value={form.organizationUrl}
|
|
467
|
+
disabled={!urlEditable}
|
|
468
|
+
placeholder={siteUrl || 'https://example.com'}
|
|
469
|
+
aria-invalid={urlIssue?.severity === 'error'}
|
|
470
|
+
onChange={(e) => setField('organizationUrl', e.target.value)}
|
|
471
|
+
className={INPUT_CLASS}
|
|
472
|
+
/>
|
|
473
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
474
|
+
Canonical organization homepage. Falls back to site URL when empty.
|
|
475
|
+
</p>
|
|
476
|
+
{urlIssue && <p className="text-destructive mt-1 text-sm">{urlIssue.message}</p>}
|
|
477
|
+
</div>
|
|
478
|
+
|
|
479
|
+
<div>
|
|
480
|
+
<span className={LABEL_CLASS}>
|
|
481
|
+
Organization logo
|
|
482
|
+
<SettingsSourceBadge meta={sources.organizationLogo} />
|
|
483
|
+
</span>
|
|
484
|
+
<div className="flex items-center gap-3">
|
|
485
|
+
<div className="border-border bg-muted flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-md border">
|
|
486
|
+
{form.organizationLogo ? (
|
|
487
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
488
|
+
<img
|
|
489
|
+
src={form.organizationLogo}
|
|
490
|
+
alt="Organization logo preview"
|
|
491
|
+
className="h-full w-full object-contain p-1"
|
|
492
|
+
/>
|
|
493
|
+
) : (
|
|
494
|
+
<ImageIcon size={20} className="text-muted-foreground" aria-hidden="true" />
|
|
495
|
+
)}
|
|
496
|
+
</div>
|
|
497
|
+
<div className="flex flex-col gap-2">
|
|
498
|
+
<div className="flex items-center gap-2">
|
|
499
|
+
<button
|
|
500
|
+
type="button"
|
|
501
|
+
onClick={() => setPickerOpen(true)}
|
|
502
|
+
disabled={!logoEditable}
|
|
503
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
504
|
+
>
|
|
505
|
+
<Upload size={14} aria-hidden="true" />
|
|
506
|
+
{form.organizationLogo ? 'Replace logo' : 'Choose logo'}
|
|
507
|
+
</button>
|
|
508
|
+
{form.organizationLogo && logoEditable && (
|
|
509
|
+
<button
|
|
510
|
+
type="button"
|
|
511
|
+
onClick={() => setField('organizationLogo', '')}
|
|
512
|
+
className="text-muted-foreground hover:text-destructive inline-flex items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors"
|
|
513
|
+
>
|
|
514
|
+
<X size={14} aria-hidden="true" />
|
|
515
|
+
Remove
|
|
516
|
+
</button>
|
|
517
|
+
)}
|
|
518
|
+
</div>
|
|
519
|
+
<p className="text-muted-foreground text-sm">Square PNG or SVG recommended.</p>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
{logoIssue && <p className="text-destructive mt-1 text-sm">{logoIssue.message}</p>}
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<div>
|
|
526
|
+
<label htmlFor={sameAsId} className={LABEL_CLASS}>
|
|
527
|
+
Social profiles (sameAs)
|
|
528
|
+
<SettingsSourceBadge meta={sources.organizationSameAs} />
|
|
529
|
+
</label>
|
|
530
|
+
<textarea
|
|
531
|
+
id={sameAsId}
|
|
532
|
+
rows={4}
|
|
533
|
+
value={form.organizationSameAs}
|
|
534
|
+
disabled={!sameAsEditable}
|
|
535
|
+
placeholder={'https://linkedin.com/company/your-brand\nhttps://twitter.com/your-brand'}
|
|
536
|
+
aria-invalid={sameAsIssue?.severity === 'error'}
|
|
537
|
+
onChange={(e) => setField('organizationSameAs', e.target.value)}
|
|
538
|
+
className={`${INPUT_CLASS} resize-y font-mono text-sm`}
|
|
539
|
+
/>
|
|
540
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
541
|
+
One HTTPS profile URL per line (LinkedIn, X, Facebook, YouTube, etc.).
|
|
542
|
+
</p>
|
|
543
|
+
{sameAsIssue && <p className="text-destructive mt-1 text-sm">{sameAsIssue.message}</p>}
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<MediaPickerModal
|
|
548
|
+
open={pickerOpen}
|
|
549
|
+
onClose={() => setPickerOpen(false)}
|
|
550
|
+
accept="image/*"
|
|
551
|
+
onSelect={(url) => setField('organizationLogo', url)}
|
|
552
|
+
/>
|
|
553
|
+
</SettingsCard>
|
|
554
|
+
)
|
|
555
|
+
}
|
|
556
|
+
|
|
412
557
|
// ---------------------------------------------------------------------------
|
|
413
558
|
// Search Engine Verification
|
|
414
559
|
// ---------------------------------------------------------------------------
|
|
@@ -39,6 +39,14 @@ export interface SeoSettingsForm {
|
|
|
39
39
|
autoGenerateSitemap: boolean
|
|
40
40
|
/** Notify Google/Bing when the sitemap changes (`module`). */
|
|
41
41
|
pingOnPublish: boolean
|
|
42
|
+
/** Schema.org Organization name (falls back to site name when empty). */
|
|
43
|
+
organizationName: string
|
|
44
|
+
/** Canonical organization URL (falls back to site URL when empty). */
|
|
45
|
+
organizationUrl: string
|
|
46
|
+
/** Absolute logo URL for Organization JSON-LD. */
|
|
47
|
+
organizationLogo: string
|
|
48
|
+
/** Social profile URLs, one per line (`sameAs`). */
|
|
49
|
+
organizationSameAs: string
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
export interface SitemapSummary {
|
|
@@ -61,6 +69,10 @@ const EMPTY_FORM: SeoSettingsForm = {
|
|
|
61
69
|
facebookDomainVerification: '',
|
|
62
70
|
autoGenerateSitemap: true,
|
|
63
71
|
pingOnPublish: true,
|
|
72
|
+
organizationName: '',
|
|
73
|
+
organizationUrl: '',
|
|
74
|
+
organizationLogo: '',
|
|
75
|
+
organizationSameAs: '',
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
/** Recommended max length for the default meta description (SERP truncation). */
|
|
@@ -93,6 +105,31 @@ export function validateTitleTemplateClient(template: string): {
|
|
|
93
105
|
return { valid: true, unknownTokens: [] }
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
function parseSameAsLines(raw: string): string[] {
|
|
109
|
+
return raw
|
|
110
|
+
.split('\n')
|
|
111
|
+
.map((line) => line.trim())
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validateUrlField(
|
|
116
|
+
value: string,
|
|
117
|
+
field: keyof SeoSettingsForm,
|
|
118
|
+
label: string,
|
|
119
|
+
): ValidationIssue[] {
|
|
120
|
+
const trimmed = value.trim()
|
|
121
|
+
if (!trimmed) return []
|
|
122
|
+
try {
|
|
123
|
+
const u = new URL(trimmed)
|
|
124
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
|
|
125
|
+
return [{ field, message: `${label} must use http:// or https://`, severity: 'error' }]
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
return [{ field, message: `${label} is not a valid URL.`, severity: 'error' }]
|
|
129
|
+
}
|
|
130
|
+
return []
|
|
131
|
+
}
|
|
132
|
+
|
|
96
133
|
function validate(form: SeoSettingsForm): ValidationIssue[] {
|
|
97
134
|
const issues: ValidationIssue[] = []
|
|
98
135
|
const tpl = validateTitleTemplateClient(form.titleTemplate)
|
|
@@ -122,6 +159,11 @@ function validate(form: SeoSettingsForm): ValidationIssue[] {
|
|
|
122
159
|
severity: 'warning',
|
|
123
160
|
})
|
|
124
161
|
}
|
|
162
|
+
issues.push(...validateUrlField(form.organizationUrl, 'organizationUrl', 'Organization URL'))
|
|
163
|
+
issues.push(...validateUrlField(form.organizationLogo, 'organizationLogo', 'Organization logo'))
|
|
164
|
+
for (const url of parseSameAsLines(form.organizationSameAs)) {
|
|
165
|
+
issues.push(...validateUrlField(url, 'organizationSameAs', 'Social profile URL'))
|
|
166
|
+
}
|
|
125
167
|
return issues
|
|
126
168
|
}
|
|
127
169
|
|
|
@@ -192,6 +234,7 @@ export function useSeoSettings(canEdit: boolean): UseSeoSettings {
|
|
|
192
234
|
const staticSite = seo.static?.site ?? {}
|
|
193
235
|
const overSite = seo.overrides?.site ?? {}
|
|
194
236
|
const verification = effectiveSite.verification ?? {}
|
|
237
|
+
const organization = effectiveSite.organization ?? {}
|
|
195
238
|
const crawl = crawlRes.data ?? {}
|
|
196
239
|
|
|
197
240
|
const loaded: SeoSettingsForm = {
|
|
@@ -206,6 +249,14 @@ export function useSeoSettings(canEdit: boolean): UseSeoSettings {
|
|
|
206
249
|
facebookDomainVerification: String(verification.facebookDomain ?? ''),
|
|
207
250
|
autoGenerateSitemap: crawl.autoRegenerateSitemapOnPublish !== false,
|
|
208
251
|
pingOnPublish: crawl.pingSearchEnginesOnPublish !== false,
|
|
252
|
+
organizationName: String(organization.name ?? ''),
|
|
253
|
+
organizationUrl: String(organization.url ?? ''),
|
|
254
|
+
organizationLogo: String(organization.logo ?? ''),
|
|
255
|
+
organizationSameAs: Array.isArray(organization.sameAs)
|
|
256
|
+
? (organization.sameAs as unknown[])
|
|
257
|
+
.filter((u): u is string => typeof u === 'string')
|
|
258
|
+
.join('\n')
|
|
259
|
+
: '',
|
|
209
260
|
}
|
|
210
261
|
|
|
211
262
|
// `editable` reflects the field's MANAGEMENT source (config vs database),
|
|
@@ -216,11 +267,19 @@ export function useSeoSettings(canEdit: boolean): UseSeoSettings {
|
|
|
216
267
|
staticSite[key]
|
|
217
268
|
? { source: 'config', editable: false, reason: 'Set in actuate.config.ts' }
|
|
218
269
|
: { source: 'database', editable: true }
|
|
270
|
+
const orgConfigManaged = (): SettingsSourceMetadata =>
|
|
271
|
+
staticSite.organization
|
|
272
|
+
? { source: 'config', editable: false, reason: 'Set in actuate.config.ts' }
|
|
273
|
+
: { source: 'database', editable: true }
|
|
219
274
|
|
|
220
275
|
setSources({
|
|
221
276
|
titleTemplate: configManaged('titleTemplate'),
|
|
222
277
|
defaultMetaDescription: configManaged('defaultMetaDescription'),
|
|
223
278
|
defaultOgImage: configManaged('defaultOgImage'),
|
|
279
|
+
organizationName: orgConfigManaged(),
|
|
280
|
+
organizationUrl: orgConfigManaged(),
|
|
281
|
+
organizationLogo: orgConfigManaged(),
|
|
282
|
+
organizationSameAs: orgConfigManaged(),
|
|
224
283
|
verification: { source: 'database', editable: true },
|
|
225
284
|
sitemap: { source: 'database', editable: true },
|
|
226
285
|
})
|
|
@@ -279,7 +338,11 @@ export function useSeoSettings(canEdit: boolean): UseSeoSettings {
|
|
|
279
338
|
form.bingVerification !== baseline.bingVerification ||
|
|
280
339
|
form.yandexVerification !== baseline.yandexVerification ||
|
|
281
340
|
form.pinterestVerification !== baseline.pinterestVerification ||
|
|
282
|
-
form.facebookDomainVerification !== baseline.facebookDomainVerification
|
|
341
|
+
form.facebookDomainVerification !== baseline.facebookDomainVerification ||
|
|
342
|
+
form.organizationName !== baseline.organizationName ||
|
|
343
|
+
form.organizationUrl !== baseline.organizationUrl ||
|
|
344
|
+
form.organizationLogo !== baseline.organizationLogo ||
|
|
345
|
+
form.organizationSameAs !== baseline.organizationSameAs
|
|
283
346
|
const sitemapChanged =
|
|
284
347
|
form.autoGenerateSitemap !== baseline.autoGenerateSitemap ||
|
|
285
348
|
form.pingOnPublish !== baseline.pingOnPublish
|
|
@@ -302,6 +365,16 @@ export function useSeoSettings(canEdit: boolean): UseSeoSettings {
|
|
|
302
365
|
pinterest: form.pinterestVerification.trim() || undefined,
|
|
303
366
|
facebookDomain: form.facebookDomainVerification.trim() || undefined,
|
|
304
367
|
},
|
|
368
|
+
organization: {
|
|
369
|
+
...(overrideSite.organization ?? {}),
|
|
370
|
+
name: form.organizationName.trim() || undefined,
|
|
371
|
+
url: form.organizationUrl.trim() || undefined,
|
|
372
|
+
logo: form.organizationLogo.trim() || undefined,
|
|
373
|
+
sameAs: (() => {
|
|
374
|
+
const urls = parseSameAsLines(form.organizationSameAs)
|
|
375
|
+
return urls.length > 0 ? urls : undefined
|
|
376
|
+
})(),
|
|
377
|
+
},
|
|
305
378
|
}
|
|
306
379
|
const res = await cmsApi('/seo/config', {
|
|
307
380
|
method: 'PUT',
|