@actuate-media/cms-admin 0.76.0 → 0.76.2

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 (61) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/__tests__/components/seo-editor-pane.render.test.d.ts +2 -0
  3. package/dist/__tests__/components/seo-editor-pane.render.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/seo-editor-pane.render.test.js +100 -0
  5. package/dist/__tests__/components/seo-editor-pane.render.test.js.map +1 -0
  6. package/dist/__tests__/layout/sidebar-plugin-nav.test.d.ts +2 -0
  7. package/dist/__tests__/layout/sidebar-plugin-nav.test.d.ts.map +1 -0
  8. package/dist/__tests__/layout/sidebar-plugin-nav.test.js +46 -0
  9. package/dist/__tests__/layout/sidebar-plugin-nav.test.js.map +1 -0
  10. package/dist/__tests__/router/navigation-blocker.test.d.ts +2 -0
  11. package/dist/__tests__/router/navigation-blocker.test.d.ts.map +1 -0
  12. package/dist/__tests__/router/navigation-blocker.test.js +69 -0
  13. package/dist/__tests__/router/navigation-blocker.test.js.map +1 -0
  14. package/dist/__tests__/views/article-body-section.render.test.d.ts +2 -0
  15. package/dist/__tests__/views/article-body-section.render.test.d.ts.map +1 -0
  16. package/dist/__tests__/views/article-body-section.render.test.js +24 -0
  17. package/dist/__tests__/views/article-body-section.render.test.js.map +1 -0
  18. package/dist/actuate-admin.css +1 -1
  19. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  20. package/dist/components/seo/SeoEditorPane.js +16 -13
  21. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  22. package/dist/layout/Sidebar.d.ts.map +1 -1
  23. package/dist/layout/Sidebar.js +17 -0
  24. package/dist/layout/Sidebar.js.map +1 -1
  25. package/dist/router/index.d.ts +13 -0
  26. package/dist/router/index.d.ts.map +1 -1
  27. package/dist/router/index.js +49 -0
  28. package/dist/router/index.js.map +1 -1
  29. package/dist/views/DocumentEdit.d.ts.map +1 -1
  30. package/dist/views/DocumentEdit.js +4 -9
  31. package/dist/views/DocumentEdit.js.map +1 -1
  32. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  33. package/dist/views/page-builder/PageBuilder.js +3 -0
  34. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  35. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  36. package/dist/views/page-editor/PageSectionEditor.js +3 -11
  37. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  38. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -1
  39. package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
  40. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  41. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  42. package/dist/views/post-editor/PostSectionEditor.js +3 -11
  43. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  44. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -1
  45. package/dist/views/post-editor/PostTemplateEditor.js +3 -10
  46. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
  47. package/package.json +4 -3
  48. package/src/__tests__/components/seo-editor-pane.render.test.tsx +112 -0
  49. package/src/__tests__/layout/sidebar-plugin-nav.test.ts +50 -0
  50. package/src/__tests__/router/navigation-blocker.test.ts +80 -0
  51. package/src/__tests__/views/article-body-section.render.test.tsx +32 -0
  52. package/src/components/seo/SeoEditorPane.tsx +24 -14
  53. package/src/layout/Sidebar.tsx +19 -0
  54. package/src/router/index.ts +61 -0
  55. package/src/styles/theme.css +91 -0
  56. package/src/views/DocumentEdit.tsx +4 -8
  57. package/src/views/page-builder/PageBuilder.tsx +4 -0
  58. package/src/views/page-editor/PageSectionEditor.tsx +3 -10
  59. package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -4
  60. package/src/views/post-editor/PostSectionEditor.tsx +3 -10
  61. package/src/views/post-editor/PostTemplateEditor.tsx +3 -9
package/CHANGELOG.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # @actuate-media/cms-admin
2
2
 
