@prenta/admin 1.17.2 → 1.17.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 (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/__tests__/lib/posts-service.test.js +35 -0
  3. package/dist/__tests__/lib/posts-service.test.js.map +1 -1
  4. package/dist/__tests__/lib/public-path.test.d.ts +2 -0
  5. package/dist/__tests__/lib/public-path.test.d.ts.map +1 -0
  6. package/dist/__tests__/lib/public-path.test.js +25 -0
  7. package/dist/__tests__/lib/public-path.test.js.map +1 -0
  8. package/dist/__tests__/views/admin-section-renderers.test.js +1 -0
  9. package/dist/__tests__/views/admin-section-renderers.test.js.map +1 -1
  10. package/dist/__tests__/views/article-body-section.render.test.js +7 -0
  11. package/dist/__tests__/views/article-body-section.render.test.js.map +1 -1
  12. package/dist/__tests__/views/posts-list-view.render.test.js +11 -0
  13. package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -1
  14. package/dist/__tests__/views/seo-autopilot-card.render.test.js +32 -47
  15. package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -1
  16. package/dist/__tests__/views/updates-tab.render.test.js +6 -2
  17. package/dist/__tests__/views/updates-tab.render.test.js.map +1 -1
  18. package/dist/lib/posts-service.d.ts +6 -0
  19. package/dist/lib/posts-service.d.ts.map +1 -1
  20. package/dist/lib/posts-service.js +6 -1
  21. package/dist/lib/posts-service.js.map +1 -1
  22. package/dist/lib/public-path.d.ts +21 -0
  23. package/dist/lib/public-path.d.ts.map +1 -0
  24. package/dist/lib/public-path.js +28 -0
  25. package/dist/lib/public-path.js.map +1 -0
  26. package/dist/views/Posts/PostsListView.d.ts.map +1 -1
  27. package/dist/views/Posts/PostsListView.js +1 -1
  28. package/dist/views/Posts/PostsListView.js.map +1 -1
  29. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +3 -4
  30. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
  31. package/dist/views/page-editor/sections/ArticleBodySection.js +6 -5
  32. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  33. package/dist/views/page-editor/sections/admin-renderers.d.ts +3 -36
  34. package/dist/views/page-editor/sections/admin-renderers.d.ts.map +1 -1
  35. package/dist/views/page-editor/sections/admin-renderers.js +5 -5
  36. package/dist/views/page-editor/sections/admin-renderers.js.map +1 -1
  37. package/package.json +3 -3
  38. package/src/__tests__/lib/posts-service.test.ts +35 -0
  39. package/src/__tests__/lib/public-path.test.ts +37 -0
  40. package/src/__tests__/views/admin-section-renderers.test.tsx +1 -0
  41. package/src/__tests__/views/article-body-section.render.test.tsx +14 -0
  42. package/src/__tests__/views/posts-list-view.render.test.tsx +12 -0
  43. package/src/__tests__/views/seo-autopilot-card.render.test.tsx +32 -53
  44. package/src/__tests__/views/updates-tab.render.test.tsx +6 -2
  45. package/src/lib/posts-service.ts +12 -1
  46. package/src/lib/public-path.ts +33 -0
  47. package/src/views/Posts/PostsListView.tsx +2 -1
  48. package/src/views/page-editor/sections/ArticleBodySection.tsx +7 -5
  49. package/src/views/page-editor/sections/admin-renderers.tsx +5 -5
@@ -32,15 +32,15 @@ import { jsx as _jsx } from "react/jsx-runtime";
32
32
  * renderer falls back to the built-in for that type, or to the placeholder if
33
33
  * the type has no built-in, instead of taking down the editor.
34
34
  */
35
+ import { sanitizeHtml } from '@prenta/core/sanitize';
35
36
  import { createContext, useContext } from 'react';
36
37
  /**
37
- * Render options passed to every admin renderer. Rich text is sanitized on
38
- * write by cms-core, so the canvas (same trust domain as the public default)
39
- * uses the identity sanitizer; images render as plain lazy `<img>` since the
40
- * editor canvas has no next/image pipeline.
38
+ * Render options passed to every admin renderer. Draft HTML is sanitized
39
+ * with the cms-core allow-list before any renderer injects it. Images render
40
+ * as plain lazy `<img>` since the editor canvas has no next/image pipeline.
41
41
  */
42
42
  export const ADMIN_RENDER_OPTIONS = {
43
- sanitizeHtml: (html) => html,
43
+ sanitizeHtml,
44
44
  baseUrl: '',
45
45
  // Never grants the priority slot. On a public page exactly one image may
46
46
  // load eagerly; in the canvas nothing competes for bandwidth and every
@@ -1 +1 @@
1
- {"version":3,"file":"admin-renderers.js","sourceRoot":"","sources":["../../../../src/views/page-editor/sections/admin-renderers.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,EAAE,aAAa,EAAE,UAAU,EAAkB,MAAM,OAAO,CAAA;AA4EjE;;;;;GAKG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAyC;IACxE,YAAY,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI;IAC5B,OAAO,EAAE,EAAE;IACX,yEAAyE;IACzE,uEAAuE;IACvE,wDAAwD;IACxD,GAAG,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE;IAC1C,eAAe,EAAE,EAAE;IACnB,WAAW,EAAE,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,SAAS,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CACjE,cACE,GAAG,EAAE,GAAG,EACR,GAAG,EAAE,GAAG,EACR,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE,KAAK,IAAI,SAAS,EACzB,MAAM,EAAE,MAAM,IAAI,SAAS,EAC3B,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,GACtC,CACH;CACF,CAAA;AAED,MAAM,4BAA4B,GAAG,aAAa,CAA0B,EAAE,CAAC,CAAA;AAE/E,MAAM,CAAC,MAAM,6BAA6B,GAAG,4BAA4B,CAAC,QAAQ,CAAA;AAElF,oFAAoF;AACpF,MAAM,UAAU,wBAAwB;IACtC,OAAO,UAAU,CAAC,4BAA4B,CAAC,CAAA;AACjD,CAAC"}
1
+ {"version":3,"file":"admin-renderers.js","sourceRoot":"","sources":["../../../../src/views/page-editor/sections/admin-renderers.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAA;AACpD,OAAO,EAAE,aAAa,EAAE,UAAU,EAAkB,MAAM,OAAO,CAAA;AA4EjE;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAyC;IACxE,YAAY;IACZ,OAAO,EAAE,EAAE;IACX,yEAAyE;IACzE,uEAAuE;IACvE,wDAAwD;IACxD,GAAG,EAAE,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE;IAC1C,eAAe,EAAE,EAAE;IACnB,WAAW,EAAE,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,SAAS,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,CACjE,cACE,GAAG,EAAE,GAAG,EACR,GAAG,EAAE,GAAG,EACR,SAAS,EAAE,SAAS,EACpB,KAAK,EAAE,KAAK,IAAI,SAAS,EACzB,MAAM,EAAE,MAAM,IAAI,SAAS,EAC3B,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,GACtC,CACH;CACF,CAAA;AAED,MAAM,4BAA4B,GAAG,aAAa,CAA0B,EAAE,CAAC,CAAA;AAE/E,MAAM,CAAC,MAAM,6BAA6B,GAAG,4BAA4B,CAAC,QAAQ,CAAA;AAElF,oFAAoF;AACpF,MAAM,UAAU,wBAAwB;IACtC,OAAO,UAAU,CAAC,4BAA4B,CAAC,CAAA;AACjD,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prenta/admin",
3
- "version": "1.17.2",
3
+ "version": "1.17.4",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -99,7 +99,7 @@
99
99
  "typescript": "^5.7.0",
100
100
  "vitest": "^4.1.0",
101
101
  "@prenta/component-blocks": "0.3.1",
102
- "@prenta/core": "7.0.0",
102
+ "@prenta/core": "7.2.0",
103
103
  "@prenta/plugin-ai": "3.0.0",
104
104
  "@prenta/plugin-blocks": "0.2.0",
105
105
  "@prenta/plugin-commerce": "0.2.1",
@@ -107,7 +107,7 @@
107
107
  "@prenta/plugin-media": "0.2.0",
108
108
  "@prenta/plugin-navigation": "0.3.0",
109
109
  "@prenta/plugin-seo": "1.0.0",
110
- "@prenta/sections-react": "0.17.42"
110
+ "@prenta/sections-react": "0.17.45"
111
111
  },
112
112
  "scripts": {
113
113
  "build": "tsc --project tsconfig.json && npx @tailwindcss/cli -i src/styles/build-input.css -o dist/prenta-admin.css --minify",
@@ -186,6 +186,41 @@ describe('posts-service.fetchPosts', () => {
186
186
  })
187
187
  })
188
188
 
189
+ it('lists the public path from the collection urlPrefix, not the collection slug (#921)', async () => {
190
+ cmsApiMock.mockReset()
191
+ cmsApiMock.mockImplementation((url: string) => {
192
+ if (!url.startsWith('/posts')) return { error: `unexpected url: ${url}` }
193
+ return {
194
+ data: {
195
+ ...POSTS_RESPONSE,
196
+ posts: [
197
+ { ...POSTS_RESPONSE.posts[1], id: 'p1', collection: 'posts', slug: 'seo-cost' },
198
+ { ...POSTS_RESPONSE.posts[1], id: 'r1', collection: 'resources', slug: 'guide' },
199
+ { ...POSTS_RESPONSE.posts[1], id: 'n1', collection: 'news', slug: 'q2' },
200
+ { ...POSTS_RESPONSE.posts[1], id: 'x1', collection: 'unknown-type', slug: 'orphan' },
201
+ ],
202
+ types: [
203
+ { ...POSTS_RESPONSE.types[0], slug: 'posts', urlPrefix: 'blog' },
204
+ { ...POSTS_RESPONSE.types[0], slug: 'resources', urlPrefix: '' },
205
+ { ...POSTS_RESPONSE.types[1], slug: 'news', urlPrefix: 'news' },
206
+ ],
207
+ },
208
+ }
209
+ })
210
+ const res = await fetchPosts({})
211
+ const byId = new Map(res.posts.map((p) => [p.id, p]))
212
+ // `posts` collection relabelled Blog with `urlPrefix: 'blog'` → /blog/{slug}.
213
+ expect(byId.get('p1')?.publicPath).toBe('/blog/seo-cost')
214
+ // Collection slug stays the API identity.
215
+ expect(byId.get('p1')?.postTypeId).toBe('posts')
216
+ // Empty urlPrefix (root-mounted) → /{slug}.
217
+ expect(byId.get('r1')?.publicPath).toBe('/guide')
218
+ // Prefix equal to slug is unchanged.
219
+ expect(byId.get('n1')?.publicPath).toBe('/news/q2')
220
+ // No type row for the collection → fall back to the collection slug.
221
+ expect(byId.get('x1')?.publicPath).toBe('/unknown-type/orphan')
222
+ })
223
+
189
224
  it('surfaces the scheduled date for SCHEDULED posts as the display date', async () => {
190
225
  const res = await fetchPosts({})
191
226
  const news = res.posts.find((p) => p.id === 'n1')
@@ -0,0 +1,37 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { publicPathFor } from '../../lib/public-path.js'
4
+
5
+ describe('publicPathFor', () => {
6
+ it('lists /{urlPrefix}/{slug} when the collection declares a public prefix', () => {
7
+ // The collection slug (`posts`) is API identity; the public path is `/blog`.
8
+ expect(publicPathFor('blog', 'posts', 'how-much-do-seo-services-cost')).toBe(
9
+ '/blog/how-much-do-seo-services-cost',
10
+ )
11
+ })
12
+
13
+ it('falls back to /{collectionSlug}/{slug} when no urlPrefix is declared', () => {
14
+ expect(publicPathFor(undefined, 'posts', 'hello')).toBe('/posts/hello')
15
+ })
16
+
17
+ it('lists /{slug} when urlPrefix is the empty string (root-mounted collection)', () => {
18
+ expect(publicPathFor('', 'pages', 'about')).toBe('/about')
19
+ })
20
+
21
+ it('supports nested prefixes and trims stray slashes', () => {
22
+ expect(publicPathFor('help/articles', 'help-articles', 'reset-password')).toBe(
23
+ '/help/articles/reset-password',
24
+ )
25
+ expect(publicPathFor('/careers/', 'careers', '/senior-engineer/')).toBe(
26
+ '/careers/senior-engineer',
27
+ )
28
+ })
29
+
30
+ it('is unchanged for a type whose prefix equals its slug', () => {
31
+ expect(publicPathFor('news', 'news', 'q2-roadmap')).toBe('/news/q2-roadmap')
32
+ })
33
+
34
+ it('keeps a trailing slash placeholder when the slug is empty', () => {
35
+ expect(publicPathFor('blog', 'posts', '')).toBe('/blog/')
36
+ })
37
+ })
@@ -56,6 +56,7 @@ describe('pluggable admin section renderers', () => {
56
56
  )
57
57
  const options = seen[0] as { sanitizeHtml: (h: string) => string; baseUrl: string }
58
58
  expect(options.sanitizeHtml('<b>x</b>')).toBe('<b>x</b>')
59
+ expect(options.sanitizeHtml('<p>ok</p><script>alert(1)</script>')).not.toContain('script')
59
60
  expect(options.baseUrl).toBe('')
60
61
  expect(screen.getByAltText('x')).toBeTruthy()
61
62
  })
@@ -29,4 +29,18 @@ describe('ArticleBodySection', () => {
29
29
  expect(container.querySelector('.prenta-rich-text')).toBeTruthy()
30
30
  expect(container.querySelector('.prose')).toBeNull()
31
31
  })
32
+
33
+ it('strips script tags from draft HTML before rendering (#396)', () => {
34
+ const { container } = render(
35
+ <ArticleBodySection
36
+ section={section({ source: 'field' })}
37
+ context={{ body: '<p>Safe copy</p><script>alert(1)</script>' }}
38
+ />,
39
+ )
40
+
41
+ const preview = container.querySelector('.prenta-rich-text')
42
+ expect(preview?.querySelector('script')).toBeNull()
43
+ expect(preview?.innerHTML).toContain('Safe copy')
44
+ expect(preview?.innerHTML).not.toContain('alert')
45
+ })
32
46
  })
@@ -13,6 +13,9 @@ function makePost(id: string, title: string) {
13
13
  postTypeAccentColor: 'blue',
14
14
  title,
15
15
  slug: id,
16
+ // Public path comes from the collection's `urlPrefix` (`blog`), not the
17
+ // collection slug (`articles`) — see #921.
18
+ publicPath: `/blog/${id}`,
16
19
  authorName: 'Author',
17
20
  authorInitials: 'AU',
18
21
  status: 'PUBLISHED',
@@ -47,6 +50,15 @@ beforeEach(() => {
47
50
  bulkDeletePosts.mockClear()
48
51
  })
49
52
 
53
+ describe('PostsListView — row subtitle', () => {
54
+ it('shows the public path (/{urlPrefix}/{slug}) rather than /{collectionSlug}/{slug} (#921)', async () => {
55
+ render(<PostsListView onNavigate={() => {}} />)
56
+ await screen.findByLabelText('Select "Alpha"')
57
+ expect(screen.getByText('/blog/a1')).toBeTruthy()
58
+ expect(screen.queryByText('/articles/a1')).toBeNull()
59
+ })
60
+ })
61
+
50
62
  describe('PostsListView — page-scoped selection', () => {
51
63
  it('clears the selection when navigating to another page so the bulk count stays honest', async () => {
52
64
  render(<PostsListView onNavigate={() => {}} />)
@@ -123,10 +123,10 @@ beforeEach(() => {
123
123
  describe('SeoAutopilotCard', () => {
124
124
  it('renders the master toggle, type groups, thresholds, and a Never run status', async () => {
125
125
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
126
- await screen.findByText('SEO Autopilot')
127
- expect(
128
- screen.getByRole('switch', { name: /enable autopilot/i }).getAttribute('aria-checked'),
129
- ).toBe('false')
126
+ // The heading is present during the loading skeleton; wait on the first
127
+ // loaded control instead so the assertions below never race the fetch.
128
+ const toggle = await screen.findByRole('switch', { name: /enable autopilot/i })
129
+ expect(toggle.getAttribute('aria-checked')).toBe('false')
130
130
  expect(screen.getByText('Titles')).toBeTruthy()
131
131
  expect(screen.getByText('Redirects')).toBeTruthy()
132
132
  expect((screen.getByLabelText(/minimum brand alignment/i) as HTMLInputElement).value).toBe('85')
@@ -137,8 +137,7 @@ describe('SeoAutopilotCard', () => {
137
137
 
138
138
  it('a blur-commit followed immediately by a checkbox click lands two PUTs, in order', async () => {
139
139
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
140
- await screen.findByText('SEO Autopilot')
141
- const brand = screen.getByLabelText(/minimum brand alignment/i)
140
+ const brand = await screen.findByLabelText(/minimum brand alignment/i)
142
141
  const checkbox = screen.getByLabelText('Missing meta title') as HTMLInputElement
143
142
  fireEvent.change(brand, { target: { value: '90' } })
144
143
  // mousedown on the checkbox blurs the input (commit → PUT #1) and the click
@@ -161,9 +160,9 @@ describe('SeoAutopilotCard', () => {
161
160
  const gate = deferred()
162
161
  putGate = gate.promise
163
162
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
164
- await screen.findByText('SEO Autopilot')
163
+ // The card region also wraps the loading skeleton; wait on a loaded control.
164
+ const checkbox = (await screen.findByLabelText('Missing meta title')) as HTMLInputElement
165
165
  const card = screen.getByRole('region', { name: 'SEO Autopilot' })
166
- const checkbox = screen.getByLabelText('Missing meta title') as HTMLInputElement
167
166
  checkbox.focus()
168
167
  fireEvent.click(checkbox)
169
168
  await waitFor(() => expect(card.getAttribute('aria-busy')).toBe('true'))
@@ -184,8 +183,7 @@ describe('SeoAutopilotCard', () => {
184
183
  runGate = gate.promise
185
184
  current = { ...SETTINGS, enabled: true, enabledById: 'a' }
186
185
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
187
- await screen.findByText('SEO Autopilot')
188
- fireEvent.click(screen.getByRole('button', { name: /run now/i }))
186
+ fireEvent.click(await screen.findByRole('button', { name: /run now/i }))
189
187
  await waitFor(() =>
190
188
  expect(cmsApi).toHaveBeenCalledWith('/seo/autopilot/run', expect.anything()),
191
189
  )
@@ -199,8 +197,7 @@ describe('SeoAutopilotCard', () => {
199
197
 
200
198
  it('toggling a type PUTs a types patch', async () => {
201
199
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
202
- await screen.findByText('SEO Autopilot')
203
- fireEvent.click(screen.getByLabelText('Missing meta title'))
200
+ fireEvent.click(await screen.findByLabelText('Missing meta title'))
204
201
  await waitFor(() =>
205
202
  expect(cmsApi).toHaveBeenCalledWith(
206
203
  '/seo/autopilot/settings',
@@ -218,8 +215,7 @@ describe('SeoAutopilotCard', () => {
218
215
 
219
216
  it('toggling the master switch PUTs { enabled }', async () => {
220
217
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
221
- await screen.findByText('SEO Autopilot')
222
- fireEvent.click(screen.getByRole('switch', { name: /enable autopilot/i }))
218
+ fireEvent.click(await screen.findByRole('switch', { name: /enable autopilot/i }))
223
219
  await waitFor(() => expect(putBodies()).toEqual([{ enabled: true }]))
224
220
  await waitFor(() =>
225
221
  expect(
@@ -230,8 +226,7 @@ describe('SeoAutopilotCard', () => {
230
226
 
231
227
  it('commits a clamped threshold on blur and sends the outcome-watch ratio as a fraction', async () => {
232
228
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
233
- await screen.findByText('SEO Autopilot')
234
- const brand = screen.getByLabelText(/minimum brand alignment/i)
229
+ const brand = await screen.findByLabelText(/minimum brand alignment/i)
235
230
  fireEvent.change(brand, { target: { value: '140' } })
236
231
  fireEvent.blur(brand)
237
232
  await waitFor(() => expect(putBodies()).toEqual([{ minBrandAlignment: 100 }]))
@@ -244,8 +239,7 @@ describe('SeoAutopilotCard', () => {
244
239
 
245
240
  it('does not PUT when a number field is blurred unchanged or with garbage', async () => {
246
241
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
247
- await screen.findByText('SEO Autopilot')
248
- const perRun = screen.getByLabelText(/max changes per run/i)
242
+ const perRun = await screen.findByLabelText(/max changes per run/i)
249
243
  fireEvent.blur(perRun)
250
244
  fireEvent.change(perRun, { target: { value: '' } })
251
245
  fireEvent.blur(perRun)
@@ -256,8 +250,7 @@ describe('SeoAutopilotCard', () => {
256
250
  it('surfaces a PUT validation error verbatim and keeps the previous state', async () => {
257
251
  putResponse = { error: 'maxPerRun must be an integer between 1 and 25', status: 400 }
258
252
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
259
- await screen.findByText('SEO Autopilot')
260
- fireEvent.click(screen.getByLabelText('Missing meta title'))
253
+ fireEvent.click(await screen.findByLabelText('Missing meta title'))
261
254
  await waitFor(() =>
262
255
  expect(toastError).toHaveBeenCalledWith('maxPerRun must be an integer between 1 and 25'),
263
256
  )
@@ -268,8 +261,7 @@ describe('SeoAutopilotCard', () => {
268
261
  it('resets a number field to the last saved value when its PUT fails', async () => {
269
262
  putResponse = { error: 'maxPerRun must be an integer between 1 and 25', status: 400 }
270
263
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
271
- await screen.findByText('SEO Autopilot')
272
- const perRun = screen.getByLabelText(/max changes per run/i) as HTMLInputElement
264
+ const perRun = (await screen.findByLabelText(/max changes per run/i)) as HTMLInputElement
273
265
  fireEvent.change(perRun, { target: { value: '9' } })
274
266
  fireEvent.blur(perRun)
275
267
  await waitFor(() =>
@@ -368,8 +360,7 @@ describe('SeoAutopilotCard', () => {
368
360
  pausedTypes: { 'missing-meta-title': reason },
369
361
  }
370
362
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
371
- await screen.findByText('SEO Autopilot')
372
- const list = screen.getByRole('list', { name: /paused fix types/i })
363
+ const list = await screen.findByRole('list', { name: /paused fix types/i })
373
364
  expect(list.textContent ?? '').toContain(`Missing meta title — ${reason}`)
374
365
  fireEvent.click(within(list).getByRole('button', { name: /resume/i }))
375
366
  await waitFor(() => expect(putBodies()).toEqual([{ resumeTypes: ['missing-meta-title'] }]))
@@ -377,8 +368,7 @@ describe('SeoAutopilotCard', () => {
377
368
 
378
369
  it('Run now is disabled while the policy is disabled and explains why', async () => {
379
370
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
380
- await screen.findByText('SEO Autopilot')
381
- const run = screen.getByRole('button', { name: /run now/i }) as HTMLButtonElement
371
+ const run = (await screen.findByRole('button', { name: /run now/i })) as HTMLButtonElement
382
372
  expect(run.disabled).toBe(true)
383
373
  expect(run.getAttribute('title')).toMatch(/enable autopilot to run it now/i)
384
374
  const hintId = run.getAttribute('aria-describedby')
@@ -397,8 +387,7 @@ describe('SeoAutopilotCard', () => {
397
387
  pausedReason: 'manual',
398
388
  }
399
389
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
400
- await screen.findByText('SEO Autopilot')
401
- const run = screen.getByRole('button', { name: /run now/i }) as HTMLButtonElement
390
+ const run = (await screen.findByRole('button', { name: /run now/i })) as HTMLButtonElement
402
391
  expect(run.disabled).toBe(true)
403
392
  expect(run.getAttribute('title')).toMatch(/resume autopilot to run it now/i)
404
393
  const hintId = run.getAttribute('aria-describedby')
@@ -410,8 +399,7 @@ describe('SeoAutopilotCard', () => {
410
399
  it('Run now carries no hint once it is runnable', async () => {
411
400
  current = { ...SETTINGS, enabled: true, enabledById: 'a' }
412
401
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
413
- await screen.findByText('SEO Autopilot')
414
- const run = screen.getByRole('button', { name: /run now/i }) as HTMLButtonElement
402
+ const run = (await screen.findByRole('button', { name: /run now/i })) as HTMLButtonElement
415
403
  expect(run.disabled).toBe(false)
416
404
  expect(run.getAttribute('title')).toBeNull()
417
405
  expect(run.getAttribute('aria-describedby')).toBeNull()
@@ -420,8 +408,7 @@ describe('SeoAutopilotCard', () => {
420
408
  it('Run now POSTs /seo/autopilot/run and reloads', async () => {
421
409
  current = { ...SETTINGS, enabled: true, enabledById: 'a' }
422
410
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
423
- await screen.findByText('SEO Autopilot')
424
- const run = screen.getByRole('button', { name: /run now/i })
411
+ const run = await screen.findByRole('button', { name: /run now/i })
425
412
  expect((run as HTMLButtonElement).disabled).toBe(false)
426
413
  fireEvent.click(run)
427
414
  // Second click while in flight must not queue a second run.
@@ -457,8 +444,7 @@ describe('SeoAutopilotCard', () => {
457
444
  },
458
445
  }
459
446
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
460
- await screen.findByText('SEO Autopilot')
461
- const summary = screen.getByRole('status', { name: /last run/i })
447
+ const summary = await screen.findByRole('status', { name: /last run/i })
462
448
  expect(summary.textContent ?? '').toMatch(/4 applied/)
463
449
  expect(summary.textContent ?? '').toMatch(/1 verify-failed/)
464
450
  expect(summary.textContent ?? '').toMatch(/2 reverted/)
@@ -475,8 +461,7 @@ describe('SeoAutopilotCard', () => {
475
461
  lastRun: { ...RUN_SUMMARY, halted: true, haltedReason: 'verify_failures' },
476
462
  }
477
463
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
478
- await screen.findByText('SEO Autopilot')
479
- const halted = screen.getByRole('status', { name: /run stopped/i })
464
+ const halted = await screen.findByRole('status', { name: /run stopped/i })
480
465
  expect(halted.textContent ?? '').toMatch(/verification failures/i)
481
466
  expect(halted.className).toContain('text-warning')
482
467
  })
@@ -491,8 +476,7 @@ describe('SeoAutopilotCard', () => {
491
476
  lastRun: { ...RUN_SUMMARY, halted: true, haltedReason: 'budget_exhausted' },
492
477
  }
493
478
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
494
- await screen.findByText('SEO Autopilot')
495
- const halted = screen.getByRole('status', { name: /run stopped/i })
479
+ const halted = await screen.findByRole('status', { name: /run stopped/i })
496
480
  expect(halted.textContent ?? '').toMatch(/ran out of time/i)
497
481
  expect(halted.textContent ?? '').not.toMatch(/daily/i)
498
482
  })
@@ -510,8 +494,7 @@ describe('SeoAutopilotCard', () => {
510
494
  lastRun: { ...RUN_SUMMARY, halted: true, haltedReason: reason },
511
495
  }
512
496
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
513
- await screen.findByText('SEO Autopilot')
514
- const halted = screen.getByRole('status', { name: /run stopped/i })
497
+ const halted = await screen.findByRole('status', { name: /run stopped/i })
515
498
  expect(halted.textContent ?? '').toMatch(copy)
516
499
  expect(halted.textContent ?? '').not.toMatch(/stopped with an error/i)
517
500
  expect(halted.className).toContain('text-warning')
@@ -524,10 +507,8 @@ describe('SeoAutopilotCard', () => {
524
507
  lastRunAt: new Date(Date.now() - 5 * 60_000).toISOString(),
525
508
  }
526
509
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
527
- await screen.findByText('SEO Autopilot')
528
- expect(screen.getByRole('status', { name: /last run/i }).textContent ?? '').toMatch(
529
- /last run 5m ago/i,
530
- )
510
+ const summary = await screen.findByRole('status', { name: /last run/i })
511
+ expect(summary.textContent ?? '').toMatch(/last run 5m ago/i)
531
512
  })
532
513
 
533
514
  it('offers every autopilot issue type exactly once (mirror of SEO_AUTOPILOT_ISSUE_TYPES)', () => {
@@ -539,10 +520,8 @@ describe('SeoAutopilotCard', () => {
539
520
  it('read-only for non-admins', async () => {
540
521
  current = { ...SETTINGS, enabled: true, enabledById: 'a' }
541
522
  render(<SeoAutopilotCard canEdit={false} onNavigate={() => {}} />)
542
- await screen.findByText('SEO Autopilot')
543
- expect(
544
- (screen.getByRole('switch', { name: /enable autopilot/i }) as HTMLInputElement).disabled,
545
- ).toBe(true)
523
+ const toggle = await screen.findByRole('switch', { name: /enable autopilot/i })
524
+ expect((toggle as HTMLInputElement).disabled).toBe(true)
546
525
  expect((screen.getByLabelText('Missing meta title') as HTMLInputElement).disabled).toBe(true)
547
526
  expect((screen.getByLabelText(/minimum brand alignment/i) as HTMLInputElement).disabled).toBe(
548
527
  true,
@@ -561,8 +540,7 @@ describe('SeoAutopilotCard', () => {
561
540
  }
562
541
  const onNavigate = vi.fn()
563
542
  render(<SeoAutopilotCard canEdit onNavigate={onNavigate} />)
564
- await screen.findByText('SEO Autopilot')
565
- const notice = screen.getByRole('region', { name: /governance/i })
543
+ const notice = await screen.findByRole('region', { name: /governance/i })
566
544
  expect(notice.getAttribute('role')).not.toBe('status')
567
545
  expect(notice.textContent ?? '').toMatch(/requires approval/i)
568
546
  fireEvent.click(within(notice).getByRole('button', { name: /settings → ai/i }))
@@ -577,7 +555,9 @@ describe('SeoAutopilotCard', () => {
577
555
  governance: { issueFixApprovalRequired: true, redirectApprovalRequired: false },
578
556
  }
579
557
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
580
- await screen.findByText('SEO Autopilot')
558
+ // Wait on a loaded control: against the skeleton the negative check below
559
+ // would pass vacuously.
560
+ await screen.findByRole('switch', { name: /enable autopilot/i })
581
561
  expect(screen.queryByRole('region', { name: /governance/i })).toBeNull()
582
562
  })
583
563
 
@@ -593,8 +573,7 @@ describe('SeoAutopilotCard', () => {
593
573
  governance: { issueFixApprovalRequired: true, redirectApprovalRequired: false },
594
574
  }
595
575
  render(<SeoAutopilotCard canEdit onNavigate={() => {}} />)
596
- await screen.findByText('SEO Autopilot')
597
- const live = screen.getAllByRole('status').map((el) => el.getAttribute('aria-label'))
576
+ const live = (await screen.findAllByRole('status')).map((el) => el.getAttribute('aria-label'))
598
577
  expect(live.sort()).toEqual(['Last run', 'Run stopped'])
599
578
  })
600
579
 
@@ -110,7 +110,10 @@ describe('UpdatesTab', () => {
110
110
 
111
111
  it('enables Save when the repo changes and persists via PUT /updates/config', async () => {
112
112
  render(<UpdatesTab role="ADMIN" />)
113
- await screen.findByText('GitHub Integration')
113
+ // The GitHub card renders optimistically for admins before /updates/check
114
+ // resolves, and that response resets the form — wait for the check to land
115
+ // before typing so the typed value cannot be wiped mid-test.
116
+ await screen.findByText("You're up to date!")
114
117
 
115
118
  const save = screen.getByRole('button', { name: 'Save Changes' }) as HTMLButtonElement
116
119
  expect(save.disabled).toBe(true)
@@ -133,7 +136,8 @@ describe('UpdatesTab', () => {
133
136
 
134
137
  it('blocks Save on an invalid repository format', async () => {
135
138
  render(<UpdatesTab role="ADMIN" />)
136
- await screen.findByText('GitHub Integration')
139
+ // See above: wait for the check response, not the optimistic card heading.
140
+ await screen.findByText("You're up to date!")
137
141
 
138
142
  const repo = screen.getByLabelText('Repository') as HTMLInputElement
139
143
  fireEvent.change(repo, { target: { value: 'not a repo' } })
@@ -13,6 +13,7 @@
13
13
  * collections the caller's scope can read.
14
14
  */
15
15
  import { cmsApi } from './api.js'
16
+ import { publicPathFor } from './public-path.js'
16
17
 
17
18
  export type PostStatus = 'DRAFT' | 'PUBLISHED' | 'SCHEDULED' | 'ARCHIVED'
18
19
 
@@ -40,6 +41,12 @@ export interface Post {
40
41
  id: string
41
42
  title: string
42
43
  slug: string
44
+ /**
45
+ * Public path (`/{urlPrefix}/{slug}`) built from the collection's
46
+ * `urlPrefix`, falling back to the collection slug when no prefix is known.
47
+ * `postTypeId` stays the API identity — never render that as the URL.
48
+ */
49
+ publicPath: string
43
50
  postTypeId: string
44
51
  postTypeName: string
45
52
  postTypeAccentColor: string | null
@@ -229,10 +236,14 @@ export async function fetchPosts(query: PostsQuery = {}): Promise<PostsResult> {
229
236
  const posts: Post[] = r.posts.map((d) => {
230
237
  const type = typeBySlug.get(d.collection)
231
238
  const authorName = d.author?.name?.trim() || 'Unknown'
239
+ const slug = d.slug ?? ''
232
240
  return {
233
241
  id: d.id,
234
242
  title: d.title ?? '(untitled)',
235
- slug: d.slug ?? '',
243
+ slug,
244
+ // The server resolves `urlPrefix` from the running config (`''` for a
245
+ // root-mounted collection); a missing type row falls back to the slug.
246
+ publicPath: publicPathFor(type?.urlPrefix, d.collection, slug),
236
247
  postTypeId: d.collection,
237
248
  postTypeName: type?.singular ?? d.collection,
238
249
  postTypeAccentColor: type?.color ?? null,
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Public path helpers for list-row subtitles.
3
+ *
4
+ * A collection's slug (`posts`) is its API / cache-tag / MCP identity; its
5
+ * `urlPrefix` (`blog`) is where the public site serves it. Editors read the
6
+ * subtitle as the live URL, so it must be built from the prefix (#921).
7
+ */
8
+
9
+ function trimSlashes(value: string): string {
10
+ return value.replace(/^\/+|\/+$/g, '')
11
+ }
12
+
13
+ /**
14
+ * Build the public path for a document.
15
+ *
16
+ * - `urlPrefix` non-empty → `/{urlPrefix}/{slug}` (nested prefixes such as
17
+ * `help/articles` are kept; stray slashes are trimmed).
18
+ * - `urlPrefix === ''` → `/{slug}` (collection mounted at the site root).
19
+ * - `urlPrefix === undefined` → `/{collectionSlug}/{slug}` (no prefix known;
20
+ * the collection slug is the best available guess).
21
+ *
22
+ * An empty `slug` yields a trailing slash (`/blog/`) so callers can append a
23
+ * placeholder.
24
+ */
25
+ export function publicPathFor(
26
+ urlPrefix: string | undefined,
27
+ collectionSlug: string,
28
+ slug: string,
29
+ ): string {
30
+ const prefix = trimSlashes(urlPrefix ?? collectionSlug)
31
+ const base = prefix ? `/${prefix}` : ''
32
+ return `${base}/${trimSlashes(slug)}`
33
+ }
@@ -825,7 +825,8 @@ function PostRow({
825
825
  {post.title}
826
826
  </span>
827
827
  <span className="text-muted-foreground block truncate text-xs">
828
- /{post.postTypeId}/{post.slug || '—'}
828
+ {post.publicPath}
829
+ {post.slug ? '' : '—'}
829
830
  </span>
830
831
  </span>
831
832
  </button>
@@ -1,3 +1,5 @@
1
+ import { sanitizeHtml } from '@prenta/core/sanitize'
2
+
1
3
  import type { PageSection } from '../../../lib/page-editor-service.js'
2
4
  import { bool, str, type PostRenderContext } from './parts.js'
3
5
 
@@ -6,10 +8,9 @@ import { bool, str, type PostRenderContext } from './parts.js'
6
8
  * post's own Body field (passed via context); otherwise it renders the
7
9
  * section's own `body` copy.
8
10
  *
9
- * The Body field is a `richText` field (sanitized HTML on write), so this
10
- * canvas preview renders it as HTML — exactly like the public site's
11
- * article-body renderer — to stay WYSIWYG. It previews the author's own
12
- * in-progress draft; the server sanitizes (sanitize-html) on save.
11
+ * The canvas previews the author's in-progress draft, which has not been
12
+ * through the write-time sanitizer yet. Strip disallowed tags here with the
13
+ * cms-core allow-list before injecting HTML.
13
14
  */
14
15
  export function ArticleBodySection({
15
16
  section,
@@ -20,7 +21,8 @@ export function ArticleBodySection({
20
21
  }) {
21
22
  const c = section.content
22
23
  const source = str(c, 'source', 'field')
23
- const html = source === 'field' ? (context?.body ?? '') : str(c, 'body')
24
+ const rawHtml = source === 'field' ? (context?.body ?? '') : str(c, 'body')
25
+ const html = sanitizeHtml(rawHtml)
24
26
  const wide = str(c, 'width', 'normal') === 'wide'
25
27
  const showToc = bool(c, 'showToc')
26
28
 
@@ -32,6 +32,7 @@
32
32
  * renderer falls back to the built-in for that type, or to the placeholder if
33
33
  * the type has no built-in, instead of taking down the editor.
34
34
  */
35
+ import { sanitizeHtml } from '@prenta/core/sanitize'
35
36
  import { createContext, useContext, type ReactNode } from 'react'
36
37
 
37
38
  /** Structural mirror of `RenderImageProps` from `@prenta/sections-react`. */
@@ -109,13 +110,12 @@ export type AdminSectionRenderer = (props: AdminSectionRendererProps) => ReactNo
109
110
  export type AdminSectionRendererMap = Record<string, AdminSectionRenderer>
110
111
 
111
112
  /**
112
- * Render options passed to every admin renderer. Rich text is sanitized on
113
- * write by cms-core, so the canvas (same trust domain as the public default)
114
- * uses the identity sanitizer; images render as plain lazy `<img>` since the
115
- * editor canvas has no next/image pipeline.
113
+ * Render options passed to every admin renderer. Draft HTML is sanitized
114
+ * with the cms-core allow-list before any renderer injects it. Images render
115
+ * as plain lazy `<img>` since the editor canvas has no next/image pipeline.
116
116
  */
117
117
  export const ADMIN_RENDER_OPTIONS: AdminSectionRendererProps['options'] = {
118
- sanitizeHtml: (html) => html,
118
+ sanitizeHtml,
119
119
  baseUrl: '',
120
120
  // Never grants the priority slot. On a public page exactly one image may
121
121
  // load eagerly; in the canvas nothing competes for bandwidth and every