@actuate-media/cms-admin 0.76.4 → 0.76.6

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 CHANGED
@@ -1,5 +1,44 @@
1
1
  # @actuate-media/cms-admin
2
2
 
3
+ ## 0.76.6
4
+
5
+ ### Patch Changes
6
+
7
+ - 0cc76fb: Declare `react`/`react-dom` as peerDependencies to prevent a dual-React crash.
8
+
9
+ `@actuate-media/cms-admin` shipped `react` and `react-dom` under
10
+ `dependencies`, so any consumer whose React version didn't dedupe with `^19.2.0`
11
+ — a React 18 app (a configuration `cms-core` advertises via `react >=18.0.0`),
12
+ or React 19.0/19.1 — got a second, nested React copy installed. Two React copies
13
+ trigger "Invalid hook call" and hard-crash the entire admin UI.
14
+
15
+ They're now `peerDependencies` (`^18.0.0 || ^19.0.0`) so the consumer's single
16
+ React instance is always used (with a devDependency for local build/test). A new
17
+ `pnpm check:react-peers` CI gate fails if any publishable package declares
18
+ `react`/`react-dom` under `dependencies` again.
19
+
20
+ ## 0.76.5
21
+
22
+ ### Patch Changes
23
+
24
+ - cd8a061: Remove dead sidebar nav from bundled plugins and guard the whole class.
25
+
26
+ `plugin-ai`, `plugin-commerce`, and `plugin-blocks` each advertised
27
+ `adminNavItems` paths (`/ai/*`, `/commerce/*`, `/blocks`) that the admin router
28
+ has no view for — plugins cannot register views, and these plugins register no
29
+ collection backing those paths — so every one of those sidebar links landed on
30
+ the 404 page. The commerce items were also duplicates of the working collection
31
+ nav (its collections already appear under the "Commerce" group at their real
32
+ `/<slug>` routes).
33
+ - Dropped the dead `adminNavItems` from all three plugins. Commerce collections
34
+ remain reachable at `/products`, `/orders`, `/discounts`, …; plugin-ai and
35
+ plugin-blocks are backend/page-builder plugins with no standalone admin route.
36
+ - Added `cms-admin`'s `router/admin-routes` resolver (`adminPathResolves`) plus
37
+ a table-driven test asserting **every** bundled plugin's nav paths resolve to a
38
+ real view, and a drift test that keeps the known-route list in sync with
39
+ `AdminRoot`. This is the systemic guard for a bug class that has now shipped in
40
+ published packages three times.
41
+
3
42
  ## 0.76.4
4
43
 
5
44
  ### Patch Changes
@@ -56,5 +56,30 @@ describe('buildNavItems — duplicate / grouping regressions', () => {
56
56
  expect(items.find((i) => i.path === '/seo/sitemap')?.group).toBe('Content');
57
57
  expect(items.find((i) => i.path === '/seo/link-health')?.group).toBe('Content');
58
58
  });
59
+ it('excludes admin.hidden collections and does not resurrect removed nav paths', () => {
60
+ // Post-fix reality: plugin-forms/plugin-redirects no longer advertise
61
+ // Submissions/Redirects nav items, and their form-submissions/redirects
62
+ // collections are admin.hidden. Neither should surface a (404-ing) nav path.
63
+ const items = buildNavItems({
64
+ collections: {
65
+ pages: { slug: 'pages', type: 'page', labels: { plural: 'Pages' } },
66
+ forms: { slug: 'forms', labels: { plural: 'Forms' }, admin: { group: 'Forms' } },
67
+ 'form-submissions': {
68
+ slug: 'form-submissions',
69
+ labels: { plural: 'Form Submissions' },
70
+ admin: { hidden: true },
71
+ },
72
+ redirects: { slug: 'redirects', labels: { plural: 'Redirects' }, admin: { hidden: true } },
73
+ },
74
+ _pluginNavItems: [
75
+ { label: 'Forms', icon: 'FileText', path: '/forms' },
76
+ { label: 'SEO', icon: 'search', path: '/seo' },
77
+ ],
78
+ });
79
+ const paths = items.map((i) => i.path);
80
+ expect(paths).not.toContain('/form-submissions');
81
+ expect(paths).not.toContain('/redirects');
82
+ expect(items.filter((i) => i.path === '/forms')).toHaveLength(1);
83
+ });
59
84
  });
60
85
  //# sourceMappingURL=build-nav-items.test.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"build-nav-items.test.js","sourceRoot":"","sources":["../../../src/__tests__/layout/build-nav-items.test.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"build-nav-items.test.js","sourceRoot":"","sources":["../../../src/__tests__/layout/build-nav-items.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=admin-routes.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-routes.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/router/admin-routes.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { ADMIN_STATIC_ROUTE_PATTERNS, adminPathResolves, matchesRoutePattern, collectionRoutePatterns, } from '../../router/admin-routes.js';
