@prenta/admin 1.9.2 → 1.10.1

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 (94) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/AdminRoot.d.ts +10 -0
  3. package/dist/AdminRoot.d.ts.map +1 -1
  4. package/dist/AdminRoot.js +2 -1
  5. package/dist/AdminRoot.js.map +1 -1
  6. package/dist/__tests__/lib/theme-chrome.test.d.ts +2 -0
  7. package/dist/__tests__/lib/theme-chrome.test.d.ts.map +1 -0
  8. package/dist/__tests__/lib/theme-chrome.test.js +104 -0
  9. package/dist/__tests__/lib/theme-chrome.test.js.map +1 -0
  10. package/dist/__tests__/views/editor-top-bar.render.test.js +11 -0
  11. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  12. package/dist/__tests__/views/theme-chrome-frame.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/theme-chrome-frame.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/theme-chrome-frame.render.test.js +236 -0
  15. package/dist/__tests__/views/theme-chrome-frame.render.test.js.map +1 -0
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -0
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/theme-chrome.d.ts +87 -0
  21. package/dist/lib/theme-chrome.d.ts.map +1 -0
  22. package/dist/lib/theme-chrome.js +117 -0
  23. package/dist/lib/theme-chrome.js.map +1 -0
  24. package/dist/prenta-admin.css +1 -1
  25. package/dist/views/page-editor/AddSectionModal.d.ts.map +1 -1
  26. package/dist/views/page-editor/AddSectionModal.js +2 -2
  27. package/dist/views/page-editor/AddSectionModal.js.map +1 -1
  28. package/dist/views/page-editor/CanvasSurface.d.ts +14 -2
  29. package/dist/views/page-editor/CanvasSurface.d.ts.map +1 -1
  30. package/dist/views/page-editor/CanvasSurface.js +30 -14
  31. package/dist/views/page-editor/CanvasSurface.js.map +1 -1
  32. package/dist/views/page-editor/EditorCanvas.d.ts +12 -1
  33. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  34. package/dist/views/page-editor/EditorCanvas.js +22 -5
  35. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  36. package/dist/views/page-editor/EditorTopBar.d.ts +5 -1
  37. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  38. package/dist/views/page-editor/EditorTopBar.js +3 -2
  39. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  40. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  41. package/dist/views/page-editor/PageSectionEditor.js +30 -2
  42. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  43. package/dist/views/page-editor/section-types.d.ts +1 -1
  44. package/dist/views/page-editor/section-types.d.ts.map +1 -1
  45. package/dist/views/page-editor/section-types.js +1 -1
  46. package/dist/views/page-editor/section-types.js.map +1 -1
  47. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.d.ts +21 -0
  48. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.d.ts.map +1 -0
  49. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.js +34 -0
  50. package/dist/views/page-editor/theme-chrome/ThemeChromeFrame.js.map +1 -0
  51. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.d.ts +12 -0
  52. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.d.ts.map +1 -0
  53. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.js +12 -0
  54. package/dist/views/page-editor/theme-chrome/ThemeChromeHealth.js.map +1 -0
  55. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.d.ts +11 -0
  56. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.d.ts.map +1 -0
  57. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.js +15 -0
  58. package/dist/views/page-editor/theme-chrome/ThemeChromeModeToggle.js.map +1 -0
  59. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.d.ts +18 -0
  60. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.d.ts.map +1 -0
  61. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.js +23 -0
  62. package/dist/views/page-editor/theme-chrome/ThemeChromeProvider.js.map +1 -0
  63. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.d.ts +11 -0
  64. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.d.ts.map +1 -0
  65. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.js +82 -0
  66. package/dist/views/page-editor/theme-chrome/ThemeChromeSlot.js.map +1 -0
  67. package/dist/views/page-editor/theme-chrome/index.d.ts +9 -0
  68. package/dist/views/page-editor/theme-chrome/index.d.ts.map +1 -0
  69. package/dist/views/page-editor/theme-chrome/index.js +6 -0
  70. package/dist/views/page-editor/theme-chrome/index.js.map +1 -0
  71. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.d.ts +3 -0
  72. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.d.ts.map +1 -0
  73. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.js +17 -0
  74. package/dist/views/page-editor/theme-chrome/useThemeChromeMode.js.map +1 -0
  75. package/package.json +4 -4
  76. package/src/AdminRoot.tsx +14 -1
  77. package/src/__tests__/lib/theme-chrome.test.ts +132 -0
  78. package/src/__tests__/views/editor-top-bar.render.test.tsx +13 -0
  79. package/src/__tests__/views/theme-chrome-frame.render.test.tsx +304 -0
  80. package/src/index.ts +17 -0
  81. package/src/lib/theme-chrome.ts +172 -0
  82. package/src/views/page-editor/AddSectionModal.tsx +6 -2
  83. package/src/views/page-editor/CanvasSurface.tsx +70 -24
  84. package/src/views/page-editor/EditorCanvas.tsx +89 -15
  85. package/src/views/page-editor/EditorTopBar.tsx +10 -0
  86. package/src/views/page-editor/PageSectionEditor.tsx +42 -0
  87. package/src/views/page-editor/section-types.ts +2 -0
  88. package/src/views/page-editor/theme-chrome/ThemeChromeFrame.tsx +119 -0
  89. package/src/views/page-editor/theme-chrome/ThemeChromeHealth.tsx +66 -0
  90. package/src/views/page-editor/theme-chrome/ThemeChromeModeToggle.tsx +63 -0
  91. package/src/views/page-editor/theme-chrome/ThemeChromeProvider.tsx +43 -0
  92. package/src/views/page-editor/theme-chrome/ThemeChromeSlot.tsx +235 -0
  93. package/src/views/page-editor/theme-chrome/index.ts +12 -0
  94. package/src/views/page-editor/theme-chrome/useThemeChromeMode.ts +24 -0
