@actuate-media/cms-admin 0.42.1 → 0.42.4

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 (37) hide show
  1. package/CHANGELOG.md +1508 -0
  2. package/README.md +35 -0
  3. package/dist/__tests__/lib/post-editor-service.test.js +120 -1
  4. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  5. package/dist/__tests__/views/post-section-editor.test.d.ts +2 -0
  6. package/dist/__tests__/views/post-section-editor.test.d.ts.map +1 -0
  7. package/dist/__tests__/views/post-section-editor.test.js +96 -0
  8. package/dist/__tests__/views/post-section-editor.test.js.map +1 -0
  9. package/dist/lib/collection-schema.d.ts +29 -0
  10. package/dist/lib/collection-schema.d.ts.map +1 -0
  11. package/dist/lib/collection-schema.js +54 -0
  12. package/dist/lib/collection-schema.js.map +1 -0
  13. package/dist/lib/page-editor-service.d.ts.map +1 -1
  14. package/dist/lib/page-editor-service.js +2 -32
  15. package/dist/lib/page-editor-service.js.map +1 -1
  16. package/dist/lib/post-editor-service.d.ts +1 -0
  17. package/dist/lib/post-editor-service.d.ts.map +1 -1
  18. package/dist/lib/post-editor-service.js +50 -9
  19. package/dist/lib/post-editor-service.js.map +1 -1
  20. package/dist/lib/posts-service.d.ts.map +1 -1
  21. package/dist/lib/posts-service.js +3 -1
  22. package/dist/lib/posts-service.js.map +1 -1
  23. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  24. package/dist/views/page-editor/PageSectionEditor.js +5 -2
  25. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  26. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  27. package/dist/views/post-editor/PostSectionEditor.js +27 -4
  28. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  29. package/package.json +5 -4
  30. package/src/__tests__/lib/post-editor-service.test.ts +136 -0
  31. package/src/__tests__/views/post-section-editor.test.tsx +147 -0
  32. package/src/lib/collection-schema.ts +62 -0
  33. package/src/lib/page-editor-service.ts +3 -35
  34. package/src/lib/post-editor-service.ts +54 -8
  35. package/src/lib/posts-service.ts +4 -1
  36. package/src/views/page-editor/PageSectionEditor.tsx +5 -2
  37. package/src/views/post-editor/PostSectionEditor.tsx +49 -2
@@ -11,7 +11,9 @@ const {
11
11
  validatePost,
12
12
  validateTemplate,
13
13
  emptyPostFromTemplate,
14
+ fetchPostForEditor,
14
15
  fetchTemplateForEditor,
16
+ missingPostFields,
15
17
  savePostDraft,
16
18
  } = await import('../../lib/post-editor-service.js')
17
19
 
@@ -89,6 +91,30 @@ describe('validatePost', () => {
89
91
  expect(r.warnings.some((w) => w.includes('SEO title'))).toBe(true)
90
92
  })
91
93
 