5
+ describe('matchesRoutePattern', () => {
6
+ it('matches by equal segment count with :param wildcards', () => {
7
+ expect(matchesRoutePattern('/pages', '/pages')).toBe(true);
8
+ expect(matchesRoutePattern('/pages/abc123/edit', '/pages/:id/edit')).toBe(true);
9
+ expect(matchesRoutePattern('/', '/')).toBe(true);
10
+ });
11
+ it('rejects a mismatched segment count or literal', () => {
12
+ expect(matchesRoutePattern('/pages/abc', '/pages')).toBe(false);
13
+ expect(matchesRoutePattern('/ai/scores', '/pages/:id')).toBe(false);
14
+ expect(matchesRoutePattern('/', '/pages')).toBe(false);
15
+ });
16
+ it('ignores query string and hash', () => {
17
+ expect(matchesRoutePattern('/seo?tab=audit', '/seo')).toBe(true);
18
+ });
19
+ });
20
+ describe('adminPathResolves', () => {
21
+ it('resolves built-in routes and SEO deep paths', () => {
22
+ expect(adminPathResolves('/media')).toBe(true);
23
+ expect(adminPathResolves('/forms')).toBe(true);
24
+ expect(adminPathResolves('/seo')).toBe(true);
25
+ expect(adminPathResolves('/seo/link-health')).toBe(true);
26
+ });
27
+ it('does NOT resolve a path with no backing route (the plugin-nav bug)', () => {
28
+ expect(adminPathResolves('/ai/scores')).toBe(false);
29
+ expect(adminPathResolves('/commerce/products')).toBe(false);
30
+ expect(adminPathResolves('/blocks')).toBe(false);
31
+ });
32
+ it('resolves a non-reserved collection at /<slug> and /collections/<slug>', () => {
33
+ const config = { collections: { products: { slug: 'products' } } };
34
+ expect(adminPathResolves('/products', config)).toBe(true);
35
+ expect(adminPathResolves('/collections/products', config)).toBe(true);
36
+ // Without the collection, the top-level route does not exist.
37
+ expect(adminPathResolves('/products')).toBe(false);
38
+ });
39
+ it('does not give a reserved-slug collection a shadowing top-level route', () => {
40
+ const patterns = collectionRoutePatterns({ collections: { pages: { slug: 'pages' } } });
41
+ expect(patterns).toContain('/collections/pages');
42
+ expect(patterns).not.toContain('/pages');
43
+ });
44
+ });
45
+ /**
46
+ * Drift guard: every literal `matchRoute('…')` in AdminRoot must be represented
47
+ * in ADMIN_STATIC_ROUTE_PATTERNS, so `adminPathResolves` can't silently fall
48
+ * behind the real router (which would let a broken plugin nav path pass review).
49
+ * Dynamic `matchRoute(`/${slug}/…`)` template calls and iterated SEO paths are
50
+ * intentionally excluded — those are covered by collection/SEO patterns.
51
+ */
52
+ describe('ADMIN_STATIC_ROUTE_PATTERNS stays in sync with AdminRoot', () => {
53
+ it('covers every literal matchRoute() call in AdminRoot', () => {
54
+ const src = readFileSync(fileURLToPath(new URL('../../AdminRoot.tsx', import.meta.url)), 'utf8');
55
+ const literals = [...src.matchAll(/matchRoute\(\s*'([^']+)'\s*\)/g)].map((m) => m[1]);
56
+ expect(literals.length).toBeGreaterThan(20);
57
+ const known = new Set(ADMIN_STATIC_ROUTE_PATTERNS);
58
+ const missing = [...new Set(literals)].filter((p) => !known.has(p));
59
+ expect(missing).toEqual([]);
60
+ });
61
+ });
62
+ //# sourceMappingURL=admin-routes.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-routes.test.js","sourceRoot":"","sources":["../../../src/__tests__/router/admin-routes.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=plugin-nav-resolves.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-nav-resolves.test.d.ts","sourceRoot":"","sources":["../../../src/__tests__/router/plugin-nav-resolves.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { aiPlugin } from '@actuate-media/plugin-ai';
3
+ import { blocksPlugin } from '@actuate-media/plugin-blocks';
4
+ import { commercePlugin } from '@actuate-media/plugin-commerce';
5
+ import { formsPlugin } from '@actuate-media/plugin-forms';
6
+ import { mediaPlugin } from '@actuate-media/plugin-media';
7
+ import { navigationPlugin } from '@actuate-media/plugin-navigation';
8
+ import { seoPlugin } from '@actuate-media/plugin-seo';
9
+ import { adminPathResolves } from '../../router/admin-routes.js';
10
+ /**
11
+ * Systemic guard for the recurring plugin-nav-404 class: a plugin advertises an
12
+ * `adminNavItems` path the admin router has no route for, so the sidebar link
13
+ * lands on the 404 page. It has shipped in published packages more than once
14
+ * (plugin-ai / plugin-commerce / plugin-blocks). Plugins cannot register views,
15
+ * so every nav path MUST resolve to a built-in route or one of the plugin's own
16
+ * collection routes — asserted here for every bundled plugin.
17
+ *
18
+ * When this fails: either point the nav item at a real route (a built-in, or a
19
+ * `/<slug>` for a collection the plugin registers) or remove it until a plugin
20
+ * view API exists. Do NOT add the path to ADMIN_STATIC_ROUTE_PATTERNS unless
21
+ * AdminRoot actually renders a view for it.
22
+ */
23
+ const BUNDLED_PLUGINS = [
24
+ { name: 'plugin-ai', plugin: aiPlugin() },
25
+ { name: 'plugin-blocks', plugin: blocksPlugin() },
26
+ { name: 'plugin-commerce', plugin: commercePlugin() },
27
+ { name: 'plugin-forms', plugin: formsPlugin() },
28
+ { name: 'plugin-media', plugin: mediaPlugin() },
29
+ { name: 'plugin-navigation', plugin: navigationPlugin() },
30
+ { name: 'plugin-seo', plugin: seoPlugin() },
31
+ ];
32
+ describe('every bundled plugin adminNavItems path resolves to a real admin view', () => {
33
+ for (const { name, plugin } of BUNDLED_PLUGINS) {
34
+ const navItems = plugin.adminNavItems ?? [];
35
+ // A config exposing exactly this plugin's collections — the only extra
36
+ // destinations a plugin can legitimately point its nav at.
37
+ const config = { collections: plugin.collections ?? {} };
38
+ it(`${name} (${navItems.length} nav item${navItems.length === 1 ? '' : 's'})`, () => {
39
+ const dead = navItems
40
+ .map((item) => item.path)
41
+ .filter((path) => !adminPathResolves(path, config));
42
+ expect(dead, `${name} advertises sidebar paths with no admin route: ${dead.join(', ')}`).toEqual([]);
43
+ });
44
+ }
45
+ });
46
+ //# sourceMappingURL=plugin-nav-resolves.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-nav-resolves.test.js","sourceRoot":"","sources":["../../../src/__tests__/router/plugin-nav-resolves.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC7C,OAAO,EAAE,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAA;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAA;AAC/D,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAA;AACzD,OAAO,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAA;AACzD,OAAO,EAAE,gBAAgB,EAAE,MAAM,kCAAkC,CAAA;AACnE,OAAO,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAA;AACrD,OAAO,EAAE,iBAAiB,EAAE,MAAM,8BAA8B,CAAA;AAEhE;;;;;;;;;;;;GAYG;AACH,MAAM,eAAe,GAAG;IACtB,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE;IACzC,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,EAAE,YAAY,EAAE,EAAE;IACjD,EAAE,IAAI,EAAE,iBAAiB,EAAE,MAAM,EAAE,cAAc,EAAE,EAAE;IACrD,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE;IAC/C,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,EAAE,WAAW,EAAE,EAAE;IAC/C,EAAE,IAAI,EAAE,mBAAmB,EAAE,MAAM,EAAE,gBAAgB,EAAE,EAAE;IACzD,EAAE,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE;CAC5C,CAAA;AAED,QAAQ,CAAC,uEAAuE,EAAE,GAAG,EAAE;IACrF,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,eAAe,EAAE,CAAC;QAC/C,MAAM,QAAQ,GAAG,MAAM,CAAC,aAAa,IAAI,EAAE,CAAA;QAC3C,uEAAuE;QACvE,2DAA2D;QAC3D,MAAM,MAAM,GAAG,EAAE,WAAW,EAAE,MAAM,CAAC,WAAW,IAAI,EAAE,EAAE,CAAA;QAExD,EAAE,CAAC,GAAG,IAAI,KAAK,QAAQ,CAAC,MAAM,YAAY,QAAQ,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,EAAE,GAAG,EAAE;YAClF,MAAM,IAAI,GAAG,QAAQ;iBAClB,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC;iBACxB,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,iBAAiB,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAA;YACrD,MAAM,CACJ,IAAI,EACJ,GAAG,IAAI,kDAAkD,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAC3E,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;QACf,CAAC,CAAC,CAAA;IACJ,CAAC;AACH,CAAC,CAAC,CAAA"}
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Every static route pattern `AdminRoot.renderView()` resolves via a literal
3
+ * `matchRoute('…')` call. Dynamic collection routes are derived per-config in
4
+ * {@link collectionRoutePatterns}; the SEO deep paths are added from the view's
5
+ * own {@link SEO_DEEP_PATHS} so the two can't drift.
6
+ */
7
+ export declare const ADMIN_STATIC_ROUTE_PATTERNS: readonly string[];
8
+ /**
9
+ * Slugs AdminRoot reserves so a consumer- or plugin-defined collection can't
10
+ * own the top-level `/<slug>` route (they stay reachable at `/collections/<slug>`).
11
+ * Mirrors `RESERVED_SLUGS` in AdminRoot.
12
+ */
13
+ export declare const RESERVED_COLLECTION_SLUGS: ReadonlySet<string>;
14
+ interface CollectionLike {
15
+ slug?: string;
16
+ type?: string;
17
+ }
18
+ interface ConfigLike {
19
+ collections?: Record<string, CollectionLike | undefined>;
20
+ }
21
+ /**
22
+ * Match a concrete path against a route pattern using AdminRoot's `matchRoute`
23
+ * rules: equal segment count, and a `:param` segment matches any single
24
+ * non-empty segment. Query string and hash are ignored.
25
+ */
26
+ export declare function matchesRoutePattern(path: string, pattern: string): boolean;
27
+ /**
28
+ * The collection routes AdminRoot derives for a config: the always-available
29
+ * generic `/collections/<slug>`, plus the top-level `/<slug>` list route for
30
+ * each non-reserved collection. Mirrors AdminRoot's `collectionMap` (keyed by
31
+ * `def.slug`, slug-less collections skipped).
32
+ */
33
+ export declare function collectionRoutePatterns(config: ConfigLike | undefined): string[];
34
+ /** Every route pattern AdminRoot can resolve under `config`. */
35
+ export declare function adminRoutePatterns(config?: ConfigLike): string[];
36
+ /**
37
+ * True when `path` resolves to a real admin view (i.e. is NOT the 404 fallback)
38
+ * under `config`. This is the contract every plugin `adminNavItems` path must
39
+ * satisfy — see the `plugin-nav-resolves` test.
40
+ */
41
+ export declare function adminPathResolves(path: string, config?: ConfigLike): boolean;
42
+ export {};
43
+ //# sourceMappingURL=admin-routes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-routes.d.ts","sourceRoot":"","sources":["../../src/router/admin-routes.ts"],"names":[],"mappings":"AAgBA;;;;;GAKG;AACH,eAAO,MAAM,2BAA2B,EAAE,SAAS,MAAM,EA0CxD,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,yBAAyB,EAAE,WAAW,CAAC,MAAM,CAexD,CAAA;AAEF,UAAU,cAAc;IACtB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AACD,UAAU,UAAU;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,GAAG,SAAS,CAAC,CAAA;CACzD;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAW1E;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,UAAU,GAAG,SAAS,GAAG,MAAM,EAAE,CAShF;AAED,gEAAgE;AAChE,wBAAgB,kBAAkB,CAAC,MAAM,CAAC,EAAE,UAAU,GAAG,MAAM,EAAE,CAEhE;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,UAAU,GAAG,OAAO,CAE5E"}
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The set of admin routes `AdminRoot.renderView()` can resolve, expressed as
3
+ * data so callers other than the render tree can ask "does this path lead to a
4
+ * real view, or to the 404 fallback?".
5
+ *
6
+ * This exists to guard a recurring class of bug: a plugin advertises an
7
+ * `adminNavItems` path (e.g. `/ai/scores`) that the router has no route for, so
8
+ * the sidebar link lands on the 404 page. Plugins cannot register views — the
9
+ * only destinations that exist are the built-in routes below and the per-config
10
+ * collection routes — so a plugin path is valid only if it resolves here. The
11
+ * `plugin-nav-resolves` test asserts exactly that for every bundled plugin, and
12
+ * the `admin-routes` drift test keeps `ADMIN_STATIC_ROUTE_PATTERNS` in sync with
13
+ * the literal `matchRoute('…')` calls in AdminRoot.
14
+ */
15
+ import { SEO_DEEP_PATHS } from '../views/SEO.js';
16
+ /**
17
+ * Every static route pattern `AdminRoot.renderView()` resolves via a literal
18
+ * `matchRoute('…')` call. Dynamic collection routes are derived per-config in
19
+ * {@link collectionRoutePatterns}; the SEO deep paths are added from the view's
20
+ * own {@link SEO_DEEP_PATHS} so the two can't drift.
21
+ */
22
+ export const ADMIN_STATIC_ROUTE_PATTERNS = [
23
+ '/',
24
+ '/api-keys',
25
+ '/collections/:slug',
26
+ '/collections/:slug/:id',
27
+ '/collections/:slug/new',
28
+ '/forgot-password',
29
+ '/forms',
30
+ '/forms/:id/edit',
31
+ '/forms/:id/submissions',
32
+ '/forms/entries',
33
+ '/forms/new',
34
+ '/media',
35
+ '/page-builder/:id',
36
+ '/page-builder/new',
37
+ '/page-templates',
38
+ '/pages',
39
+ '/pages/:id',
40
+ '/pages/:id/edit',
41
+ '/pages/:id/preview',
42
+ '/pages/new',
43
+ '/pages/tags',
44
+ '/pages/tags/:id/edit',
45
+ '/pages/tags/new',
46
+ '/posts',
47
+ '/posts/:type/:id/edit',
48
+ '/posts/:type/:id/preview',
49
+ '/posts/:type/new',
50
+ '/posts/new',
51
+ '/posts/types',
52
+ '/posts/types/:slug/edit',
53
+ '/posts/types/:slug/template',
54
+ '/posts/types/new',
55
+ '/profile',
56
+ '/reset-password',
57
+ '/saved-sections',
58
+ '/script-tags',
59
+ '/script-tags/:id',
60
+ '/script-tags/new',
61
+ '/settings',
62
+ '/users',
63
+ '/widgets',
64
+ ];
65
+ /**
66
+ * Slugs AdminRoot reserves so a consumer- or plugin-defined collection can't
67
+ * own the top-level `/<slug>` route (they stay reachable at `/collections/<slug>`).
68
+ * Mirrors `RESERVED_SLUGS` in AdminRoot.
69
+ */
70
+ export const RESERVED_COLLECTION_SLUGS = new Set([
71
+ 'media',
72
+ 'forms',
73
+ 'seo',
74
+ 'script-tags',
75
+ 'saved-sections',
76
+ 'page-templates',
77
+ 'widgets',
78
+ 'users',
79
+ 'settings',
80
+ 'profile',
81
+ 'page-builder',
82
+ 'collections',
83
+ 'pages',
84
+ 'posts',
85
+ ]);
86
+ /**
87
+ * Match a concrete path against a route pattern using AdminRoot's `matchRoute`
88
+ * rules: equal segment count, and a `:param` segment matches any single
89
+ * non-empty segment. Query string and hash are ignored.
90
+ */
91
+ export function matchesRoutePattern(path, pattern) {
92
+ const pathParts = path.split('?')[0].split('#')[0].split('/').filter(Boolean);
93
+ const patternParts = pattern.split('/').filter(Boolean);
94
+ if (pattern === '/')
95
+ return pathParts.length === 0;
96
+ if (patternParts.length !== pathParts.length)
97
+ return false;
98
+ for (let i = 0; i < patternParts.length; i++) {
99
+ const pp = patternParts[i];
100
+ if (pp.startsWith(':'))
101
+ continue;
102
+ if (pp !== pathParts[i])
103
+ return false;
104
+ }
105
+ return true;
106
+ }
107
+ /**
108
+ * The collection routes AdminRoot derives for a config: the always-available
109
+ * generic `/collections/<slug>`, plus the top-level `/<slug>` list route for
110
+ * each non-reserved collection. Mirrors AdminRoot's `collectionMap` (keyed by
111
+ * `def.slug`, slug-less collections skipped).
112
+ */
113
+ export function collectionRoutePatterns(config) {
114
+ const out = [];
115
+ for (const def of Object.values(config?.collections ?? {})) {
116
+ const slug = def?.slug;
117
+ if (!slug)
118
+ continue;
119
+ out.push(`/collections/${slug}`);
120
+ if (!RESERVED_COLLECTION_SLUGS.has(slug))
121
+ out.push(`/${slug}`);
122
+ }
123
+ return out;
124
+ }
125
+ /** Every route pattern AdminRoot can resolve under `config`. */
126
+ export function adminRoutePatterns(config) {
127
+ return [...ADMIN_STATIC_ROUTE_PATTERNS, ...SEO_DEEP_PATHS, ...collectionRoutePatterns(config)];
128
+ }
129
+ /**
130
+ * True when `path` resolves to a real admin view (i.e. is NOT the 404 fallback)
131
+ * under `config`. This is the contract every plugin `adminNavItems` path must
132
+ * satisfy — see the `plugin-nav-resolves` test.
133
+ */
134
+ export function adminPathResolves(path, config) {
135
+ return adminRoutePatterns(config).some((pattern) => matchesRoutePattern(path, pattern));
136
+ }
137
+ //# sourceMappingURL=admin-routes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-routes.js","sourceRoot":"","sources":["../../src/router/admin-routes.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@actuate-media/cms-admin",
3
- "version": "0.76.4",
3
+ "version": "0.76.6",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -67,8 +67,6 @@
67
67
  "lowlight": "^3.0.0",
68
68
  "lucide-react": "^1.7.0",
69
69
  "qrcode.react": "^4.2.0",
70
- "react": "^19.2.0",
71
- "react-dom": "^19.2.0",
72
70
  "recharts": "^3.8.1",
73
71
  "sonner": "^2.0.7",
74
72
  "tailwind-merge": "^3.5.0",
@@ -79,7 +77,9 @@
79
77
  },
