@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 +39 -0
- package/dist/__tests__/layout/build-nav-items.test.js +25 -0
- package/dist/__tests__/layout/build-nav-items.test.js.map +1 -1
- package/dist/__tests__/router/admin-routes.test.d.ts +2 -0
- package/dist/__tests__/router/admin-routes.test.d.ts.map +1 -0
- package/dist/__tests__/router/admin-routes.test.js +62 -0
- package/dist/__tests__/router/admin-routes.test.js.map +1 -0
- package/dist/__tests__/router/plugin-nav-resolves.test.d.ts +2 -0
- package/dist/__tests__/router/plugin-nav-resolves.test.d.ts.map +1 -0
- package/dist/__tests__/router/plugin-nav-resolves.test.js +46 -0
- package/dist/__tests__/router/plugin-nav-resolves.test.js.map +1 -0
- package/dist/router/admin-routes.d.ts +43 -0
- package/dist/router/admin-routes.d.ts.map +1 -0
- package/dist/router/admin-routes.js +137 -0
- package/dist/router/admin-routes.js.map +1 -0
- package/package.json +14 -5
- package/src/__tests__/layout/build-nav-items.test.ts +26 -0
- package/src/__tests__/router/admin-routes.test.ts +74 -0
- package/src/__tests__/router/plugin-nav-resolves.test.ts +51 -0
- package/src/router/admin-routes.ts +144 -0
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 @@
|
|
|
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 @@
|
|
|
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.
|
|
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.
|
|
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
|
+
}
|