@prenta/admin 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/__tests__/lib/preview-link.test.js +8 -0
- package/dist/__tests__/lib/preview-link.test.js.map +1 -1
- package/dist/components/SEOConfigPanel.d.ts.map +1 -1
- package/dist/components/SEOConfigPanel.js +1 -1
- package/dist/components/SEOConfigPanel.js.map +1 -1
- package/dist/hooks/usePublicSiteUrl.d.ts +3 -0
- package/dist/hooks/usePublicSiteUrl.d.ts.map +1 -1
- package/dist/hooks/usePublicSiteUrl.js +3 -0
- package/dist/hooks/usePublicSiteUrl.js.map +1 -1
- package/dist/lib/open-public-site-tab.d.ts +2 -0
- package/dist/lib/open-public-site-tab.d.ts.map +1 -1
- package/dist/lib/open-public-site-tab.js +1 -0
- package/dist/lib/open-public-site-tab.js.map +1 -1
- package/dist/lib/preview-link.d.ts +5 -2
- package/dist/lib/preview-link.d.ts.map +1 -1
- package/dist/lib/preview-link.js +7 -10
- package/dist/lib/preview-link.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.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +3 -2
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +3 -2
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/seo/LinksTab.d.ts.map +1 -1
- package/dist/views/seo/LinksTab.js +2 -2
- package/dist/views/seo/LinksTab.js.map +1 -1
- package/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
- package/dist/views/settings/SiteInformationCard.js +2 -1
- package/dist/views/settings/SiteInformationCard.js.map +1 -1
- package/dist/views/settings/useGeneralSettings.d.ts +1 -0
- package/dist/views/settings/useGeneralSettings.d.ts.map +1 -1
- package/dist/views/settings/useGeneralSettings.js +6 -1
- package/dist/views/settings/useGeneralSettings.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/lib/preview-link.test.ts +11 -0
- package/src/components/SEOConfigPanel.tsx +11 -0
- package/src/hooks/usePublicSiteUrl.ts +7 -0
- package/src/lib/open-public-site-tab.ts +3 -0
- package/src/lib/preview-link.ts +24 -8
- package/src/lib/seo-service.ts +2 -0
- package/src/views/page-editor/PageSectionEditor.tsx +7 -2
- package/src/views/post-editor/PostSectionEditor.tsx +7 -2
- package/src/views/seo/LinksTab.tsx +9 -3
- package/src/views/settings/SiteInformationCard.tsx +23 -0
- package/src/views/settings/useGeneralSettings.ts +6 -1
|
@@ -24,6 +24,7 @@ interface RobotsSettings {
|
|
|
24
24
|
interface SiteSEO {
|
|
25
25
|
siteUrl?: string
|
|
26
26
|
siteName?: string
|
|
27
|
+
trailingSlash?: 'always' | 'never'
|
|
27
28
|
defaultOgImage?: string
|
|
28
29
|
twitterHandle?: string
|
|
29
30
|
robots?: { blockAIBots?: boolean; disabled?: boolean }
|
|
@@ -254,6 +255,16 @@ export function SEOConfigPanel() {
|
|
|
254
255
|
onChange={(v) => patchSite('siteUrl', v)}
|
|
255
256
|
hint="Canonical origin used in sitemap URLs and JSON-LD."
|
|
256
257
|
/>
|
|
258
|
+
<Select
|
|
259
|
+
label="Trailing slashes"
|
|
260
|
+
value={site.trailingSlash ?? ''}
|
|
261
|
+
options={['', 'always', 'never']}
|
|
262
|
+
onChange={(v) =>
|
|
263
|
+
patchSite('trailingSlash', (v || undefined) as 'always' | 'never' | undefined)
|
|
264
|
+
}
|
|
265
|
+
placeholderLabel={`default: ${staticSite.trailingSlash ?? 'never'}`}
|
|
266
|
+
hint="Must match the public adapter. Sitemap, canonicals, href rewrite, link-health."
|
|
267
|
+
/>
|
|
257
268
|
<Field
|
|
258
269
|
label="Site name"
|
|
259
270
|
placeholder={staticSite.siteName ?? 'My Site'}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from 'react'
|
|
4
|
+
import type { TrailingSlashPolicy } from '@prenta/core/client'
|
|
4
5
|
import { cmsApi } from '../lib/api.js'
|
|
5
6
|
import { siteUrlMatchesAdminOrigin } from '../lib/preview-link.js'
|
|
6
7
|
|
|
@@ -9,6 +10,8 @@ export type PublicSiteUrlSource = 'api' | 'config' | 'none'
|
|
|
9
10
|
export interface PublicSiteUrlState {
|
|
10
11
|
/** Effective public apex (no trailing slash), ready for buildPublicUrl. */
|
|
11
12
|
siteUrl: string
|
|
13
|
+
/** Public URL spelling from effective SEO config. */
|
|
14
|
+
trailingSlash: TrailingSlashPolicy
|
|
12
15
|
loading: boolean
|
|
13
16
|
source: PublicSiteUrlSource
|
|
14
17
|
/**
|
|
@@ -21,6 +24,7 @@ export interface PublicSiteUrlState {
|
|
|
21
24
|
interface PublicUrlApiData {
|
|
22
25
|
siteUrl: string | null
|
|
23
26
|
source: 'override' | 'config' | null
|
|
27
|
+
trailingSlash?: TrailingSlashPolicy | null
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
/**
|
|
@@ -35,6 +39,7 @@ interface PublicUrlApiData {
|
|
|
35
39
|
export function usePublicSiteUrl(configFallback?: string | null): PublicSiteUrlState {
|
|
36
40
|
const fallback = (configFallback ?? '').trim().replace(/\/+$/, '')
|
|
37
41
|
const [apiUrl, setApiUrl] = useState<string | null>(null)
|
|
42
|
+
const [trailingSlash, setTrailingSlash] = useState<TrailingSlashPolicy>('never')
|
|
38
43
|
const [loading, setLoading] = useState(true)
|
|
39
44
|
const [source, setSource] = useState<PublicSiteUrlSource>(fallback ? 'config' : 'none')
|
|
40
45
|
|
|
@@ -45,6 +50,7 @@ export function usePublicSiteUrl(configFallback?: string | null): PublicSiteUrlS
|
|
|
45
50
|
.then((res) => {
|
|
46
51
|
if (cancelled) return
|
|
47
52
|
const url = (res.data?.siteUrl ?? '').trim().replace(/\/+$/, '')
|
|
53
|
+
setTrailingSlash(res.data?.trailingSlash === 'always' ? 'always' : 'never')
|
|
48
54
|
if (url) {
|
|
49
55
|
setApiUrl(url)
|
|
50
56
|
setSource('api')
|
|
@@ -70,6 +76,7 @@ export function usePublicSiteUrl(configFallback?: string | null): PublicSiteUrlS
|
|
|
70
76
|
const siteUrl = apiUrl || fallback
|
|
71
77
|
return {
|
|
72
78
|
siteUrl,
|
|
79
|
+
trailingSlash,
|
|
73
80
|
loading,
|
|
74
81
|
source,
|
|
75
82
|
matchesAdminOrigin: siteUrlMatchesAdminOrigin(siteUrl),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { toast } from 'sonner'
|
|
2
|
+
import type { TrailingSlashPolicy } from '@prenta/core/client'
|
|
2
3
|
import { buildPublicPreviewUrl, buildPublicUrl, createPreviewToken } from './preview-link.js'
|
|
3
4
|
import { NO_PUBLIC_SITE_URL_REASON, type ViewSiteIntent } from './view-site-chrome.js'
|
|
4
5
|
|
|
@@ -11,6 +12,7 @@ export async function openPublicSiteTab(opts: {
|
|
|
11
12
|
path?: string | null
|
|
12
13
|
urlPrefix?: string
|
|
13
14
|
data?: Record<string, unknown> | null
|
|
15
|
+
trailingSlash?: TrailingSlashPolicy | null
|
|
14
16
|
dirty: boolean
|
|
15
17
|
saveDraft: () => Promise<boolean>
|
|
16
18
|
}): Promise<void> {
|
|
@@ -23,6 +25,7 @@ export async function openPublicSiteTab(opts: {
|
|
|
23
25
|
slug: opts.slug,
|
|
24
26
|
path: opts.path,
|
|
25
27
|
data: opts.data,
|
|
28
|
+
trailingSlash: opts.trailingSlash,
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
if (opts.intent === 'view-live') {
|
package/src/lib/preview-link.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
documentPublicPath,
|
|
3
|
+
resolveDocumentUrl,
|
|
4
|
+
type TrailingSlashPolicy,
|
|
5
|
+
} from '@prenta/core/client'
|
|
2
6
|
import { cmsApi } from './api.js'
|
|
3
7
|
|
|
4
8
|
/**
|
|
@@ -40,6 +44,8 @@ export interface PublicUrlParts {
|
|
|
40
44
|
path?: string | null
|
|
41
45
|
/** Document `data` bag; `data.path` wins when `path` is omitted. */
|
|
42
46
|
data?: Record<string, unknown> | null
|
|
47
|
+
/** Must match `seo.trailingSlash` so View live opens the adapter URL. */
|
|
48
|
+
trailingSlash?: TrailingSlashPolicy | null
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
export interface PublicPreviewUrlParts extends PublicUrlParts {
|
|
@@ -47,10 +53,12 @@ export interface PublicPreviewUrlParts extends PublicUrlParts {
|
|
|
47
53
|
token: string
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
function absolutePublicUrl(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
function absolutePublicUrl(
|
|
57
|
+
siteUrl: string,
|
|
58
|
+
path: string,
|
|
59
|
+
trailingSlash?: TrailingSlashPolicy | null,
|
|
60
|
+
): string {
|
|
61
|
+
return resolveDocumentUrl({ siteUrl, path, trailingSlash })
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/**
|
|
@@ -65,6 +73,7 @@ export function buildPublicPreviewUrl({
|
|
|
65
73
|
token,
|
|
66
74
|
path,
|
|
67
75
|
data,
|
|
76
|
+
trailingSlash,
|
|
68
77
|
}: PublicPreviewUrlParts): string {
|
|
69
78
|
const publicPath = documentPublicPath({ urlPrefix, slug, path, data })
|
|
70
79
|
// Home → `https://example.com/?preview=` (slash before query keeps relative
|
|
@@ -73,7 +82,7 @@ export function buildPublicPreviewUrl({
|
|
|
73
82
|
const root = (siteUrl ?? '').replace(/\/+$/, '')
|
|
74
83
|
return `${root || ''}/?preview=${encodeURIComponent(token)}`
|
|
75
84
|
}
|
|
76
|
-
return `${absolutePublicUrl(siteUrl, publicPath)}?preview=${encodeURIComponent(token)}`
|
|
85
|
+
return `${absolutePublicUrl(siteUrl, publicPath, trailingSlash)}?preview=${encodeURIComponent(token)}`
|
|
77
86
|
}
|
|
78
87
|
|
|
79
88
|
/**
|
|
@@ -81,8 +90,15 @@ export function buildPublicPreviewUrl({
|
|
|
81
90
|
* {@link buildPublicPreviewUrl} without a preview token. Home-style slugs and
|
|
82
91
|
* `data.path` are handled by {@link documentPublicPath}.
|
|
83
92
|
*/
|
|
84
|
-
export function buildPublicUrl({
|
|
85
|
-
|
|
93
|
+
export function buildPublicUrl({
|
|
94
|
+
siteUrl,
|
|
95
|
+
urlPrefix,
|
|
96
|
+
slug,
|
|
97
|
+
path,
|
|
98
|
+
data,
|
|
99
|
+
trailingSlash,
|
|
100
|
+
}: PublicUrlParts): string {
|
|
101
|
+
return resolveDocumentUrl({ siteUrl, urlPrefix, slug, path, data, trailingSlash })
|
|
86
102
|
}
|
|
87
103
|
|
|
88
104
|
/**
|
package/src/lib/seo-service.ts
CHANGED
|
@@ -218,7 +218,11 @@ export function PageSectionEditor({
|
|
|
218
218
|
|
|
219
219
|
const { canEdit, canPublish, canCreateRedirect } = useMemo(() => deriveCan(session), [session])
|
|
220
220
|
const customRenderers = useAdminSectionRenderers()
|
|
221
|
-
const {
|
|
221
|
+
const {
|
|
222
|
+
siteUrl: publicSiteUrl,
|
|
223
|
+
trailingSlash,
|
|
224
|
+
matchesAdminOrigin,
|
|
225
|
+
} = usePublicSiteUrl(config?.seo?.siteUrl)
|
|
222
226
|
|
|
223
227
|
// A misconfigured `pages` collection (missing the fields the editor
|
|
224
228
|
// writes) would silently drop content on save — detect it up front.
|
|
@@ -748,13 +752,14 @@ export function PageSectionEditor({
|
|
|
748
752
|
documentId: page.id,
|
|
749
753
|
slug: page.slug,
|
|
750
754
|
path: page.path,
|
|
755
|
+
trailingSlash,
|
|
751
756
|
dirty,
|
|
752
757
|
saveDraft: handleSaveDraft,
|
|
753
758
|
}).catch((err) => {
|
|
754
759
|
toast.error(err instanceof Error ? err.message : 'Failed to open the site')
|
|
755
760
|
})
|
|
756
761
|
},
|
|
757
|
-
[page, dirty, handleSaveDraft, publicSiteUrl],
|
|
762
|
+
[page, dirty, handleSaveDraft, publicSiteUrl, trailingSlash],
|
|
758
763
|
)
|
|
759
764
|
|
|
760
765
|
const handleShare = useCallback(async () => {
|
|
@@ -182,7 +182,11 @@ export function PostSectionEditor({
|
|
|
182
182
|
|
|
183
183
|
const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
|
|
184
184
|
const customRenderers = useAdminSectionRenderers()
|
|
185
|
-
const {
|
|
185
|
+
const {
|
|
186
|
+
siteUrl: publicSiteUrl,
|
|
187
|
+
trailingSlash,
|
|
188
|
+
matchesAdminOrigin,
|
|
189
|
+
} = usePublicSiteUrl(config?.seo?.siteUrl)
|
|
186
190
|
|
|
187
191
|
const typeLabel = useMemo(() => {
|
|
188
192
|
const col = config?.collections?.[postType]
|
|
@@ -610,13 +614,14 @@ export function PostSectionEditor({
|
|
|
610
614
|
slug: post.slug,
|
|
611
615
|
urlPrefix,
|
|
612
616
|
data: { path: (post as { path?: string }).path },
|
|
617
|
+
trailingSlash,
|
|
613
618
|
dirty,
|
|
614
619
|
saveDraft: handleSaveDraft,
|
|
615
620
|
}).catch((err) => {
|
|
616
621
|
toast.error(err instanceof Error ? err.message : 'Failed to open the site')
|
|
617
622
|
})
|
|
618
623
|
},
|
|
619
|
-
[post, postType, urlPrefix, dirty, handleSaveDraft, publicSiteUrl],
|
|
624
|
+
[post, postType, urlPrefix, dirty, handleSaveDraft, publicSiteUrl, trailingSlash],
|
|
620
625
|
)
|
|
621
626
|
|
|
622
627
|
const handleShare = useCallback(async () => {
|
|
@@ -413,7 +413,7 @@ export function LinksTab({ onNavigate }: { onNavigate?: (path: string) => void }
|
|
|
413
413
|
{/* Live link health */}
|
|
414
414
|
<SectionCard
|
|
415
415
|
title="Live link health scan"
|
|
416
|
-
description="Probe outbound URLs (rate-limited).
|
|
416
|
+
description="Probe outbound URLs (rate-limited). Reports broken links and internal redirects (3xx) so trailing-slash hrefs can be rewritten to the canonical URL."
|
|
417
417
|
action={
|
|
418
418
|
<button
|
|
419
419
|
type="button"
|
|
@@ -438,7 +438,13 @@ export function LinksTab({ onNavigate }: { onNavigate?: (path: string) => void }
|
|
|
438
438
|
<p className="text-muted-foreground mt-0.5 flex items-center gap-1 truncate text-xs">
|
|
439
439
|
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden />
|
|
440
440
|
{issue.url}
|
|
441
|
-
|
|
441
|
+
{issue.kind === 'redirected' ? (
|
|
442
|
+
<span className="text-muted-foreground ml-1">
|
|
443
|
+
HTTP {issue.status} → {issue.redirectTarget ?? 'canonical URL'}
|
|
444
|
+
</span>
|
|
445
|
+
) : (
|
|
446
|
+
<span className="text-destructive ml-1">HTTP {issue.status || 'error'}</span>
|
|
447
|
+
)}
|
|
442
448
|
<span className="text-muted-foreground">· {issue.type}</span>
|
|
443
449
|
</p>
|
|
444
450
|
</li>
|
|
@@ -447,7 +453,7 @@ export function LinksTab({ onNavigate }: { onNavigate?: (path: string) => void }
|
|
|
447
453
|
) : (
|
|
448
454
|
<p className="text-muted-foreground text-sm">
|
|
449
455
|
{healthIssues
|
|
450
|
-
? 'No
|
|
456
|
+
? 'No broken or redirecting links in the last scan.'
|
|
451
457
|
: 'Run a scan to check live URL responses across published content.'}
|
|
452
458
|
</p>
|
|
453
459
|
)}
|
|
@@ -33,6 +33,7 @@ export function SiteInformationCard({
|
|
|
33
33
|
const taglineId = useId()
|
|
34
34
|
const urlId = useId()
|
|
35
35
|
const urlErrId = useId()
|
|
36
|
+
const slashId = useId()
|
|
36
37
|
|
|
37
38
|
const titleIssue = issues.find((i) => i.field === 'siteTitle')
|
|
38
39
|
const urlIssue = issues.find((i) => i.field === 'siteUrl')
|
|
@@ -121,6 +122,28 @@ export function SiteInformationCard({
|
|
|
121
122
|
this URL.
|
|
122
123
|
</p>
|
|
123
124
|
</div>
|
|
125
|
+
|
|
126
|
+
<div>
|
|
127
|
+
<label htmlFor={slashId} className={LABEL_CLASS}>
|
|
128
|
+
Trailing slashes
|
|
129
|
+
</label>
|
|
130
|
+
<select
|
|
131
|
+
id={slashId}
|
|
132
|
+
value={form.trailingSlash}
|
|
133
|
+
disabled={!canEdit}
|
|
134
|
+
onChange={(e) =>
|
|
135
|
+
setField('trailingSlash', e.target.value === 'always' ? 'always' : 'never')
|
|
136
|
+
}
|
|
137
|
+
className={INPUT_CLASS}
|
|
138
|
+
>
|
|
139
|
+
<option value="never">Never — /about (Next.js default)</option>
|
|
140
|
+
<option value="always">Always — /about/ (Astro trailingSlash: always)</option>
|
|
141
|
+
</select>
|
|
142
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
143
|
+
Must match the public adapter. Drives sitemap, canonical / OG URLs, href rewrite on
|
|
144
|
+
save, and link-health.
|
|
145
|
+
</p>
|
|
146
|
+
</div>
|
|
124
147
|
</div>
|
|
125
148
|
</SettingsCard>
|
|
126
149
|
)
|
|
@@ -8,6 +8,7 @@ export interface GeneralSettingsForm {
|
|
|
8
8
|
siteTitle: string
|
|
9
9
|
tagline: string
|
|
10
10
|
siteUrl: string
|
|
11
|
+
trailingSlash: 'always' | 'never'
|
|
11
12
|
language: string
|
|
12
13
|
timezone: string
|
|
13
14
|
defaultNoIndex: boolean
|
|
@@ -59,6 +60,7 @@ const EMPTY_FORM: GeneralSettingsForm = {
|
|
|
59
60
|
siteTitle: '',
|
|
60
61
|
tagline: '',
|
|
61
62
|
siteUrl: '',
|
|
63
|
+
trailingSlash: 'never',
|
|
62
64
|
language: 'en',
|
|
63
65
|
timezone: 'UTC',
|
|
64
66
|
defaultNoIndex: false,
|
|
@@ -212,6 +214,7 @@ export function useGeneralSettings(canEdit: boolean): UseGeneralSettings {
|
|
|
212
214
|
siteTitle: String(settingsData.siteTitle ?? ''),
|
|
213
215
|
tagline: String(settingsData.tagline ?? ''),
|
|
214
216
|
siteUrl: String(effectiveSite.siteUrl ?? settingsData.siteUrl ?? ''),
|
|
217
|
+
trailingSlash: effectiveSite.trailingSlash === 'always' ? 'always' : 'never',
|
|
215
218
|
language: String(settingsData.language ?? 'en'),
|
|
216
219
|
timezone: String(settingsData.timezone ?? 'UTC'),
|
|
217
220
|
defaultNoIndex: robots.defaultNoIndex === true,
|
|
@@ -302,6 +305,7 @@ export function useGeneralSettings(canEdit: boolean): UseGeneralSettings {
|
|
|
302
305
|
form.language !== baseline.language ||
|
|
303
306
|
form.timezone !== baseline.timezone
|
|
304
307
|
const siteUrlChanged = siteUrlEditable && form.siteUrl !== baseline.siteUrl
|
|
308
|
+
const trailingSlashChanged = form.trailingSlash !== baseline.trailingSlash
|
|
305
309
|
const robotsChanged =
|
|
306
310
|
form.defaultNoIndex !== baseline.defaultNoIndex ||
|
|
307
311
|
form.defaultNoFollow !== baseline.defaultNoFollow ||
|
|
@@ -336,9 +340,10 @@ export function useGeneralSettings(canEdit: boolean): UseGeneralSettings {
|
|
|
336
340
|
|
|
337
341
|
// 2. Site URL + robots defaults → the SHARED SEO config (merge over the
|
|
338
342
|
// current override site so SEO-tab fields aren't lost).
|
|
339
|
-
if (siteUrlChanged || robotsChanged) {
|
|
343
|
+
if (siteUrlChanged || trailingSlashChanged || robotsChanged) {
|
|
340
344
|
const nextSite: Record<string, any> = { ...overrideSite }
|
|
341
345
|
if (siteUrlChanged) nextSite.siteUrl = form.siteUrl.trim() || undefined
|
|
346
|
+
if (trailingSlashChanged) nextSite.trailingSlash = form.trailingSlash
|
|
342
347
|
if (robotsChanged) {
|
|
343
348
|
nextSite.robots = {
|
|
344
349
|
...(overrideSite.robots ?? {}),
|