@@ -241,4 +241,17 @@ describe('EditorTopBar history button', () => {
241
241
  expect(screen.getByRole('button', { name: 'Post settings' })).toBeTruthy()
242
242
  expect(screen.queryByRole('button', { name: 'Page settings' })).toBeNull()
243
243
  })
244
+
245
+ it('hides the Page / Frame toggle until theme chrome is on', () => {
246
+ render(<EditorTopBar {...baseProps} />)
247
+ expect(screen.queryByRole('group', { name: /Canvas layout/ })).toBeNull()
248
+ })
249
+
250
+ it('toggles Page and Frame when theme chrome is enabled', () => {
251
+ const onChromeMode = vi.fn()
252
+ render(<EditorTopBar {...baseProps} chromeMode="flow" onChromeMode={onChromeMode} />)
253
+ expect(screen.getByRole('button', { name: /^Page$/ }).getAttribute('aria-pressed')).toBe('true')
254
+ fireEvent.click(screen.getByRole('button', { name: /^Frame$/ }))
255
+ expect(onChromeMode).toHaveBeenCalledWith('frame')
256
+ })
244
257
  })
@@ -0,0 +1,304 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
4
+ import type { PageSection } from '../../lib/page-editor-service.js'
5
+ import { THEME_CHROME_MAIN_ID, type AdminThemeChromeProps } from '../../lib/theme-chrome.js'
6
+ import { ThemeChromeProvider } from '../../views/page-editor/theme-chrome/ThemeChromeProvider.js'
7
+ import { EditorCanvas } from '../../views/page-editor/EditorCanvas.js'
8
+
9
+ afterEach(cleanup)
10
+
11
+ const SECTION: PageSection = {
12
+ id: 's1',
13
+ sectionType: 'hero',
14
+ name: 'Hero Banner',
15
+ visible: true,
16
+ content: { heading: 'A headline' },
17
+ settings: {},
18
+ }
19
+
20
+ const FIFTH: PageSection = {
21
+ ...SECTION,
22
+ id: 's5',
23
+ name: 'Fifth',
24
+ content: { heading: 'Fifth band' },
25
+ }
26
+
27
+ function CompliantHeader(props: AdminThemeChromeProps) {
28
+ return (
29
+ <header>
30
+ <p>Acme</p>
31
+ <nav>
32
+ <a href="/about">About</a>
33
+ </nav>
34
+ <span data-testid="chrome-path">{props.document.path}</span>
35
+ <span data-testid="chrome-locale">{props.locale}</span>
36
+ </header>
37
+ )
38
+ }
39
+
40
+ function CompliantFooter() {
41
+ return <footer>© Acme</footer>
42
+ }
43
+
44
+ function H1Header() {
45
+ return (
46
+ <header>
47
+ <h1>Acme</h1>
48
+ </header>
49
+ )
50
+ }
51
+
52
+ function ThrowingHeader(): never {
53
+ throw new Error('header boom')
54
+ }
55
+
56
+ function defaultThemeChrome(
57
+ extra: Partial<NonNullable<Parameters<typeof EditorCanvas>[0]['themeChrome']>> = {},
58
+ ) {
59
+ return {
60
+ spec: extra.spec ?? {
61
+ header: CompliantHeader,
62
+ footer: CompliantFooter,
63
+ },
64
+ enabled: extra.enabled ?? true,
65
+ mode: extra.mode ?? 'flow',
66
+ locale: extra.locale ?? 'en',
67
+ dir: extra.dir ?? 'ltr',
68
+ document: extra.document ?? {
69
+ id: 'p1',
70
+ title: 'About',
71
+ path: '/about',
72
+ collection: 'pages',
73
+ },
74
+ onNavigate: extra.onNavigate ?? vi.fn(),
75
+ }
76
+ }
77
+
78
+ function canvas(overrides: Partial<Parameters<typeof EditorCanvas>[0]> = {}) {
79
+ const themeChrome = overrides.themeChrome
80
+ ? defaultThemeChrome(overrides.themeChrome)
81
+ : defaultThemeChrome()
82
+ return (
83
+ <ThemeChromeProvider value={themeChrome.spec}>
84
+ <EditorCanvas
85
+ sections={[
86
+ SECTION,
87
+ { ...SECTION, id: 's2' },
88
+ { ...SECTION, id: 's3' },
89
+ { ...SECTION, id: 's4' },
90
+ FIFTH,
91
+ ]}
92
+ selectedId={null}
93
+ breakpoint="wide"
94
+ onSelect={vi.fn()}
95
+ {...overrides}
96
+ themeChrome={overrides.themeChrome === undefined ? themeChrome : themeChrome}
97
+ />
98
+ </ThemeChromeProvider>
99
+ )
100
+ }
101
+
102
+ describe('EditorCanvas theme chrome', () => {
103
+ it('omits chrome when the prop is not enabled', () => {
104
+ render(
105
+ <EditorCanvas sections={[SECTION]} selectedId={null} breakpoint="wide" onSelect={vi.fn()} />,
106
+ )
107
+ expect(document.querySelector('[data-prenta-theme-chrome="header"]')).toBeNull()
108
+ expect(document.getElementById(THEME_CHROME_MAIN_ID)).toBeNull()
109
+ })
110
+
111
+ it('renders header then bands then footer in flow mode', () => {
112
+ const { container } = render(canvas())
113
+ const frame = container.querySelector('[data-prenta-theme-chrome-mode="flow"]')
114
+ expect(frame).toBeTruthy()
115
+ const header = frame!.querySelector('[data-prenta-theme-chrome="header"]')
116
+ const main = frame!.querySelector(`#${THEME_CHROME_MAIN_ID}`)
117
+ const footer = frame!.querySelector('[data-prenta-theme-chrome="footer"]')
118
+ expect(header).toBeTruthy()
119
+ expect(main).toBeTruthy()
120
+ expect(footer).toBeTruthy()
121
+ expect(header!.compareDocumentPosition(main!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
122
+ expect(main!.compareDocumentPosition(footer!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
123
+ expect(container.querySelector('.prenta-canvas')?.getAttribute('lang')).toBe('en')
124
+ expect(container.querySelector('.prenta-canvas')?.getAttribute('dir')).toBe('ltr')
125
+ })
126
+
127
+ it('keeps chrome mounted in frame mode after selecting a middle band', () => {
128
+ const { rerender } = render(
129
+ canvas({
130
+ selectedId: 's5',
131
+ themeChrome: {
132
+ spec: { header: CompliantHeader, footer: CompliantFooter },
133
+ enabled: true,
134
+ mode: 'frame',
135
+ locale: 'en',
136
+ dir: 'ltr',
137
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
138
+ onNavigate: vi.fn(),
139
+ },
140
+ }),
141
+ )
142
+ expect(document.querySelector('[data-prenta-theme-chrome-mode="frame"]')).toBeTruthy()
143
+ expect(document.querySelector('[data-prenta-theme-chrome="header"]')).toBeTruthy()
144
+ expect(document.querySelector('[data-prenta-theme-chrome="footer"]')).toBeTruthy()
145
+ rerender(
146
+ canvas({
147
+ selectedId: 's5',
148
+ themeChrome: {
149
+ spec: { header: CompliantHeader, footer: CompliantFooter },
150
+ enabled: true,
151
+ mode: 'frame',
152
+ locale: 'en',
153
+ dir: 'ltr',
154
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
155
+ onNavigate: vi.fn(),
156
+ },
157
+ }),
158
+ )
159
+ expect(document.querySelector('[data-prenta-theme-chrome="header"]')).toBeTruthy()
160
+ })
161
+
162
+ it('still shows chrome when every band is hidden or the list is empty', () => {
163
+ render(
164
+ canvas({
165
+ sections: [],
166
+ }),
167
+ )
168
+ expect(document.querySelector('[data-prenta-theme-chrome="header"]')).toBeTruthy()
169
+ expect(document.querySelector('[data-prenta-theme-chrome="footer"]')).toBeTruthy()
170
+ expect(screen.getByText('This template renders no sections')).toBeTruthy()
171
+ })
172
+
173
+ it('opens the edit menu and routes Navigations through onNavigate', () => {
174
+ const onNavigate = vi.fn()
175
+ render(
176
+ <div className="prenta-admin" data-prenta-admin-root="">
177
+ {canvas({
178
+ themeChrome: {
179
+ spec: { header: CompliantHeader, footer: CompliantFooter },
180
+ enabled: true,
181
+ mode: 'flow',
182
+ locale: 'en',
183
+ dir: 'ltr',
184
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
185
+ onNavigate,
186
+ },
187
+ })}
188
+ </div>,
189
+ )
190
+ // Clicking the chrome surface (logo/name) is the Fortune-500 path — not
191
+ // only the pencil. Capture-phase opens the controlled menu.
192
+ fireEvent.click(screen.getByText('Acme'))
193
+ fireEvent.click(screen.getByRole('menuitem', { name: 'Edit navigation' }))
194
+ expect(onNavigate).toHaveBeenCalledWith('/navigations')
195
+ })
196
+
197
+ it('does not follow a nav href', () => {
198
+ render(canvas())
199
+ const link = screen.getByRole('link', { name: 'About' })
200
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true })
201
+ link.dispatchEvent(event)
202
+ expect(event.defaultPrevented).toBe(true)
203
+ expect(window.location.pathname).not.toBe('/about')
204
+ })
205
+
206
+ it('falls back when the header throws and still paints bands', () => {
207
+ render(
208
+ canvas({
209
+ themeChrome: {
210
+ spec: { header: ThrowingHeader, footer: CompliantFooter },
211
+ enabled: true,
212
+ mode: 'flow',
213
+ locale: 'en',
214
+ dir: 'ltr',
215
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
216
+ onNavigate: vi.fn(),
217
+ },
218
+ }),
219
+ )
220
+ expect(screen.getByText('Site header')).toBeTruthy()
221
+ expect(document.querySelector('[data-section-id="s1"]')).toBeTruthy()
222
+ expect(screen.getByText('© Acme')).toBeTruthy()
223
+ })
224
+
225
+ it('rejects an off-origin previewSrc and shows the placeholder', () => {
226
+ render(
227
+ canvas({
228
+ themeChrome: {
229
+ spec: {
230
+ previewSrc: { header: 'https://evil.example/header' },
231
+ footer: CompliantFooter,
232
+ },
233
+ enabled: true,
234
+ mode: 'flow',
235
+ locale: 'en',
236
+ dir: 'ltr',
237
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
238
+ onNavigate: vi.fn(),
239
+ },
240
+ }),
241
+ )
242
+ expect(document.querySelector('iframe')).toBeNull()
243
+ expect(document.querySelector('[data-prenta-theme-chrome-placeholder="header"]')).toBeTruthy()
244
+ })
245
+
246
+ it('puts locale, dir, and the public path on the frame — not an admin route', () => {
247
+ render(
248
+ canvas({
249
+ themeChrome: {
250
+ spec: { header: CompliantHeader, footer: CompliantFooter },
251
+ enabled: true,
252
+ mode: 'flow',
253
+ locale: 'ar',
254
+ dir: 'rtl',
255
+ document: { id: 'p1', title: 'About', path: '/ar/about', collection: 'pages' },
256
+ onNavigate: vi.fn(),
257
+ },
258
+ }),
259
+ )
260
+ const frame = document.querySelector('[data-prenta-theme-chrome-mode]')
261
+ expect(frame?.getAttribute('data-prenta-theme-chrome-locale')).toBe('ar')
262
+ expect(frame?.getAttribute('data-prenta-theme-chrome-dir')).toBe('rtl')
263
+ expect(frame?.getAttribute('data-prenta-theme-chrome-path')).toBe('/ar/about')
264
+ expect(screen.getByTestId('chrome-path').textContent).toBe('/ar/about')
265
+ expect(screen.getByTestId('chrome-locale').textContent).toBe('ar')
266
+ })
267
+
268
+ it('warns when the header renders an h1', () => {
269
+ render(
270
+ canvas({
271
+ themeChrome: {
272
+ spec: { header: H1Header, footer: CompliantFooter },
273
+ enabled: true,
274
+ mode: 'flow',
275
+ locale: 'en',
276
+ dir: 'ltr',
277
+ document: { id: 'p1', title: 'About', path: '/about', collection: 'pages' },
278
+ onNavigate: vi.fn(),
279
+ },
280
+ }),
281
+ )
282
+ expect(document.querySelector('[data-prenta-theme-chrome-outline-warning]')).toBeTruthy()
283
+ expect(screen.getByText(/Chrome uses H1/)).toBeTruthy()
284
+ })
285
+
286
+ it('does not warn for a compliant header', () => {
287
+ render(canvas())
288
+ expect(document.querySelector('[data-prenta-theme-chrome-outline-warning]')).toBeNull()
289
+ })
290
+
291
+ it('keeps chrome visible in review mode and does not select chrome as a section', () => {
292
+ const onSelect = vi.fn()
293
+ render(
294
+ canvas({
295
+ reviewing: true,
296
+ onSelect,
297
+ reviewBar: <div>Reviewing</div>,
298
+ }),
299
+ )
300
+ expect(document.querySelector('[data-prenta-theme-chrome="header"]')).toBeTruthy()
301
+ fireEvent.click(screen.getByText('Acme'))
302
+ expect(onSelect).not.toHaveBeenCalled()
303
+ })
304
+ })
package/src/index.ts CHANGED
@@ -14,6 +14,23 @@ export type {
14
14
  AdminRenderImageProps,
15
15
  } from './views/page-editor/sections/index.js'
16
16
 
17
+ export type {
18
+ AdminThemeChrome,
19
+ AdminThemeChromeProps,
20
+ AdminThemeChromeRenderer,
21
+ ThemeChromeDocument,
22
+ ThemeChromeEditTarget,
23
+ ThemeChromeMode,
24
+ ThemeChromeSurface,
25
+ } from './lib/theme-chrome.js'
26
+ export {
27
+ isThemeChromeEnabled,
28
+ isSameOriginPreviewSrc,
29
+ themeChromeEditPath,
30
+ THEME_CHROME_MAIN_ID,
31
+ } from './lib/theme-chrome.js'
32
+ export { useThemeChromeContext } from './views/page-editor/theme-chrome/index.js'
33
+
17
34
  export { Layout } from './layout/Layout.js'
18
35
  export type { LayoutProps } from './layout/Layout.js'
19
36
  export { Sidebar } from './layout/Sidebar.js'
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Theme chrome — site header/footer in the page editor canvas.
3
+ *
4
+ * Pure helpers and the public contract. React lives in
5
+ * `views/page-editor/theme-chrome/`. Do not put renderers on
6
+ * `prenta.config.ts` — this file is imported from the admin bundle only.
7
+ */
8
+
9
+ import type { ReactNode } from 'react'
10
+ import type { BreakpointId } from './breakpoints.js'
11
+
12
+ export type ThemeChromeMode = 'flow' | 'frame'
13
+ export type ThemeChromeSurface = 'pages' | 'posts'
14
+ export type ThemeChromeEditTarget = 'navigations' | 'appearance' | 'settings'
15
+ export type ThemeChromeSlotStatus = 'ok' | 'preview' | 'fallback' | 'placeholder'
16
+
17
+ export interface ThemeChromeDocument {
18
+ id: string | null
19
+ title: string
20
+ path: string
21
+ collection: string
22
+ }
23
+
24
+ export interface AdminThemeChromeProps {
25
+ region: 'header' | 'footer'
26
+ breakpoint: BreakpointId
27
+ mode: ThemeChromeMode
28
+ preview: true
29
+ locale: string
30
+ dir: 'ltr' | 'rtl'
31
+ document: ThemeChromeDocument
32
+ onEditChrome: (target: ThemeChromeEditTarget) => void
33
+ }
34
+
35
+ export type AdminThemeChromeRenderer = (props: AdminThemeChromeProps) => ReactNode
36
+
37
+ export interface AdminThemeChrome {
38
+ header?: AdminThemeChromeRenderer
39
+ footer?: AdminThemeChromeRenderer
40
+ /** Shown when header/footer throw or are omitted but chrome is enabled. */
41
+ fallback?: AdminThemeChromeRenderer
42
+ /**
43
+ * Same-origin URL for hosts whose real chrome is server-only.
44
+ * Rendered in a sandboxed iframe inside the chrome slot; never the whole page.
45
+ */
46
+ previewSrc?: { header?: string; footer?: string }
47
+ /** Surfaces that mount chrome. Default: pages only. */
48
+ surfaces?: ThemeChromeSurface[]
49
+ /** Default canvas mode. Default: flow (real document scroll). */
50
+ defaultMode?: ThemeChromeMode
51
+ /**
52
+ * Optional health-strip extras. Hosts that already know nav/brand state
53
+ * can pass them; the canvas will not fetch on their behalf.
54
+ */
55
+ diagnostics?: { navLinkCount?: number; brandPresent?: boolean }
56
+ }
57
+
58
+ export const THEME_CHROME_MODE_KEY = 'prenta-theme-chrome-mode'
59
+ export const THEME_CHROME_MAIN_ID = 'prenta-canvas-main'
60
+
61
+ export function isThemeChromeEnabled(
62
+ spec: AdminThemeChrome | null | undefined,
63
+ killSwitch: boolean | { pages?: boolean; posts?: boolean } | undefined,
64
+ surface: ThemeChromeSurface,
65
+ ): boolean {
66
+ if (!spec) return false
67
+ const surfaces = spec.surfaces ?? ['pages']
68
+ if (!surfaces.includes(surface)) return false
69
+ if (killSwitch === false) return false
70
+ if (killSwitch && typeof killSwitch === 'object' && killSwitch[surface] === false) {
71
+ return false
72
+ }
73
+ return true
74
+ }
75
+
76
+ export function readStoredChromeMode(): ThemeChromeMode | null {
77
+ if (typeof window === 'undefined') return null
78
+ try {
79
+ const value = window.localStorage.getItem(THEME_CHROME_MODE_KEY)
80
+ return value === 'flow' || value === 'frame' ? value : null
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ export function writeStoredChromeMode(mode: ThemeChromeMode): void {
87
+ if (typeof window === 'undefined') return
88
+ try {
89
+ window.localStorage.setItem(THEME_CHROME_MODE_KEY, mode)
90
+ } catch {
91
+ // Private mode / quota — mode still works for this session.
92
+ }
93
+ }
94
+
95
+ export function resolveThemeChromeLocale(opts: {
96
+ documentLocale?: string | null
97
+ activeLocale?: string | null
98
+ defaultLocale?: string | null
99
+ }): string {
100
+ const picked = opts.documentLocale || opts.activeLocale || opts.defaultLocale || 'en'
101
+ return picked.trim() || 'en'
102
+ }
103
+
104
+ export function resolveThemeChromeDir(
105
+ locale: string,
106
+ locales: Array<{ code: string; dir?: 'ltr' | 'rtl' }>,
107
+ ): 'ltr' | 'rtl' {
108
+ return locales.find((entry) => entry.code === locale)?.dir === 'rtl' ? 'rtl' : 'ltr'
109
+ }
110
+
111
+ /**
112
+ * Public path for chrome context. Serving-path first; never an admin route.
113
+ */
114
+ export function resolveThemeChromePath(path: string | null | undefined, slug?: string): string {
115
+ const trimmed = (path ?? '').trim()
116
+ if (trimmed) return trimmed.startsWith('/') ? trimmed : `/${trimmed}`
117
+ const s = (slug ?? '').trim().replace(/^\/+/, '')
118
+ if (!s || s === 'home' || s === 'index') return '/'
119
+ return `/${s}`
120
+ }
121
+
122
+ export function themeChromeEditPath(target: ThemeChromeEditTarget): string {
123
+ switch (target) {
124
+ case 'navigations':
125
+ return '/navigations'
126
+ case 'appearance':
127
+ return '/settings?tab=appearance'
128
+ case 'settings':
129
+ return '/settings?tab=general'
130
+ default: {
131
+ const _exhaustive: never = target
132
+ return _exhaustive
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Same-origin chrome-only preview URLs. Relative paths starting with `/`
139
+ * (not `//`) are allowed. Absolute URLs must match `origin`. Protocol-relative,
140
+ * data:, javascript:, and off-origin URLs are rejected.
141
+ */
142
+ export function isSameOriginPreviewSrc(src: string, origin?: string): boolean {
143
+ const trimmed = src.trim()
144
+ if (!trimmed) return false
145
+ if (trimmed.startsWith('/') && !trimmed.startsWith('//')) {
146
+ return !trimmed.toLowerCase().startsWith('/\\') && !/^[a-z][a-z0-9+.-]*:/i.test(trimmed)
147
+ }
148
+ let parsed: URL
149
+ try {
150
+ parsed = new URL(trimmed)
151
+ } catch {
152
+ return false
153
+ }
154
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
155
+ const expected = origin ?? (typeof window !== 'undefined' ? window.location.origin : '')
156
+ if (!expected) return false
157
+ try {
158
+ return parsed.origin === new URL(expected).origin
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
164
+ export function chromeContainsH1(root: ParentNode | null): boolean {
165
+ return Boolean(root?.querySelector('h1'))
166
+ }
167
+
168
+ export function closestAnchor(target: EventTarget | null): HTMLAnchorElement | null {
169
+ if (!(target instanceof Element)) return null
170
+ const found = target.closest('a[href]')
171
+ return found instanceof HTMLAnchorElement ? found : null
172
+ }
@@ -1,7 +1,11 @@
1
1
  'use client'
2
2
 
3
3
  import { Modal } from '../../components/ui/Modal.js'
4
- import { getSectionTypesForScope, type SectionScope, type SectionTypeId } from './section-types.js'
4
+ import {
5
+ getAddableSectionTypesForScope,
6
+ type SectionScope,
7
+ type SectionTypeId,
8
+ } from './section-types.js'
5
9
  import { sectionIconFor } from './section-icons.js'
6
10
 
7
11
  interface AddSectionModalProps {
@@ -22,7 +26,7 @@ interface AddSectionModalProps {
22
26
  * model; see the page-editor changeset.)
23
27
  */
24
28
  export function AddSectionModal({ open, onClose, onAdd, scope = 'page' }: AddSectionModalProps) {
25
- const types = getSectionTypesForScope(scope)
29
+ const types = getAddableSectionTypesForScope(scope)
26
30
  return (
27
31
  <Modal open={open} onClose={onClose} title="Add a section">
28
32
  <p className="text-muted-foreground mb-4 text-sm">