@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.
- package/CHANGELOG.md +12 -0
- package/dist/__tests__/lib/posts-service.test.js +35 -0
- package/dist/__tests__/lib/posts-service.test.js.map +1 -1
- package/dist/__tests__/lib/public-path.test.d.ts +2 -0
- package/dist/__tests__/lib/public-path.test.d.ts.map +1 -0
- package/dist/__tests__/lib/public-path.test.js +25 -0
- package/dist/__tests__/lib/public-path.test.js.map +1 -0
- package/dist/__tests__/views/admin-section-renderers.test.js +1 -0
- package/dist/__tests__/views/admin-section-renderers.test.js.map +1 -1
- package/dist/__tests__/views/article-body-section.render.test.js +7 -0
- package/dist/__tests__/views/article-body-section.render.test.js.map +1 -1
- package/dist/__tests__/views/posts-list-view.render.test.js +11 -0
- package/dist/__tests__/views/posts-list-view.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-autopilot-card.render.test.js +32 -47
- package/dist/__tests__/views/seo-autopilot-card.render.test.js.map +1 -1
- package/dist/__tests__/views/updates-tab.render.test.js +6 -2
- package/dist/__tests__/views/updates-tab.render.test.js.map +1 -1
- package/dist/lib/posts-service.d.ts +6 -0
- package/dist/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +6 -1
- package/dist/lib/posts-service.js.map +1 -1
- package/dist/lib/public-path.d.ts +21 -0
- package/dist/lib/public-path.d.ts.map +1 -0
- package/dist/lib/public-path.js +28 -0
- package/dist/lib/public-path.js.map +1 -0
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +1 -1
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts +3 -4
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js +6 -5
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- package/dist/views/page-editor/sections/admin-renderers.d.ts +3 -36
- package/dist/views/page-editor/sections/admin-renderers.d.ts.map +1 -1
- package/dist/views/page-editor/sections/admin-renderers.js +5 -5
- package/dist/views/page-editor/sections/admin-renderers.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/posts-service.test.ts +35 -0
- package/src/__tests__/lib/public-path.test.ts +37 -0
- package/src/__tests__/views/admin-section-renderers.test.tsx +1 -0
- package/src/__tests__/views/article-body-section.render.test.tsx +14 -0
- package/src/__tests__/views/posts-list-view.render.test.tsx +12 -0
- package/src/__tests__/views/seo-autopilot-card.render.test.tsx +32 -53
- package/src/__tests__/views/updates-tab.render.test.tsx +6 -2
- package/src/lib/posts-service.ts +12 -1
- package/src/lib/public-path.ts +33 -0
- package/src/views/Posts/PostsListView.tsx +2 -1
- package/src/views/page-editor/sections/ArticleBodySection.tsx +7 -5
- 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.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
528
|
-
expect(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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' } })
|
package/src/lib/posts-service.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
|
@@ -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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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.
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
|
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
|