3
+ ## 0.76.2
4
+
5
+ ### Patch Changes
6
+
7
+ - cc18833: SEO audit remediation — 47 verified fixes across the SEO system.
8
+
9
+ Highlights: redirect middleware no longer excludes legacy `.html/.php/.aspx`
10
+ URLs (migrations); robots.txt now `Allow`s the default OG image (`/api/cms/og.png`)
11
+ so social shares render; RSS always emits the mandatory channel `<description>`;
12
+ structured-data auto-detection no longer emits invalid `Review/Event/JobPosting`
13
+ (required fields enforced or the node is suppressed) and no longer mis-types
14
+ collections by substring (`newsletters`→NewsArticle etc.); the sitemap endpoint
15
+ no longer selects full document JSON for up to 45k rows; IndexNow submits on
16
+ every publish instead of being swallowed by the sitemap debounce; the exported
17
+ `renderJsonLdScript()` now escapes `<`; the editor no longer shows two
18
+ conflicting scores and the Content-tab analysis now understands section-built
19
+ pages; plus pagination `rel`/canonical, `og:locale` normalization + alternates,
20
+ canonical absolutization, VideoObject/Product/WebSite-SearchAction builders,
21
+ redirect loop/case handling, plugin-seo sitemap caps + real `lastmod`, and
22
+ plugin link-health routed through `safeFetch`. Adds several new exports on
23
+ `@actuate-media/cms-core` (e.g. `buildVideoObjectSchema`, `buildProductSchema`).
24
+
25
+ ## 0.76.1
26
+
27
+ ### Patch Changes
28
+
29
+ - f75df17: Prevent silent data loss when navigating away from an editor with unsaved
30
+ changes. The editors previously guarded only against browser unload/refresh
31
+ (`beforeunload`), which never fires on the admin's client-side (SPA) navigation —
32
+ so clicking Cancel, a sidebar/breadcrumb link, or a command-palette entry with
33
+ unsaved edits discarded them without warning. `navigate()` now confirms before
34
+ leaving when any editor has unsaved changes, via a shared `useNavigationBlocker`
35
+ hook wired into the document, page, post, post-template, and page-builder
36
+ editors (also fixing the page builder, which had no guard at all).
37
+ - 31bdfbd: Fix article body canvas preview so rich-text headings render with typography matching the TipTap editor. Replaces non-functional `prose` classes with scoped `actuate-rich-text` styles.
38
+ - 041a228: Add the MIT `license` field to every published package (previously missing) and correct `repository.url` to `github.com/actuate-media/actuatecms` (13 packages carried the wrong org).
39
+ - 898a3f8: Make plugins actually extend the CMS. Previously `config.plugins` only had their
40
+ `hooks` harvested — a plugin's `collections`, `init`, `fields`, and
41
+ `adminNavItems` were declared on the type but never consumed, so adding a plugin
42
+ registered nothing.
43
+
44
+ `defineConfig` now runs a plugin runner (`applyPlugins`): for each plugin, in
45
+ order, it applies `init(config)`, merges the plugin's `collections` (the
46
+ consumer's config and earlier plugins win on slug conflicts, so a plugin can
47
+ never clobber your collection), and accumulates reusable `fields` presets and
48
+ `adminNavItems`. Merged plugin collections flow through the existing collection
49
+ registry, so they are immediately manageable in the admin (the sidebar derives
50
+ its Content nav from `config.collections`) and queryable over REST. The admin
51
+ sidebar also renders plugin-contributed `adminNavItems` (deduped by path).
52
+
3
53
  ## 0.76.0
4
54
 
