diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Hub.tsx b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Hub.tsx index b5fcb315..f2d34020 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Hub.tsx +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/Hub.tsx @@ -124,7 +124,19 @@ function CardIcon({ icon }: { icon: string }) { ); } -export default function Hub() { +interface HubProps { + availableUrls?: string[]; +} + +export default function Hub({ availableUrls }: HubProps) { + const available = new Set((availableUrls ?? []).map((u) => u.toLowerCase())); + const visibleGroups = groups + .map((group) => ({ + ...group, + items: group.items.filter((i) => available.has(i.url.toLowerCase())), + })) + .filter((group) => group.items.length > 0); + return (
- {groups.map((group) => ( + {visibleGroups.map((group) => (

{group.title} diff --git a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs index d388e112..8e24e868 100644 --- a/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs +++ b/modules/Admin/src/SimpleModule.Admin/Pages/Admin/HubEndpoint.cs @@ -10,9 +10,54 @@ public class HubEndpoint : IViewEndpoint { public const string Route = AdminConstants.Routes.Hub; + // Must stay in sync with the `url` values in Hub.tsx's `groups`. URLs not + // present in the app's endpoint table are filtered out so the hub doesn't + // surface 404 tiles for peer modules that aren't installed. + private static readonly string[] CandidateUrls = + [ + "/admin/users", + "/admin/roles", + "/openiddict/clients", + "/tenants/manage", + "/pages/manage", + "/email/templates", + "/email/history", + "/settings/menus", + "/feature-flags/manage", + "/rate-limiting/manage", + "/admin/jobs", + "/audit-logs/browse", + "/settings/manage", + ]; + + private string[]? _availableUrls; + public void Map(IEndpointRouteBuilder app) { - app.MapGet(Route, () => Inertia.Render("Admin/Admin/Hub")) + app.MapGet( + Route, + (EndpointDataSource endpointDataSource) => + { + _availableUrls ??= ComputeAvailableUrls(endpointDataSource); + return Inertia.Render( + "Admin/Admin/Hub", + new { availableUrls = _availableUrls } + ); + } + ) .RequireAuthorization(policy => policy.RequireRole("Admin")); } + + private static string[] ComputeAvailableUrls(EndpointDataSource endpointDataSource) + { + var registered = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var endpoint in endpointDataSource.Endpoints) + { + if (endpoint is RouteEndpoint route) + { + registered.Add("/" + route.RoutePattern.RawText?.TrimStart('/')); + } + } + return CandidateUrls.Where(registered.Contains).ToArray(); + } } diff --git a/packages/SimpleModule.Client/src/define-module-config.ts b/packages/SimpleModule.Client/src/define-module-config.ts index 80a430f0..16d2713e 100644 --- a/packages/SimpleModule.Client/src/define-module-config.ts +++ b/packages/SimpleModule.Client/src/define-module-config.ts @@ -1,9 +1,60 @@ +import { readdirSync, readFileSync } from 'node:fs'; import { basename, resolve } from 'node:path'; import react from '@vitejs/plugin-react'; import type { UserConfig } from 'vite'; import { defineConfig } from 'vite'; import { defaultVendors } from './vite-plugin-vendor.ts'; +/** + * Detect the case-insensitive filesystem trap where `Pages/index.ts` (the + * framework-required barrel) sits next to a page like `Pages/Index.tsx` and + * the barrel uses an extension-less dynamic import (`() => import('./Index')`). + * + * On macOS/Windows, Rolldown resolves `./Index` back to `./index.ts` (the + * barrel itself), producing a self-referential chunk that throws at runtime + * with `Cannot assign to property 'layout' of [object Module]`. + * + * Throw early with a clear, actionable error. + */ +function assertNoBarrelCollision(dir: string): void { + const pagesDir = resolve(dir, 'Pages'); + + let entries: string[]; + try { + entries = readdirSync(pagesDir); + } catch { + return; + } + if (!entries.includes('index.ts')) return; + + const offending = entries.find((f) => /^index\.(tsx|jsx|js)$/i.test(f) && f !== 'index.ts'); + if (!offending) return; + + let barrel: string; + try { + barrel = readFileSync(resolve(pagesDir, 'index.ts'), 'utf8'); + } catch { + return; + } + + // Match extension-less dynamic imports whose specifier case-insensitively + // resolves to the barrel itself (`import('./Index')` next to `index.ts`). + // On case-insensitive filesystems Rolldown silently picks `./index.ts` + // (the barrel) rather than the sibling `./Index.tsx`, producing a chunk + // that re-exports the barrel and crashes at runtime. + if (!/import\(\s*['"]\.\/index['"]\s*\)/i.test(barrel)) return; + + const specifier = offending.replace(/\.(tsx|jsx|js)$/i, ''); + throw new Error( + `[@simplemodule/client] Pages/index.ts contains \`import('./${specifier}')\` which collides ` + + `with the barrel on case-insensitive filesystems (macOS/Windows). Rolldown will silently ` + + `emit a self-referential chunk and the page will fail at runtime with ` + + `"Cannot assign to property 'layout' of [object Module]".\n\n` + + `Fix: use the explicit file extension, e.g. \`import('./${offending}')\`, or rename ` + + `Pages/${offending} to a name that does not case-insensitively match 'index'.`, + ); +} + /** * Unified Vite config for SimpleModule modules. * @@ -20,6 +71,8 @@ import { defaultVendors } from './vite-plugin-vendor.ts'; * ``` */ export function defineModuleConfig(dir: string): UserConfig { + assertNoBarrelCollision(dir); + const name = basename(dir); const isDev = process.env.VITE_MODE !== 'prod';