@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/lib/preview-link.test.js +8 -0
  3. package/dist/__tests__/lib/preview-link.test.js.map +1 -1
  4. package/dist/components/SEOConfigPanel.d.ts.map +1 -1
  5. package/dist/components/SEOConfigPanel.js +1 -1
  6. package/dist/components/SEOConfigPanel.js.map +1 -1
  7. package/dist/hooks/usePublicSiteUrl.d.ts +3 -0
  8. package/dist/hooks/usePublicSiteUrl.d.ts.map +1 -1
  9. package/dist/hooks/usePublicSiteUrl.js +3 -0
  10. package/dist/hooks/usePublicSiteUrl.js.map +1 -1
  11. package/dist/lib/open-public-site-tab.d.ts +2 -0
  12. package/dist/lib/open-public-site-tab.d.ts.map +1 -1
  13. package/dist/lib/open-public-site-tab.js +1 -0
  14. package/dist/lib/open-public-site-tab.js.map +1 -1
  15. package/dist/lib/preview-link.d.ts +5 -2
  16. package/dist/lib/preview-link.d.ts.map +1 -1
  17. package/dist/lib/preview-link.js +7 -10
  18. package/dist/lib/preview-link.js.map +1 -1
  19. package/dist/lib/seo-service.d.ts +2 -0
  20. package/dist/lib/seo-service.d.ts.map +1 -1
  21. package/dist/lib/seo-service.js.map +1 -1
  22. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  23. package/dist/views/page-editor/PageSectionEditor.js +3 -2
  24. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  25. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  26. package/dist/views/post-editor/PostSectionEditor.js +3 -2
  27. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  28. package/dist/views/seo/LinksTab.d.ts.map +1 -1
  29. package/dist/views/seo/LinksTab.js +2 -2
  30. package/dist/views/seo/LinksTab.js.map +1 -1
  31. package/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
  32. package/dist/views/settings/SiteInformationCard.js +2 -1
  33. package/dist/views/settings/SiteInformationCard.js.map +1 -1
  34. package/dist/views/settings/useGeneralSettings.d.ts +1 -0
  35. package/dist/views/settings/useGeneralSettings.d.ts.map +1 -1
  36. package/dist/views/settings/useGeneralSettings.js +6 -1
  37. package/dist/views/settings/useGeneralSettings.js.map +1 -1
  38. package/package.json +4 -4
  39. package/src/__tests__/lib/preview-link.test.ts +11 -0
  40. package/src/components/SEOConfigPanel.tsx +11 -0
  41. package/src/hooks/usePublicSiteUrl.ts +7 -0
  42. package/src/lib/open-public-site-tab.ts +3 -0
  43. package/src/lib/preview-link.ts +24 -8
  44. package/src/lib/seo-service.ts +2 -0
  45. package/src/views/page-editor/PageSectionEditor.tsx +7 -2
  46. package/src/views/post-editor/PostSectionEditor.tsx +7 -2
  47. package/src/views/seo/LinksTab.tsx +9 -3
  48. package/src/views/settings/SiteInformationCard.tsx +23 -0
  49. 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') {
@@ -1,4 +1,8 @@
1
- import { documentPublicPath } from '@prenta/core/client'
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(siteUrl: string, path: string): string {
51
- const base = (siteUrl ?? '').replace(/\/+$/, '')
52
- if (path === '/') return base || '/'
53
- return `${base}${path}`
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({ siteUrl, urlPrefix, slug, path, data }: PublicUrlParts): string {
85
- return absolutePublicUrl(siteUrl, documentPublicPath({ urlPrefix, slug, path, data }))
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
  /**
@@ -1114,6 +1114,8 @@ export interface LinkHealthIssue {
1114
1114
  url: string
1115
1115
  status: number
1116
1116
  type: string
1117
+ kind?: 'broken' | 'redirected'
1118
+ redirectTarget?: string
1117
1119
  }
1118
1120
 
1119
1121
  export interface LinkSuggestion {
@@ -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 { siteUrl: publicSiteUrl, matchesAdminOrigin } = usePublicSiteUrl(config?.seo?.siteUrl)
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 { siteUrl: publicSiteUrl, matchesAdminOrigin } = usePublicSiteUrl(config?.seo?.siteUrl)
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). Complements audit broken-link issues."
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
- <span className="text-destructive ml-1">HTTP {issue.status || 'error'}</span>
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 failing links in the last scan.'
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 ?? {}),