94
+ it('does not block publish on unmanaged (externally-managed) sections', () => {
95
+ const r = validatePost(
96
+ basePost({
97
+ seoTitle: 'T',
98
+ seoDescription: 'D',
99
+ sections: [
100
+ // No registered definition for `opal-hero` → validateSectionContent
101
+ // would report "Unknown section type"; the unmanaged flag must skip it.
102
+ {
103
+ id: 's1',
104
+ sectionType: 'opal-hero',
105
+ name: 'Opal Hero',
106
+ visible: true,
107
+ content: { heading: 'Hi' },
108
+ settings: {},
109
+ unmanaged: true,
110
+ },
111
+ ],
112
+ }),
113
+ true,
114
+ )
115
+ expect(r.errors).toEqual([])
116
+ })
117
+
92
118
  it('skips hidden sections during publish validation', () => {
93
119
  const r = validatePost(
94
120
  basePost({
@@ -240,6 +266,116 @@ describe('saveTemplate', () => {
240
266
  })
241
267
  })
242
268
 
269
+ describe('unmanaged section round-trip (data-loss regression)', () => {
270
+ // The old service coerced sections with the default `'drop'` strategy, so a
271
+ // post containing a section type unknown to the admin bundle (e.g. a custom
272
+ // `opal-*` type missing from config) lost those sections on open + save.
273
+ const OPAL_SECTION = {
274
+ id: 'op1',
275
+ sectionType: 'opal-prose-band',
276
+ name: 'Prose Band',
277
+ visible: true,
278
+ content: { html: '<p>kept</p>' },
279
+ settings: { paddingY: 'lg' as const },
280
+ }
281
+
282
+ it('fetchPostForEditor preserves unknown section types as unmanaged', async () => {
283
+ cmsApiMock.mockResolvedValueOnce({
284
+ data: {
285
+ id: 'p1',
286
+ title: 'Logan',
287
+ slug: 'logan',
288
+ status: 'PUBLISHED',
289
+ data: {
290
+ sections: [
291
+ OPAL_SECTION,
292
+ { id: 'q1', sectionType: 'quote', name: 'Q', visible: true, content: {}, settings: {} },
293
+ ],
294
+ },
295
+ },
296
+ })
297
+ const post = await fetchPostForEditor('locations', 'p1')
298
+ expect(post.sections.map((s) => s.sectionType)).toEqual(['opal-prose-band', 'quote'])
299
+ const opal = post.sections[0]!
300
+ expect(opal.unmanaged).toBe(true)
301
+ // content/settings survive verbatim
302
+ expect(opal.content).toEqual({ html: '<p>kept</p>' })
303
+ })
304
+
305
+ it('savePostDraft round-trips unmanaged sections, stripping the derived marker', async () => {
306
+ cmsApiMock.mockResolvedValueOnce({
307
+ data: { id: 'p1', title: 'Logan', slug: 'logan', status: 'DRAFT', data: { sections: [] } },
308
+ })
309
+ await savePostDraft(
310
+ basePost({
311
+ postType: 'locations',
312
+ sections: [{ ...OPAL_SECTION, unmanaged: true }],
313
+ }),
314
+ )
315
+ const [, init] = cmsApiMock.mock.calls[0]!
316
+ const body = JSON.parse(init.body as string)
317
+ expect(body.sections).toHaveLength(1)
318
+ expect(body.sections[0].sectionType).toBe('opal-prose-band')
319
+ expect(body.sections[0].content).toEqual({ html: '<p>kept</p>' })
320
+ // The marker is derived from the registry on read — never persisted.
321
+ expect('unmanaged' in body.sections[0]).toBe(false)
322
+ })
323
+
324
+ it('fetchTemplateForEditor preserves unknown section types in templates', async () => {
325
+ cmsApiMock.mockResolvedValueOnce({
326
+ data: { postType: 'locations', docId: 'tmpl-1', header: {}, sections: [OPAL_SECTION] },
327
+ })
328
+ const t = await fetchTemplateForEditor('locations')
329
+ expect(t.sections).toHaveLength(1)
330
+ expect(t.sections[0]!.unmanaged).toBe(true)
331
+ })
332
+
333
+ it('emptyPostFromTemplate clones unmanaged template sections without throwing', async () => {
334
+ // `createSection` throws for unregistered types — the clone must not use it.
335
+ cmsApiMock.mockResolvedValueOnce({
336
+ data: { postType: 'locations', docId: 'tmpl-1', header: {}, sections: [OPAL_SECTION] },
337
+ })
338
+ const post = await emptyPostFromTemplate('locations')
339
+ expect(post.sections).toHaveLength(1)
340
+ expect(post.sections[0]!.sectionType).toBe('opal-prose-band')
341
+ expect(post.sections[0]!.id).not.toBe('op1')
342
+ })
343
+ })
344
+
345
+ describe('missingPostFields (schema guard)', () => {
346
+ const FULL_FIELDS = {
347
+ title: { type: 'text' },
348
+ slug: { type: 'slug' },
349
+ sections: { type: 'json' },
350
+ excerpt: { type: 'text' },
351
+ featuredImage: { type: 'media' },
352
+ body: { type: 'richText' },
353
+ category: { type: 'text' },
354
+ publishDate: { type: 'date' },
355
+ metaTitle: { type: 'text' },
356
+ metaDescription: { type: 'text' },
357
+ }
358
+
359
+ it('returns [] for a fully-declared post-type collection', () => {
360
+ const config = { collections: { blog: { slug: 'blog', fields: FULL_FIELDS } } }
361
+ expect(missingPostFields(config, 'blog')).toEqual([])
362
+ })
363
+
364
+ it('reports fields the editor writes that the collection does not declare', () => {
365
+ const config = {
366
+ collections: { blog: { slug: 'blog', fields: { title: { type: 'text' } } } },
367
+ }
368
+ const missing = missingPostFields(config, 'blog')
369
+ expect(missing).toContain('sections')
370
+ expect(missing).toContain('metaTitle')
371
+ })
372
+
373
+ it('trusts the consumer when config or the collection is absent', () => {
374
+ expect(missingPostFields(undefined, 'blog')).toEqual([])
375
+ expect(missingPostFields({ collections: {} }, 'blog')).toEqual([])
376
+ })
377
+ })
378
+
243
379
  describe('savePostDraft', () => {
244
380
  it('PUTs the post fields + sections for an existing post', async () => {
245
381
  cmsApiMock.mockResolvedValueOnce({
@@ -0,0 +1,147 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { render, screen } from '@testing-library/react'
4
+
5
+ const fetchPostForEditor = vi.fn()
6
+ const fetchTemplateForEditor = vi.fn()
7
+ vi.mock('../../lib/post-editor-service.js', async (importOriginal) => {
8
+ const actual = await importOriginal<typeof import('../../lib/post-editor-service.js')>()
9
+ return {
10
+ ...actual,
11
+ fetchPostForEditor: (...args: unknown[]) => fetchPostForEditor(...args),
12
+ fetchTemplateForEditor: (...args: unknown[]) => fetchTemplateForEditor(...args),
13
+ }
14
+ })
15
+
16
+ const { PostSectionEditor } = await import('../../views/post-editor/PostSectionEditor.js')
17
+
18
+ const validConfig = {
19
+ collections: {
20
+ blog: {
21
+ slug: 'blog',
22
+ labels: { singular: 'Post', plural: 'Posts' },
23
+ fields: {
24
+ title: { type: 'text' },
25
+ slug: { type: 'slug' },
26
+ sections: { type: 'json' },
27
+ excerpt: { type: 'text' },
28
+ featuredImage: { type: 'media' },
29
+ body: { type: 'richText' },
30
+ category: { type: 'text' },
31
+ publishDate: { type: 'date' },
32
+ metaTitle: { type: 'text' },
33
+ metaDescription: { type: 'text' },
34
+ },
35
+ },
36
+ },
37
+ } as any
38
+
39
+ function loadedPost(
40
+ sections: Array<{ id: string; sectionType: string; name: string; unmanaged?: boolean }>,
41
+ ) {
42
+ return {
43
+ id: 'p1',
44
+ postType: 'blog',
45
+ title: 'Logan',
46
+ slug: 'logan',
47
+ excerpt: '',
48
+ featuredImage: '',
49
+ body: '',
50
+ category: '',
51
+ status: 'DRAFT',
52
+ publishDate: null,
53
+ sections: sections.map((s) => ({ ...s, visible: true, content: {}, settings: {} })),
54
+ seoTitle: '',
55
+ seoDescription: '',
56
+ publishedAt: null,
57
+ updatedAt: null,
58
+ }
59
+ }
60
+
61
+ afterEach(() => {
62
+ fetchPostForEditor.mockReset()
63
+ fetchTemplateForEditor.mockReset()
64
+ })
65
+
66
+ describe('PostSectionEditor schema guard', () => {
67
+ it('refuses to operate when the post-type collection is missing the sections field', async () => {
68
+ const badConfig = {
69
+ collections: { blog: { slug: 'blog', fields: { title: { type: 'text' } } } },
70
+ } as any
71
+
72
+ render(
73
+ <PostSectionEditor
74
+ postType="blog"
75
+ documentId="p1"
76
+ config={badConfig}
77
+ onNavigate={() => {}}
78
+ session={{}}
79
+ />,
80
+ )
81
+
82
+ expect(await screen.findByText(/missing required field/i)).toBeTruthy()
83
+ // The guard must short-circuit before any network read.
84
+ expect(fetchPostForEditor).not.toHaveBeenCalled()
85
+ })
86
+
87
+ it('does not block when no config is provided (embedding/test contexts)', async () => {
88
+ fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
89
+ fetchPostForEditor.mockResolvedValue(
90
+ loadedPost([{ id: 's1', sectionType: 'quote', name: 'Quote' }]),
91
+ )
92
+ render(
93
+ <PostSectionEditor
94
+ postType="blog"
95
+ documentId="p1"
96
+ config={undefined}
97
+ onNavigate={() => {}}
98
+ session={{}}
99
+ />,
100
+ )
101
+ expect(await screen.findByText('Sections')).toBeTruthy()
102
+ })
103
+ })
104
+
105
+ describe('PostSectionEditor unmanaged sections', () => {
106
+ it('shows the externally-managed banner when a post has unmanaged sections', async () => {
107
+ fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
108
+ fetchPostForEditor.mockResolvedValue(
109
+ loadedPost([
110
+ { id: 's1', sectionType: 'quote', name: 'Quote' },
111
+ { id: 's2', sectionType: 'opal-prose-band', name: 'Prose Band', unmanaged: true },
112
+ ]),
113
+ )
114
+ render(
115
+ <PostSectionEditor
116
+ postType="blog"
117
+ documentId="p1"
118
+ config={validConfig}
119
+ onNavigate={() => {}}
120
+ session={{}}
121
+ />,
122
+ )
123
+
124
+ await screen.findByText('Sections')
125
+ const banner = screen.getByText(/managed outside the editor/)
126
+ expect(banner.textContent).toContain('opal-prose-band')
127
+ })
128
+
129
+ it('shows no banner when every section type is registered', async () => {
130
+ fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
131
+ fetchPostForEditor.mockResolvedValue(
132
+ loadedPost([{ id: 's1', sectionType: 'quote', name: 'Quote' }]),
133
+ )
134
+ render(
135
+ <PostSectionEditor
136
+ postType="blog"
137
+ documentId="p1"
138
+ config={validConfig}
139
+ onNavigate={() => {}}
140
+ session={{}}
141
+ />,
142
+ )
143
+
144
+ await screen.findByText('Sections')
145
+ expect(screen.queryByText(/managed outside the editor/)).toBeNull()
146
+ })
147
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Collection schema guard shared by the Page and Post editors.
3
+ *
4
+ * Both editors persist content as top-level keys on a collection document
5
+ * (`sections`, `metaTitle`, …). The server's field-access layer only writes
6
+ * keys that are *declared as fields* on the collection — any undeclared key is
7
+ * silently dropped on save. A collection missing e.g. the `sections` field
8
+ * would therefore discard all section content with no error, so the editors
9
+ * use this check to refuse to operate (and explain why) instead.
10
+ *
11
+ * When `config` (or its collections) is not provided — some embedding/test
12
+ * contexts don't pass it — we can't check, so we report nothing missing and
13
+ * trust the consumer.
14
+ */
15
+
16
+ interface CollectionLike {
17
+ slug?: string
18
+ fields?: unknown
19
+ }
20
+
21
+ /** Look up a collection by slug; `collections` may be a record or an array. */
22
+ export function findCollection(config: unknown, slug: string): CollectionLike | null {
23
+ const collections = (config as { collections?: unknown } | null | undefined)?.collections
24
+ if (!collections) return null
25
+ if (Array.isArray(collections)) {
26
+ return (collections as CollectionLike[]).find((c) => c?.slug === slug) ?? null
27
+ }
28
+ if (typeof collections === 'object') {
29
+ return (collections as Record<string, CollectionLike>)[slug] ?? null
30
+ }
31
+ return null
32
+ }
33
+
34
+ /** Field definitions may be a record (keyed by name) or an array of `{ name }`. */
35
+ export function declaredFieldNames(fields: unknown): Set<string> | null {
36
+ if (!fields) return null
37
+ if (Array.isArray(fields)) {
38
+ return new Set(
39
+ (fields as Array<{ name?: string }>)
40
+ .map((f) => f?.name)
41
+ .filter((n): n is string => typeof n === 'string'),
42
+ )
43
+ }
44
+ if (typeof fields === 'object') return new Set(Object.keys(fields as Record<string, unknown>))
45
+ return null
46
+ }
47
+
48
+ /**
49
+ * Returns the `required` field keys absent from the collection's declared
50
+ * fields, or `[]` when the collection / its fields can't be inspected.
51
+ */
52
+ export function missingCollectionFields(
53
+ config: unknown,
54
+ slug: string,
55
+ required: readonly string[],
56
+ ): string[] {
57
+ const collection = findCollection(config, slug)
58
+ if (!collection) return []
59
+ const declared = declaredFieldNames(collection.fields)
60
+ if (declared === null) return []
61
+ return required.filter((k) => !declared.has(k))
62
+ }
@@ -34,6 +34,8 @@ import {
34
34
  type PageSection,
35
35
  } from '@actuate-media/cms-core/page-sections'
36
36
 
37
+ import { missingCollectionFields } from './collection-schema.js'
38
+
37
39
  export type { PageSection } from '@actuate-media/cms-core/page-sections'
38
40
 
39
41
  // Re-export the pure section helpers so existing editor imports
@@ -151,41 +153,7 @@ export function validatePage(page: EditorPage, forPublish = false): ValidationRe
151
153
  const REQUIRED_PAGE_FIELDS = ['sections', 'path', 'metaTitle', 'metaDescription'] as const
152
154
 
153
155
  export function missingPageFields(config: unknown): string[] {
154
- const pages = findPagesCollection(config)
155
- if (!pages) return []
156
- const declared = declaredFieldNames(pages.fields)
157
- if (declared === null) return []
158
- return REQUIRED_PAGE_FIELDS.filter((k) => !declared.has(k))
159
- }
160
-
161
- function findPagesCollection(config: unknown): { fields?: unknown } | null {
162
- const collections = (config as { collections?: unknown } | null | undefined)?.collections
163
- if (!collections) return null
164
- if (Array.isArray(collections)) {
165
- return (
166
- (collections as Array<{ slug?: string; fields?: unknown }>).find(
167
- (c) => c?.slug === PAGES_COLLECTION,
168
- ) ?? null
169
- )
170
- }
171
- if (typeof collections === 'object') {
172
- return (collections as Record<string, { fields?: unknown }>)[PAGES_COLLECTION] ?? null
173
- }
174
- return null
175
- }
176
-
177
- /** Field definitions may be a record (keyed by name) or an array of `{ name }`. */
178
- function declaredFieldNames(fields: unknown): Set<string> | null {
179
- if (!fields) return null
180
- if (Array.isArray(fields)) {
181
- return new Set(
182
- (fields as Array<{ name?: string }>)
183
- .map((f) => f?.name)
184
- .filter((n): n is string => typeof n === 'string'),
185
- )
186
- }
187
- if (typeof fields === 'object') return new Set(Object.keys(fields as Record<string, unknown>))
188
- return null
156
+ return missingCollectionFields(config, PAGES_COLLECTION, REQUIRED_PAGE_FIELDS)
189
157
  }
190
158
 
191
159
  // ─── API row mapping ─────────────────────────────────────────────────
@@ -20,8 +20,10 @@
20
20
  * so the field-access layer doesn't strip them on write.
21
21
  */
22
22
  import { cmsApi } from './api.js'
23
+ import { missingCollectionFields } from './collection-schema.js'
23
24
  import {
24
25
  getSectionType,
26
+ generateSectionId,
25
27
  createSection,
26
28
  addSection,
27
29
  removeSection,
@@ -93,6 +95,30 @@ export interface ValidationResult {
93
95
  warnings: string[]
94
96
  }
95
97
 
98
+ // ─── Schema guard ────────────────────────────────────────────────────
99
+
100
+ /**
101
+ * The Post Editor persists these as top-level keys on the post-type
102
+ * collection's documents. The server's field-access layer silently drops any
103
+ * key not declared as a field on the collection, so a post-type collection
104
+ * missing e.g. `sections` would discard all section content on save with no
105
+ * error. Mirrors the Page Editor's `missingPageFields` guard.
106
+ */
107
+ const REQUIRED_POST_FIELDS = [
108
+ 'sections',
109
+ 'excerpt',
110
+ 'featuredImage',
111
+ 'body',
112
+ 'category',
113
+ 'publishDate',
114
+ 'metaTitle',
115
+ 'metaDescription',
116
+ ] as const
117
+
118
+ export function missingPostFields(config: unknown, postType: string): string[] {
119
+ return missingCollectionFields(config, postType, REQUIRED_POST_FIELDS)
120
+ }
121
+
96
122
  // ─── Validation ──────────────────────────────────────────────────────
97
123
 
98
124
  function isBlank(v: unknown): boolean {
@@ -113,7 +139,9 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
113
139
 
114
140
  if (forPublish) {
115
141
  const visible = post.sections.filter((s) => s.visible)
116
- for (const section of visible) {
142
+ // Unmanaged (externally-managed) sections have no registered field schema,
143
+ // so they can't be content-validated here and must not block publish.
144
+ for (const section of visible.filter((s) => !s.unmanaged)) {
117
145
  const def = getSectionType(section.sectionType)
118
146
  const label = section.name || def?.name || 'Section'
119
147
  const result = validateSectionContent(section.sectionType, section.content, section.settings)
@@ -133,7 +161,7 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
133
161
  export function validateTemplate(template: EditorPostTemplate): ValidationResult {
134
162
  const errors: string[] = []
135
163
  const warnings: string[] = []
136
- for (const section of template.sections.filter((s) => s.visible)) {
164
+ for (const section of template.sections.filter((s) => s.visible && !s.unmanaged)) {
137
165
  const def = getSectionType(section.sectionType)
138
166
  const label = section.name || def?.name || 'Section'
139
167
  const result = validateSectionContent(section.sectionType, section.content, section.settings)
@@ -175,7 +203,9 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
175
203
  category: dataStr(data, 'category'),
176
204
  status: doc.status ?? 'DRAFT',
177
205
  publishDate: (data.publishDate as string) ?? doc.scheduledAt ?? null,
178
- sections: coerceSections(data.sections),
206
+ // Preserve unknown (externally-managed) sections so the editor can show
207
+ // them read-only and round-trip them on save instead of dropping them.
208
+ sections: coerceSections(data.sections, { onUnknown: 'preserve' }),
179
209
  seoTitle: dataStr(data, 'metaTitle'),
180
210
  seoDescription: dataStr(data, 'metaDescription'),
181
211
  publishedAt: doc.publishedAt ?? null,
@@ -216,7 +246,7 @@ export async function fetchTemplateForEditor(postType: string): Promise<EditorPo
216
246
  const r = res.data
217
247
  const docId = r.docId ?? null
218
248
  const header = coercePostHeader(r.header)
219
- let sections = coerceSections(r.sections)
249
+ let sections = coerceSections(r.sections, { onUnknown: 'preserve' })
220
250
  // Defend against an older server (or an empty stored template): an unsaved
221
251
  // type should still seed the default sections so authors aren't faced with a
222
252
  // blank canvas.
@@ -230,7 +260,10 @@ export async function fetchTemplateForEditor(postType: string): Promise<EditorPo
230
260
  export async function saveTemplate(template: EditorPostTemplate): Promise<EditorPostTemplate> {
231
261
  const res = await cmsApi<TemplateApiResponse>(templateEndpoint(template.postType), {
232
262
  method: 'PUT',
233
- body: JSON.stringify({ header: template.header, sections: template.sections }),
263
+ body: JSON.stringify({
264
+ header: template.header,
265
+ sections: template.sections.map(stripUnmanagedMarker),
266
+ }),
234
267
  })
235
268
  if (res.error || !res.data) throw new Error(res.error ?? 'Failed to save template')
236
269
  const r = res.data
@@ -238,7 +271,7 @@ export async function saveTemplate(template: EditorPostTemplate): Promise<Editor
238
271
  docId: r.docId ?? null,
239
272
  postType: template.postType,
240
273
  header: coercePostHeader(r.header),
241
- sections: coerceSections(r.sections),
274
+ sections: coerceSections(r.sections, { onUnknown: 'preserve' }),
242
275
  }
243
276
  }
244
277
 
@@ -263,6 +296,15 @@ interface PostWritePayload {
263
296
  metaDescription: string
264
297
  }
265
298
 
299
+ /**
300
+ * Strip the derived `unmanaged` marker before persisting — it is recomputed
301
+ * from the registry on read, so persisting it would only stale.
302
+ */
303
+ function stripUnmanagedMarker(section: PageSection): Omit<PageSection, 'unmanaged'> {
304
+ const { unmanaged: _unmanaged, ...rest } = section
305
+ return rest
306
+ }
307
+
266
308
  function toWriteBody(post: EditorPost, extra?: Record<string, unknown>): string {
267
309
  const payload: PostWritePayload & Record<string, unknown> = {
268
310
  title: post.title,
@@ -272,7 +314,9 @@ function toWriteBody(post: EditorPost, extra?: Record<string, unknown>): string
272
314
  body: post.body,
273
315
  category: post.category,
274
316
  publishDate: post.publishDate,
275
- sections: post.sections,
317
+ // Unmanaged sections are part of `post.sections`, so they round-trip on
318
+ // every save (no data loss).
319
+ sections: post.sections.map(stripUnmanagedMarker),
276
320
  metaTitle: post.seoTitle,
277
321
  metaDescription: post.seoDescription,
278
322
  ...extra,
@@ -318,7 +362,9 @@ export async function emptyPostFromTemplate(postType: string): Promise<EditorPos
318
362
  const template = await fetchTemplateForEditor(postType)
319
363
  // Clone with fresh ids so the post owns its own sections, decoupled from
320
364
  // the template (editing the post never mutates the type template).
321
- sections = template.sections.map((s) => ({ ...s, id: createSection(s.sectionType).id }))
365
+ // `generateSectionId` (not `createSection`) because preserved unmanaged
366
+ // template sections have no registered type and `createSection` throws.
367
+ sections = template.sections.map((s) => ({ ...s, id: generateSectionId() }))
322
368
  } catch {
323
369
  // No template / fetch failure → start from an empty section list.
324
370
  sections = []
@@ -87,6 +87,7 @@ interface CollectionApiRow {
87
87
  group: string | null
88
88
  hidden: boolean
89
89
  fieldCount: number
90
+ excludeFromPostsAggregate?: boolean
90
91
  }
91
92
 
92
93
  function isPostsCollection(c: CollectionApiRow): boolean {
@@ -96,7 +97,9 @@ function isPostsCollection(c: CollectionApiRow): boolean {
96
97
  async function fetchPostCollections(): Promise<CollectionApiRow[]> {
97
98
  const res = await cmsApi<CollectionApiRow[]>('/collections')
98
99
  if (res.error || !res.data) return []
99
- return res.data.filter((c) => !c.hidden && isPostsCollection(c))
100
+ // `excludeFromPostsAggregate` mirrors the server-side /posts filter: a
101
+ // post-shaped collection with its own workflow stays out of the Posts UI.
102
+ return res.data.filter((c) => !c.hidden && !c.excludeFromPostsAggregate && isPostsCollection(c))
100
103
  }
101
104
 
102
105
  export async function fetchPostTypes(): Promise<PostType[]> {
@@ -58,8 +58,11 @@ interface PageSectionEditorProps {
58
58
 
59
59
  function deriveCan(session: any): { canEdit: boolean; canPublish: boolean } {
60
60
  const role: string | undefined = session?.user?.role
61
- // No session info (e.g. local dev) allow; otherwise gate by role.
62
- if (!role) return { canEdit: true, canPublish: true }
61
+ // Default closed: when we can't identify a role, hide the edit/publish
62
+ // affordances rather than exposing them. The server independently enforces
63
+ // access on every write, so this is a UX guard — but it should fail safe.
64
+ // (Matches the Post editor's behavior.)
65
+ if (!role) return { canEdit: false, canPublish: false }
63
66
  const editors = ['ADMIN', 'EDITOR', 'AUTHOR']
64
67
  const publishers = ['ADMIN', 'EDITOR']
65
68
  return { canEdit: editors.includes(role), canPublish: publishers.includes(role) }