5
55
  ### Minor Changes
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=seo-editor-pane.render.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo-editor-pane.render.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/components/seo-editor-pane.render.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,100 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // @vitest-environment happy-dom
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
5
+ /**
6
+ * Focused SeoEditorPane behaviours:
7
+ * - The header labels the two numbers unambiguously ("SEO score" vs "Content
8
+ * analysis N/100") instead of the ambiguous "· Live analysis N".
9
+ * - Generating an OG title/description writes into the OG field itself and
10
+ * never overwrites the (search) meta title/description — a previously
11
+ * destructive, undo-less action.
12
+ */
13
+ const RECORD = {
14
+ id: 'seo1',
15
+ entityType: 'page',
16
+ entityId: 'p1',
17
+ title: 'Home',
18
+ url: 'https://example.com/',
19
+ canonicalUrl: null,
20
+ metaTitle: 'Home — Example',
21
+ metaTitlePage: 'Home',
22
+ siteName: 'Example',
23
+ titleTemplate: '%page% — %site%',
24
+ metaDescription: 'Welcome to Example.',
25
+ focusKeyword: null,
26
+ keyTakeaway: null,
27
+ ogTitle: null,
28
+ ogDescription: null,
29
+ ogImageId: null,
30
+ ogImage: null,
31
+ twitterTitle: null,
32
+ twitterDescription: null,
33
+ twitterImageId: null,
34
+ noindex: false,
35
+ nofollow: false,
36
+ structuredDataType: 'WebPage',
37
+ structuredData: null,
38
+ llmsInclude: 'AUTO',
39
+ llmsDescription: null,
40
+ llmsSection: null,
41
+ llmsOptional: false,
42
+ seoScore: 82,
43
+ seoGrade: 'good',
44
+ updatedAt: '2026-06-01T00:00:00Z',
45
+ };
46
+ const fetchSeoContentRecord = vi.fn(async () => RECORD);
47
+ const generateSeoField = vi.fn(async (_field, _ctx) => ({
48
+ text: 'Generated OG Title',
49
+ }));
50
+ vi.mock('@actuate-media/cms-core/client', async (importOriginal) => {
51
+ const actual = await importOriginal();
52
+ return { ...actual };
53
+ });
54
+ vi.mock('../../lib/seo-service.js', () => ({
55
+ fetchSeoContentRecord: () => fetchSeoContentRecord(),
56
+ fetchDocumentSeoAnalysis: vi.fn(async () => ({ score: 72, checks: [] })),
57
+ updateSeoFields: vi.fn(async () => ({})),
58
+ generateSeoField: (field, ctx) => generateSeoField(field, ctx),
59
+ fetchLinkSuggestions: vi.fn(async () => ({ data: { suggestions: [] } })),
60
+ SEO_SCHEMA_TYPES: ['Article', 'WebPage'],
61
+ }));
62
+ vi.mock('../../lib/brand-voice-client.js', () => ({
63
+ fixBrandVoiceContent: vi.fn(async () => ({ ok: false })),
64
+ brandAlignmentTone: () => 'text-success',
65
+ brandAlignmentLabel: () => 'On brand',
66
+ }));
67
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }));
68
+ const { SeoEditorPane } = await import('../../components/seo/SeoEditorPane.js');
69
+ beforeEach(() => {
70
+ fetchSeoContentRecord.mockClear();
71
+ generateSeoField.mockClear();
72
+ });
73
+ describe('SeoEditorPane header', () => {
74
+ it('labels the analysis number as "Content analysis N/100", not "Live analysis"', async () => {
75
+ render(_jsx(SeoEditorPane, { entityType: "page", entityId: "p1", onClose: () => { } }));
76
+ // Score header renders once the record loads.
77
+ expect(await screen.findByText(/SEO score:/)).toBeTruthy();
78
+ await waitFor(() => expect(screen.getByText(/Content analysis 72\/100/)).toBeTruthy());
79
+ expect(screen.queryByText(/Live analysis/)).toBeNull();
80
+ });
81
+ });
82
+ describe('SeoEditorPane OG generation', () => {
83
+ it('writes an AI OG title into the OG field and leaves the meta title intact', async () => {
84
+ render(_jsx(SeoEditorPane, { entityType: "page", entityId: "p1", onClose: () => { } }));
85
+ // Wait for the record → the SEO (meta) title field shows the page portion.
86
+ expect(await screen.findByDisplayValue('Home')).toBeTruthy();
87
+ // Expand the collapsed "Social appearance" accordion to reveal OG fields.
88
+ fireEvent.click(screen.getByRole('button', { name: /Social appearance/ }));
89
+ // Click the AI Generate button that belongs to the "OG title" field.
90
+ const ogLabel = await screen.findByText('OG title');
91
+ const ogFieldHeader = ogLabel.closest('div');
92
+ fireEvent.click(within(ogFieldHeader).getByRole('button', { name: /AI Generate/ }));
93
+ await waitFor(() => expect(generateSeoField).toHaveBeenCalledWith('ogTitle', expect.anything()));
94
+ // The suggestion lands in the OG field...
95
+ await waitFor(() => expect(screen.getByDisplayValue('Generated OG Title')).toBeTruthy());
96
+ // ...and the search meta title is untouched (was NOT overwritten/cleared).
97
+ expect(screen.getByDisplayValue('Home')).toBeTruthy();
98
+ });
99
+ });
100
+ //# sourceMappingURL=seo-editor-pane.render.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"seo-editor-pane.render.test.js","sourceRoot":"","sources":["../../../src/__tests__/components/seo-editor-pane.render.test.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=sidebar-plugin-nav.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sidebar-plugin-nav.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/layout/sidebar-plugin-nav.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildNavItems } from '../../layout/Sidebar.js';
3
+ // Plugin adminNavItems (collected onto `_pluginNavItems` by cms-core's
4
+ // applyPlugins) must surface in the admin sidebar, without doubling a built-in
5
+ // destination or one of the plugin's own collections.
6
+ const baseCollections = {
7
+ collections: {
8
+ pages: { slug: 'pages', labels: { singular: 'Page', plural: 'Pages' }, type: 'page' },
9
+ },
10
+ };
11
+ describe('buildNavItems — plugin nav items', () => {
12
+ it('adds a plugin-contributed custom nav link', () => {
13
+ const items = buildNavItems({
14
+ ...baseCollections,
15
+ _pluginNavItems: [{ label: 'Redirects', icon: 'arrow-right-left', path: '/redirects' }],
16
+ });
17
+ const redirects = items.find((i) => i.path === '/redirects');
18
+ expect(redirects).toBeDefined();
19
+ expect(redirects?.label).toBe('Redirects');
20
+ expect(redirects?.group).toBe('Content');
21
+ });
22
+ it('does not double a built-in destination path', () => {
23
+ const items = buildNavItems({
24
+ ...baseCollections,
25
+ _pluginNavItems: [{ label: 'Media Library', icon: 'image', path: '/media' }],
26
+ });
27
+ expect(items.filter((i) => i.path === '/media')).toHaveLength(1);
28
+ });
29
+ it('falls back to a default icon for an unknown icon string and still renders', () => {
30
+ const items = buildNavItems({
31
+ ...baseCollections,
32
+ _pluginNavItems: [{ label: 'Custom', icon: 'no-such-icon', path: '/custom' }],
33
+ });
34
+ const custom = items.find((i) => i.path === '/custom');
35
+ expect(custom).toBeDefined();
36
+ expect(custom?.icon).toBeTruthy();
37
+ });
38
+ it('ignores malformed plugin nav entries', () => {
39
+ const items = buildNavItems({
40
+ ...baseCollections,
41
+ _pluginNavItems: [{ label: 'No path' }, null, { path: '/ok', label: 'Ok', icon: 'menu' }],
42
+ });
43
+ expect(items.find((i) => i.path === '/ok')).toBeDefined();
44
+ });
45
+ });
46
+ //# sourceMappingURL=sidebar-plugin-nav.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sidebar-plugin-nav.test.js","sourceRoot":"","sources":["../../../src/__tests__/layout/sidebar-plugin-nav.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAA;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,yBAAyB,CAAA;AAEvD,uEAAuE;AACvE,+EAA+E;AAC/E,sDAAsD;AACtD,MAAM,eAAe,GAAG;IACtB,WAAW,EAAE;QACX,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE;KACtF;CACF,CAAA;AAED,QAAQ,CAAC,kCAAkC,EAAE,GAAG,EAAE;IAChD,EAAE,CAAC,2CAA2C,EAAE,GAAG,EAAE;QACnD,MAAM,KAAK,GAAG,aAAa,CAAC;YAC1B,GAAG,eAAe;YAClB,eAAe,EAAE,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,kBAAkB,EAAE,IAAI,EAAE,YAAY,EAAE,CAAC;SACxF,CAAC,CAAA;QACF,MAAM,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,YAAY,CAAC,CAAA;QAC5D,MAAM,CAAC,SAAS,CAAC,CAAC,WAAW,EAAE,CAAA;QAC/B,MAAM,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;QAC1C,MAAM,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAA;IAC1C,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,6CAA6C,EAAE,GAAG,EAAE;QACrD,MAAM,KAAK,GAAG,aAAa,CAAC;YAC1B,GAAG,eAAe;YAClB,eAAe,EAAE,CAAC,EAAE,KAAK,EAAE,eAAe,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;SAC7E,CAAC,CAAA;QACF,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,CAAA;IAClE,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,2EAA2E,EAAE,GAAG,EAAE;QACnF,MAAM,KAAK,GAAG,aAAa,CAAC;YAC1B,GAAG,eAAe;YAClB,eAAe,EAAE,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,cAAc,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;SAC9E,CAAC,CAAA;QACF,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,SAAS,CAAC,CAAA;QACtD,MAAM,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAA;QAC5B,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,UAAU,EAAE,CAAA;IACnC,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,sCAAsC,EAAE,GAAG,EAAE;QAC9C,MAAM,KAAK,GAAG,aAAa,CAAC;YAC1B,GAAG,eAAe;YAClB,eAAe,EAAE,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,IAAI,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC;SAC1F,CAAC,CAAA;QACF,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,KAAK,CAAC,CAAC,CAAC,WAAW,EAAE,CAAA;IAC3D,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=navigation-blocker.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation-blocker.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/router/navigation-blocker.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,69 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { act, renderHook } from '@testing-library/react';
4
+ import { hasBlockingNavigation, NAVIGATION_CONFIRM_MESSAGE, registerNavigationBlocker, useAdminRouter, useNavigationBlocker, } from '../../router/index.js';
5
+ // Guards against silent data loss: an in-app route change (Cancel, sidebar /
6
+ // breadcrumb links, command palette) must confirm before discarding an editor's
7
+ // unsaved changes — beforeunload only covers hard reloads, never SPA navigation.
8
+ describe('navigation blocker', () => {
9
+ let confirmMock;
10
+ beforeEach(() => {
11
+ // Reset the URL so each test's router mounts at '/' (pushState from a prior
12
+ // test would otherwise leak into window.location and skew currentPath).
13
+ window.history.replaceState(null, '', '/admin');
14
+ confirmMock = vi.fn(() => true);
15
+ window.confirm =
16
+ confirmMock;
17
+ });
18
+ afterEach(() => {
19
+ // Ensure no blocker leaks into the next test.
20
+ expect(hasBlockingNavigation()).toBe(false);
21
+ });
22
+ it('navigates freely and never prompts when nothing is dirty', () => {
23
+ const { result } = renderHook(() => useAdminRouter('/admin', '/'));
24
+ act(() => result.current.navigate('/collections/posts'));
25
+ expect(result.current.currentPath).toBe('/collections/posts');
26
+ expect(confirmMock).not.toHaveBeenCalled();
27
+ });
28
+ it('aborts the navigation when a dirty editor is registered and the user cancels', () => {
29
+ confirmMock.mockReturnValue(false);
30
+ const unregister = registerNavigationBlocker(() => true);
31
+ try {
32
+ const { result } = renderHook(() => useAdminRouter('/admin', '/'));
33
+ act(() => result.current.navigate('/collections/posts'));
34
+ expect(confirmMock).toHaveBeenCalledWith(NAVIGATION_CONFIRM_MESSAGE);
35
+ expect(result.current.currentPath).toBe('/');
36
+ }
37
+ finally {
38
+ unregister();
39
+ }
40
+ });
41
+ it('proceeds with the navigation when the user confirms leaving', () => {
42
+ confirmMock.mockReturnValue(true);
43
+ const unregister = registerNavigationBlocker(() => true);
44
+ try {
45
+ const { result } = renderHook(() => useAdminRouter('/admin', '/'));
46
+ act(() => result.current.navigate('/settings'));
47
+ expect(confirmMock).toHaveBeenCalledOnce();
48
+ expect(result.current.currentPath).toBe('/settings');
49
+ }
50
+ finally {
51
+ unregister();
52
+ }
53
+ });
54
+ it('useNavigationBlocker only blocks while dirty and cleans up on unmount', () => {
55
+ const { rerender, unmount } = renderHook(({ dirty }) => useNavigationBlocker(dirty), {
56
+ initialProps: { dirty: false },
57
+ });
58
+ expect(hasBlockingNavigation()).toBe(false);
59
+ rerender({ dirty: true });
60
+ expect(hasBlockingNavigation()).toBe(true);
61
+ rerender({ dirty: false });
62
+ expect(hasBlockingNavigation()).toBe(false);
63
+ rerender({ dirty: true });
64
+ expect(hasBlockingNavigation()).toBe(true);
65
+ unmount();
66
+ expect(hasBlockingNavigation()).toBe(false);
67
+ });
68
+ });
69
+ //# sourceMappingURL=navigation-blocker.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navigation-blocker.test.js","sourceRoot":"","sources":["../../../src/__tests__/router/navigation-blocker.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=article-body-section.render.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"article-body-section.render.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/views/article-body-section.render.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // @vitest-environment happy-dom
3
+ import { render, screen } from '@testing-library/react';
4
+ import { describe, expect, it } from 'vitest';
5
+ import { ArticleBodySection } from '../../views/page-editor/sections/ArticleBodySection.js';
6
+ function section(content = {}) {
7
+ return {
8
+ id: 's1',
9
+ sectionType: 'article-body',
10
+ name: 'Article Body',
11
+ visible: true,
12
+ content,
13
+ settings: {},
14
+ };
15
+ }
16
+ describe('ArticleBodySection', () => {
17
+ it('renders post body HTML with rich-text preview class', () => {
18
+ const { container } = render(_jsx(ArticleBodySection, { section: section({ source: 'field' }), context: { body: '<h2>What is Lorem Ipsum?</h2><p>Body copy.</p>' } }));
19
+ expect(screen.getByRole('heading', { level: 2, name: 'What is Lorem Ipsum?' })).toBeTruthy();
20
+ expect(container.querySelector('.actuate-rich-text')).toBeTruthy();
21
+ expect(container.querySelector('.prose')).toBeNull();
22
+ });
23
+ });
24
+ //# sourceMappingURL=article-body-section.render.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"article-body-section.render.test.js","sourceRoot":"","sources":["../../../src/__tests__/views/article-body-section.render.test.tsx"],"names":[],"mappings":";AAAA,gCAAgC;AAChC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAA;AACvD,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAA;AAE7C,OAAO,EAAE,kBAAkB,EAAE,MAAM,wDAAwD,CAAA;AAG3F,SAAS,OAAO,CAAC,UAAmC,EAAE;IACpD,OAAO;QACL,EAAE,EAAE,IAAI;QACR,WAAW,EAAE,cAAc;QAC3B,IAAI,EAAE,cAAc;QACpB,OAAO,EAAE,IAAI;QACb,OAAO;QACP,QAAQ,EAAE,EAAE;KACb,CAAA;AACH,CAAC;AAED,QAAQ,CAAC,oBAAoB,EAAE,GAAG,EAAE;IAClC,EAAE,CAAC,qDAAqD,EAAE,GAAG,EAAE;QAC7D,MAAM,EAAE,SAAS,EAAE,GAAG,MAAM,CAC1B,KAAC,kBAAkB,IACjB,OAAO,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,EACrC,OAAO,EAAE,EAAE,IAAI,EAAE,gDAAgD,EAAE,GACnE,CACH,CAAA;QAED,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,SAAS,EAAE,EAAE,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,sBAAsB,EAAE,CAAC,CAAC,CAAC,UAAU,EAAE,CAAA;QAC5F,MAAM,CAAC,SAAS,CAAC,aAAa,CAAC,oBAAoB,CAAC,CAAC,CAAC,UAAU,EAAE,CAAA;QAClE,MAAM,CAAC,SAAS,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;IACtD,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}