80
78
  "peerDependencies": {
81
79
  "@actuate-media/cms-core": ">=0.1.0",
82
- "@actuate-media/component-blocks": ">=0.1.0"
80
+ "@actuate-media/component-blocks": ">=0.1.0",
81
+ "react": "^18.0.0 || ^19.0.0",
82
+ "react-dom": "^18.0.0 || ^19.0.0"
83
83
  },
84
84
  "peerDependenciesMeta": {
85
85
  "@actuate-media/component-blocks": {
@@ -93,11 +93,20 @@
93
93
  "@types/react-dom": "^19.0.0",
94
94
  "concurrently": "^9.2.1",
95
95
  "happy-dom": "^20.9.0",
96
+ "react": "^19.2.0",
97
+ "react-dom": "^19.2.0",
96
98
  "tailwindcss": "^4.0.0",
97
99
  "typescript": "^5.7.0",
98
100
  "vitest": "^4.1.0",
99
101
  "@actuate-media/component-blocks": "0.2.2",
100
- "@actuate-media/cms-core": "0.93.1"
102
+ "@actuate-media/cms-core": "0.93.4",
103
+ "@actuate-media/plugin-ai": "0.5.4",
104
+ "@actuate-media/plugin-blocks": "0.0.15",
105
+ "@actuate-media/plugin-commerce": "0.1.1",
106
+ "@actuate-media/plugin-media": "0.0.14",
107
+ "@actuate-media/plugin-forms": "0.0.15",
108
+ "@actuate-media/plugin-seo": "0.0.17",
109
+ "@actuate-media/plugin-navigation": "0.0.14"
101
110
  },
102
111
  "scripts": {
103
112
  "build": "tsc --project tsconfig.json && npx @tailwindcss/cli -i src/styles/build-input.css -o dist/actuate-admin.css --minify",
@@ -61,4 +61,30 @@ describe('buildNavItems — duplicate / grouping regressions', () => {
61
61
  expect(items.find((i) => i.path === '/seo/sitemap')?.group).toBe('Content')
62
62
  expect(items.find((i) => i.path === '/seo/link-health')?.group).toBe('Content')
63
63
  })
64
+
65
+ it('excludes admin.hidden collections and does not resurrect removed nav paths', () => {
66
+ // Post-fix reality: plugin-forms/plugin-redirects no longer advertise
67
+ // Submissions/Redirects nav items, and their form-submissions/redirects
68
+ // collections are admin.hidden. Neither should surface a (404-ing) nav path.
69
+ const items = buildNavItems({
70
+ collections: {
71
+ pages: { slug: 'pages', type: 'page', labels: { plural: 'Pages' } },
72
+ forms: { slug: 'forms', labels: { plural: 'Forms' }, admin: { group: 'Forms' } },
73
+ 'form-submissions': {
74
+ slug: 'form-submissions',
75
+ labels: { plural: 'Form Submissions' },
76
+ admin: { hidden: true },
77
+ },
78
+ redirects: { slug: 'redirects', labels: { plural: 'Redirects' }, admin: { hidden: true } },
79
+ },
80
+ _pluginNavItems: [
81
+ { label: 'Forms', icon: 'FileText', path: '/forms' },
82
+ { label: 'SEO', icon: 'search', path: '/seo' },
83
+ ],
84
+ })
85
+ const paths = items.map((i) => i.path)
86
+ expect(paths).not.toContain('/form-submissions')
87
+ expect(paths).not.toContain('/redirects')
88
+ expect(items.filter((i) => i.path === '/forms')).toHaveLength(1)
89
+ })
64
90
  })
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import {
5
+ ADMIN_STATIC_ROUTE_PATTERNS,
6
+ adminPathResolves,
7
+ matchesRoutePattern,
8
+ collectionRoutePatterns,
9
+ } from '../../router/admin-routes.js'
10
+
11
+ describe('matchesRoutePattern', () => {
12
+ it('matches by equal segment count with :param wildcards', () => {
13
+ expect(matchesRoutePattern('/pages', '/pages')).toBe(true)
14
+ expect(matchesRoutePattern('/pages/abc123/edit', '/pages/:id/edit')).toBe(true)
15
+ expect(matchesRoutePattern('/', '/')).toBe(true)
16
+ })
17
+
18
+ it('rejects a mismatched segment count or literal', () => {
19
+ expect(matchesRoutePattern('/pages/abc', '/pages')).toBe(false)
20
+ expect(matchesRoutePattern('/ai/scores', '/pages/:id')).toBe(false)
21
+ expect(matchesRoutePattern('/', '/pages')).toBe(false)
22
+ })
23
+
24
+ it('ignores query string and hash', () => {
25
+ expect(matchesRoutePattern('/seo?tab=audit', '/seo')).toBe(true)
26
+ })
27
+ })
28
+
29
+ describe('adminPathResolves', () => {
30
+ it('resolves built-in routes and SEO deep paths', () => {
31
+ expect(adminPathResolves('/media')).toBe(true)
32
+ expect(adminPathResolves('/forms')).toBe(true)
33
+ expect(adminPathResolves('/seo')).toBe(true)
34
+ expect(adminPathResolves('/seo/link-health')).toBe(true)
35
+ })
36
+
37
+ it('does NOT resolve a path with no backing route (the plugin-nav bug)', () => {
38
+ expect(adminPathResolves('/ai/scores')).toBe(false)
39
+ expect(adminPathResolves('/commerce/products')).toBe(false)
40
+ expect(adminPathResolves('/blocks')).toBe(false)
41
+ })
42
+
43
+ it('resolves a non-reserved collection at /<slug> and /collections/<slug>', () => {
44
+ const config = { collections: { products: { slug: 'products' } } }
45
+ expect(adminPathResolves('/products', config)).toBe(true)
46
+ expect(adminPathResolves('/collections/products', config)).toBe(true)
47
+ // Without the collection, the top-level route does not exist.
48
+ expect(adminPathResolves('/products')).toBe(false)
49
+ })
50
+
51
+ it('does not give a reserved-slug collection a shadowing top-level route', () => {
52
+ const patterns = collectionRoutePatterns({ collections: { pages: { slug: 'pages' } } })
53
+ expect(patterns).toContain('/collections/pages')
54
+ expect(patterns).not.toContain('/pages')
55
+ })
56
+ })
57
+
58
+ /**
59
+ * Drift guard: every literal `matchRoute('…')` in AdminRoot must be represented
60
+ * in ADMIN_STATIC_ROUTE_PATTERNS, so `adminPathResolves` can't silently fall
61
+ * behind the real router (which would let a broken plugin nav path pass review).
62
+ * Dynamic `matchRoute(`/${slug}/…`)` template calls and iterated SEO paths are
63
+ * intentionally excluded — those are covered by collection/SEO patterns.
64
+ */
65
+ describe('ADMIN_STATIC_ROUTE_PATTERNS stays in sync with AdminRoot', () => {
66
+ it('covers every literal matchRoute() call in AdminRoot', () => {
67
+ const src = readFileSync(fileURLToPath(new URL('../../AdminRoot.tsx', import.meta.url)), 'utf8')
68
+ const literals = [...src.matchAll(/matchRoute\(\s*'([^']+)'\s*\)/g)].map((m) => m[1]!)
69
+ expect(literals.length).toBeGreaterThan(20)
70
+ const known = new Set(ADMIN_STATIC_ROUTE_PATTERNS)
71
+ const missing = [...new Set(literals)].filter((p) => !known.has(p))
72
+ expect(missing).toEqual([])
73
+ })
74
+ })
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { aiPlugin } from '@actuate-media/plugin-ai'
3
+ import { blocksPlugin } from '@actuate-media/plugin-blocks'
4
+ import { commercePlugin } from '@actuate-media/plugin-commerce'
5
+ import { formsPlugin } from '@actuate-media/plugin-forms'
6
+ import { mediaPlugin } from '@actuate-media/plugin-media'
7
+ import { navigationPlugin } from '@actuate-media/plugin-navigation'
8
+ import { seoPlugin } from '@actuate-media/plugin-seo'
9
+ import { adminPathResolves } from '../../router/admin-routes.js'
10
+
11
+ /**
12
+ * Systemic guard for the recurring plugin-nav-404 class: a plugin advertises an
13
+ * `adminNavItems` path the admin router has no route for, so the sidebar link
14
+ * lands on the 404 page. It has shipped in published packages more than once
15
+ * (plugin-ai / plugin-commerce / plugin-blocks). Plugins cannot register views,
16
+ * so every nav path MUST resolve to a built-in route or one of the plugin's own
17
+ * collection routes — asserted here for every bundled plugin.
18
+ *
19
+ * When this fails: either point the nav item at a real route (a built-in, or a
20
+ * `/<slug>` for a collection the plugin registers) or remove it until a plugin
21
+ * view API exists. Do NOT add the path to ADMIN_STATIC_ROUTE_PATTERNS unless
22
+ * AdminRoot actually renders a view for it.
23
+ */
24
+ const BUNDLED_PLUGINS = [
25
+ { name: 'plugin-ai', plugin: aiPlugin() },
26
+ { name: 'plugin-blocks', plugin: blocksPlugin() },
27
+ { name: 'plugin-commerce', plugin: commercePlugin() },
28
+ { name: 'plugin-forms', plugin: formsPlugin() },
29
+ { name: 'plugin-media', plugin: mediaPlugin() },
30
+ { name: 'plugin-navigation', plugin: navigationPlugin() },
31
+ { name: 'plugin-seo', plugin: seoPlugin() },
32
+ ]
33
+
34
+ describe('every bundled plugin adminNavItems path resolves to a real admin view', () => {
35
+ for (const { name, plugin } of BUNDLED_PLUGINS) {
36
+ const navItems = plugin.adminNavItems ?? []
37
+ // A config exposing exactly this plugin's collections — the only extra
38
+ // destinations a plugin can legitimately point its nav at.
39
+ const config = { collections: plugin.collections ?? {} }
40
+
41
+ it(`${name} (${navItems.length} nav item${navItems.length === 1 ? '' : 's'})`, () => {
42
+ const dead = navItems
43
+ .map((item) => item.path)
44
+ .filter((path) => !adminPathResolves(path, config))
45
+ expect(
46
+ dead,
47
+ `${name} advertises sidebar paths with no admin route: ${dead.join(', ')}`,
48
+ ).toEqual([])
49
+ })
50
+ }
51
+ })
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The set of admin routes `AdminRoot.renderView()` can resolve, expressed as
3
+ * data so callers other than the render tree can ask "does this path lead to a
4
+ * real view, or to the 404 fallback?".
5
+ *
6
+ * This exists to guard a recurring class of bug: a plugin advertises an
7
+ * `adminNavItems` path (e.g. `/ai/scores`) that the router has no route for, so
8
+ * the sidebar link lands on the 404 page. Plugins cannot register views — the
9
+ * only destinations that exist are the built-in routes below and the per-config
10
+ * collection routes — so a plugin path is valid only if it resolves here. The
11
+ * `plugin-nav-resolves` test asserts exactly that for every bundled plugin, and
12
+ * the `admin-routes` drift test keeps `ADMIN_STATIC_ROUTE_PATTERNS` in sync with
13
+ * the literal `matchRoute('…')` calls in AdminRoot.
14
+ */
15
+ import { SEO_DEEP_PATHS } from '../views/SEO.js'
16
+
17
+ /**
18
+ * Every static route pattern `AdminRoot.renderView()` resolves via a literal
19
+ * `matchRoute('…')` call. Dynamic collection routes are derived per-config in
20
+ * {@link collectionRoutePatterns}; the SEO deep paths are added from the view's
21
+ * own {@link SEO_DEEP_PATHS} so the two can't drift.
22
+ */
23
+ export const ADMIN_STATIC_ROUTE_PATTERNS: readonly string[] = [
24
+ '/',
25
+ '/api-keys',
26
+ '/collections/:slug',
27
+ '/collections/:slug/:id',
28
+ '/collections/:slug/new',
29
+ '/forgot-password',
30
+ '/forms',
31
+ '/forms/:id/edit',
32
+ '/forms/:id/submissions',
33
+ '/forms/entries',
34
+ '/forms/new',
35
+ '/media',
36
+ '/page-builder/:id',
37
+ '/page-builder/new',
38
+ '/page-templates',
39
+ '/pages',
40
+ '/pages/:id',
41
+ '/pages/:id/edit',
42
+ '/pages/:id/preview',
43
+ '/pages/new',
44
+ '/pages/tags',
45
+ '/pages/tags/:id/edit',
46
+ '/pages/tags/new',
47
+ '/posts',
48
+ '/posts/:type/:id/edit',
49
+ '/posts/:type/:id/preview',
50
+ '/posts/:type/new',
51
+ '/posts/new',
52
+ '/posts/types',
53
+ '/posts/types/:slug/edit',
54
+ '/posts/types/:slug/template',
55
+ '/posts/types/new',
56
+ '/profile',
57
+ '/reset-password',
58
+ '/saved-sections',
59
+ '/script-tags',
60
+ '/script-tags/:id',
61
+ '/script-tags/new',
62
+ '/settings',
63
+ '/users',
64
+ '/widgets',
65
+ ]
66
+
67
+ /**
68
+ * Slugs AdminRoot reserves so a consumer- or plugin-defined collection can't
69
+ * own the top-level `/<slug>` route (they stay reachable at `/collections/<slug>`).
70
+ * Mirrors `RESERVED_SLUGS` in AdminRoot.
71
+ */
72
+ export const RESERVED_COLLECTION_SLUGS: ReadonlySet<string> = new Set([
73
+ 'media',
74
+ 'forms',
75
+ 'seo',
76
+ 'script-tags',
77
+ 'saved-sections',
78
+ 'page-templates',
79
+ 'widgets',
80
+ 'users',
81
+ 'settings',
82
+ 'profile',
83
+ 'page-builder',
84
+ 'collections',
85
+ 'pages',
86
+ 'posts',
87
+ ])
88
+
89
+ interface CollectionLike {
90
+ slug?: string
91
+ type?: string
92
+ }
93
+ interface ConfigLike {
94
+ collections?: Record<string, CollectionLike | undefined>
95
+ }
96
+
97
+ /**
98
+ * Match a concrete path against a route pattern using AdminRoot's `matchRoute`
99
+ * rules: equal segment count, and a `:param` segment matches any single
100
+ * non-empty segment. Query string and hash are ignored.
101
+ */
102
+ export function matchesRoutePattern(path: string, pattern: string): boolean {
103
+ const pathParts = path.split('?')[0]!.split('#')[0]!.split('/').filter(Boolean)
104
+ const patternParts = pattern.split('/').filter(Boolean)
105
+ if (pattern === '/') return pathParts.length === 0
106
+ if (patternParts.length !== pathParts.length) return false
107
+ for (let i = 0; i < patternParts.length; i++) {
108
+ const pp = patternParts[i]!
109
+ if (pp.startsWith(':')) continue
110
+ if (pp !== pathParts[i]) return false
111
+ }
112
+ return true
113
+ }
114
+
115
+ /**
116
+ * The collection routes AdminRoot derives for a config: the always-available
117
+ * generic `/collections/<slug>`, plus the top-level `/<slug>` list route for
118
+ * each non-reserved collection. Mirrors AdminRoot's `collectionMap` (keyed by
119
+ * `def.slug`, slug-less collections skipped).
120
+ */
121
+ export function collectionRoutePatterns(config: ConfigLike | undefined): string[] {
122
+ const out: string[] = []
123
+ for (const def of Object.values(config?.collections ?? {})) {
124
+ const slug = def?.slug
125
+ if (!slug) continue
126
+ out.push(`/collections/${slug}`)
127
+ if (!RESERVED_COLLECTION_SLUGS.has(slug)) out.push(`/${slug}`)
128
+ }
129
+ return out
130
+ }
131
+
132
+ /** Every route pattern AdminRoot can resolve under `config`. */
133
+ export function adminRoutePatterns(config?: ConfigLike): string[] {
134
+ return [...ADMIN_STATIC_ROUTE_PATTERNS, ...SEO_DEEP_PATHS, ...collectionRoutePatterns(config)]
135
+ }
136
+
137
+ /**
138
+ * True when `path` resolves to a real admin view (i.e. is NOT the 404 fallback)
139
+ * under `config`. This is the contract every plugin `adminNavItems` path must
140
+ * satisfy — see the `plugin-nav-resolves` test.
141
+ */
142
+ export function adminPathResolves(path: string, config?: ConfigLike): boolean {
143
+ return adminRoutePatterns(config).some((pattern) => matchesRoutePattern(path, pattern))
144
+ }