UNPKG

@m5nv/rr-builder

Version:

Fluent API for seamless route & navigation authoring experience in React Router v7 framework mode

700 lines (568 loc) 20.6 kB
# Fluent Route Configuration A tiny, fluent builder API to configure React Router v7 framework mode routes, for a seamless, unified route & navigation authoring experience. > **Single‑source of truth for routes + navigation** ## 1 · Motivation Modern web applications benefit from **colocating navigation metadata with route configuration**—keeping icons, labels, permissions, and UI state together with the routes that use them. This creates a single source of truth that prevents the drift and inconsistencies that can plague growing applications. React Router v7's **framework mode** gives us much needed relief from handicapped file-system based routing mechanisms. The one downside is that it **discards extra route properties**, making colocation impossible. Even if React Router preserved extra metadata, storing complex navigation data in optional route arguments becomes **brittle and unmanageable** as applications scale. `@m5nv/rr-builder` solves this by providing a **fluent, type-safe DSL** for authoring augmented routes with colocated navigational metadata. It provides a tool to generate both React Router configuration and a runtime navigation API that keeps everything perfectly synchronized. Now, you can **author your routes and all navigation metadata in one fluent, type-safe DSL**, and use the generated runtimesafe module for your layout, menu, and navigational UI components. ## 2 · Installation & Platform Support ```bash # npm or pnpm (dev-only; nothing leaks to runtime) npm install -D @m5nv/rr-builder # or pnpm add -D @m5nv/rr-builder ``` > **Peer dependency:** Make sure you have `@react-router/dev` (v7) installed as > a peer dependency: > > ```bash > npm install @react-router/dev > ``` ## 3 · Quick Start & Example ```ts // routes.ts import { build, external, index, layout, prefix, route, section, } from "@m5nv/rr-builder"; export default build( [ // Main section (default) layout("project/layout.tsx").children( route("overview", "project/overview.tsx").nav({ label: "Overview", iconName: "ClipboardList", }), route("settings", "project/settings.tsx").nav({ label: "Settings", iconName: "Settings", id: "project-settings", }) ), // Dedicated admin section section("admin").children( layout("admin/layout.tsx").children( index("admin/dashboard.tsx").nav({ label: "Admin Dashboard", iconName: "Shield", }), route("users", "admin/users.tsx").nav({ label: "User Management", iconName: "Users", }), route("reports", "admin/reports.tsx").nav({ label: "Reports", iconName: "BarChart", id: "admin-reports", }) ) ), // Documentation section with external links section("docs").children( external("https://docs.acme.dev").nav({ label: "API Docs", iconName: "Book", }), external("https://guides.acme.dev").nav({ label: "User Guides", iconName: "HelpCircle", }) ), ], { globalActions: [ { id: "search", label: "Search", iconName: "Search", sections: ["main", "admin"], // Available in main and admin sections }, { id: "feedback", label: "Send Feedback", iconName: "MessageSquare", externalUrl: "https://feedback.acme.dev", }, ], badgeTargets: ["project-settings", "admin-reports"], } ); ``` Generate the runtime helper and nav code: ```bash # using npx (works everywhere) npx rr-check routes.ts --out src/navigation.generated.ts ``` > NOTE: to natively handle `typescript` files you do need the latest version of > `Node.js`; or you could use typescript to convert `routes.ts` to JS and then > use `rr-check`. More details below. ## 4 · Fluent Authoring API (with Type Safety) @m5nv/rr-builder provides a type-safe, fluent builder DSL so only legal route/navigation structures are possible: ### Route & Layout Builders - `route(...)` and `layout(...)` return builders with `.children()` and `.nav()`. - Both support all navigation metadata fields. - Only `route` and `layout` may have children. ### Index & External Builders - `index(...)` and `external(...)` **do not support** `.children()` (type error). - Both support `.nav()`, with all fields allowed (except `external()` auto-sets `external: true`). - `external(...)` **cannot** be passed to `prefix()`. ### Section Builder - `section(name)` creates a navigation section for organizing routes into separate trees. - **Sections can only be used at the top level** of `build()` - they cannot be nested within other routes or sections. - Section builders support `.nav()` (without the `section` field) and `.children()`. - A section's `.children()` can accept any non-section builder (`route`, `layout`, `index`, `external`). ### Prefix Builder - `prefix(path, [builders...])` nests several builders under a path segment. - Only `route`, `layout`, and `index` builders are allowed; `external()` and `section()` are disallowed by type. #### Meta Shape (passed to `.nav()`) The `.nav()` method lets you attach navigation and UI metadata directly to your route definitions—making your navigation menus, sidebars, toolbars, and breadcrumbs entirely data-driven, consistent, and easy to extend. **Each field serves a specific purpose for your navigation or UI layer**: ```ts // Navigation metadata for all builders (external flag is set automatically) type NavMeta = { /** * Human‑friendly display name for this route/menu entry. * Always provide a label for anything visible in navigation. */ label: string; /** * Icon to show beside label in menus and sidebars. * Typically the string name from lucide-react or your icon set. */ iconName?: string; /** * Used to sort menu items within a group or section. * Lower numbers appear first. Defaults to 0 if omitted. */ order?: number; /** * Used for grouping related routes into submenus, tab panels, * or logical clusters within a section (e.g. "Settings", "Billing" tabs). */ group?: string; /** * List of keywords for search/filtering or for applying styles/logic * to groups of routes (e.g. tags: ["admin", "beta"]). */ tags?: string[]; /** * If true, hides this item from all navigation UI, * but leaves the route itself active and linkable. * Useful for "secret"/unlinked pages, or wizard steps. */ hidden?: boolean; /** * If true, menu highlighting should only occur on an exact path match, * not for descendant routes. Useful for "Home" or main dashboard links. */ end?: boolean; /** * Arbitrary access control claims for runtime ABAC or RBAC. * Can be used to hide/show nav entries based on permissions. */ abac?: string | string[]; /** * List of per-route/contextual actions. These surface as menu items, * action buttons, or FABs—e.g. "Create", "Export", "Refresh". * UI code can render them as dropdowns, icon buttons, or toolbars. */ actions?: Array<{ id: string; label: string; iconName?: string }>; /** * Marks this as an external (off-site) link. * This flag is set automatically by `external()` and cannot be set manually. * UI code can use this to render with special icons or open in a new tab. */ external?: true; // Set automatically, not manually }; // All builders use: Omit<NavMeta, 'external'> for their .nav() method // The external flag is only set automatically by the external() builder ``` #### Global Actions & Badge Targets Beyond per-route metadata, you can define **global actions** and **badge targets** that apply across your entire application: ```ts // In your build() call export default build( [ // ... your routes ], { globalActions: [ { id: "search", label: "Global Search", iconName: "Search", sections: ["main", "admin"], // Only show in these sections }, { id: "help", label: "Help Center", iconName: "HelpCircle", externalUrl: "https://help.acme.dev", // External link }, { id: "create-project", label: "New Project", iconName: "Plus", // No sections = available everywhere }, ], badgeTargets: [ "notifications", // Route IDs that can show badges "admin-users", "reports-monthly", "global-search", // Action IDs that can show badges ], } ); ``` **Global Actions** are app-wide commands that appear in your navigation UI regardless of the current route. They can: - Be scoped to specific sections via the `sections` array - Link to external URLs via `externalUrl` - Trigger application logic when clicked (handled by your UI code) **Badge Targets** are route IDs or action IDs that can display notification badges, counters, or status indicators in your navigation UI. #### Type Safety at a Glance - `.children(...)` only available on `route()`, `layout()`, and `section()`. - `section()` builders can only be used at the **top level** of `build()`. - `prefix()` only accepts `route`, `layout`, `index` builders (not `external` or `section`). - `.children(...)` on `index()` or `external()` is a compile-time error. ##### Example Misuse (flagged for type errors) ```ts index("home.tsx").children(route("foo", "foo.tsx")); // ❌ error external("http://foo").children(route("foo", "foo.tsx")); // ❌ error section("admin").children(section("nested")); // ❌ error prefix("docs", [external("https://foo.dev")]); // ❌ error prefix("api", [section("admin")]); // ❌ error // This is also an error - sections can only be at top level route("admin", "admin.tsx").children( section("users") // ❌ error ); ``` ## 5 · Section-Based Navigation Architecture The `section()` builder enables you to organize your application into distinct navigation trees, perfect for: - **Monorepo applications**: A single repo serving multiple webapps (docs, dashboard, ...) - **Role-based interfaces**: Different navigation for different user types - **Feature modules**: Isolate documentation, settings, or tool sections - **Progressive disclosure**: Show simpler navigation to new users ### Section Examples ```ts export default build([ // Main application routes layout("app/layout.tsx").children( index("app/dashboard.tsx").nav({ label: "Dashboard" }), route("projects", "app/projects.tsx").nav({ label: "Projects" }) ), // Admin section with its own navigation tree section("admin") .nav({ label: "Administration", iconName: "Shield" }) .children( layout("admin/layout.tsx").children( index("admin/overview.tsx").nav({ label: "Admin Overview" }), route("users", "admin/users.tsx").nav({ label: "Users" }), route("settings", "admin/settings.tsx").nav({ label: "Settings" }) ) ), // Documentation section section("docs") .nav({ label: "Documentation", iconName: "Book" }) .children( external("https://api-docs.acme.dev").nav({ label: "API Reference" }), external("https://guides.acme.dev").nav({ label: "User Guides" }) ), ]); ``` Your generated navigation API will provide section-aware methods: ```tsx // Get all available sections const sections = nav.sections(); // ["main", "admin", "docs"] // Get routes for a specific section const adminRoutes = nav.routes("admin"); const mainRoutes = nav.routes("main"); // or nav.routes() for default ``` ## 6 · CLI & Code Generation (`rr-check`) Check your routes, visualize trees, and generate navigation helpers for runtime. ### Usage ```bash npx rr-check <routes-file> [--print:<flags>] [--out <file>] [--watch] ``` **Flags:** | Flag | Effect | | ----------------- | ---------------------------------------------------------- | | `--out <file>` | Where to emit the navigation helper module | | `--force` | Code-gen even if duplicate IDs or missing files are found | | `--print:<flags>` | Comma-list: route-tree, nav-tree, include-id, include-path | | `--watch` | Watch for file changes and regenerate automatically | The generated module exports: ```ts // Wire up RR export function registerRouter(adapter: RouterAdapter): void; // Data model and utility functions export type NavigationApi = { /** list of section names present in the forest of trees */ sections(): string[]; /* pure selectors – NO runtime context */ routes(section?: string): NavTreeNode[]; routesByTags(section: string, tags: string[]): NavTreeNode[]; routesByGroup(section: string, group: string): NavTreeNode[]; /* convenience hook that hydrates results returned by adapter.useMatches */ useHydratedMatches: <T = unknown>() => Array<{ handle: NavMeta }>; /* static extras */ globalActions: GlobalActionSpec[]; badgeTargets: string[]; /* router adapter injected at factory time */ router: RouterAdapter; }; ``` ### Typescript support You can run the codegen/CLI tool with Deno for `.ts` route files: ```bash deno run --unstable-sloppy-imports --allow-read ./node_modules/@m5nv/rr-builder/src/rr-check.js routes.ts ``` Or, use the latest Node.js (> v23.6.0): ```bash node ./node_modules/@m5nv/rr-builder/src/rr-check.js routes.ts ``` ### Typical error and output ```bash npx rr-check routes.ts --print:nav-tree,include-id ⚠️ Found 1 duplicate route ID ⚠️ Found 6 missing component files ... ├── Home(*!) [id: foo] ├── Settings(!) [id: routes/settings/page] └── Overview(*!) [id: foo] └── Annual(!) [id: routes/dashboard/reports/annual] ``` ## 7 · Using the Generated Navigation Module in Your UI ### Register the Router Adapter ```tsx import { Link, matchPath, useLocation, useMatches } from "react-router"; import nav, { registerRouter } from "@/navigation.generated"; /// one-time registration registerRouter({ Link, useLocation, useMatches, matchPath }); ``` ### Rendering Section-Based Navigation ```tsx function AppNavigation() { const sections = nav.sections(); return ( <nav> {sections.map((sectionName) => ( <section key={sectionName}> <h3>{sectionName}</h3> <NavigationSection section={sectionName} /> </section> ))} </nav> ); } function NavigationSection({ section }) { const items = nav.routes(section); return ( <ul> {items.map((item) => ( <li key={item.id}> <nav.router.Link to={item.path}> {item.iconName && <Icon name={item.iconName} />} {item.label} </nav.router.Link> </li> ))} </ul> ); } ``` ### Global Actions & Badges ```tsx function GlobalActionBar() { const actions = nav.globalActions; const currentSection = getCurrentSection(); // Your logic const availableActions = actions.filter( (action) => !action.sections || action.sections.includes(currentSection) ); return ( <div className="action-bar"> {availableActions.map((action) => ( <ActionButton key={action.id} action={action} hasBadge={nav.badgeTargets.includes(action.id)} /> ))} </div> ); } function ActionButton({ action, hasBadge }) { const handleClick = () => { if (action.externalUrl) { window.open(action.externalUrl, "_blank"); } else { // Dispatch your custom action dispatchAction(action.id); } }; return ( <button onClick={handleClick} className="action-button"> {action.iconName && <Icon name={action.iconName} />} {action.label} {hasBadge && <Badge />} </button> ); } ``` ### Dynamic Layouts with Hydrated Matches ```tsx import { Outlet } from "react-router"; import { useHydratedMatches } from "./navigation.generated"; export default function ContentLayout() { const matches = useHydratedMatches(); const match = matches.at(-1); let { label, iconName, actions } = match?.handle ?? { label: "Unknown", iconName: "Help", actions: [], }; return ( <article> <header> <h2> <Icon name={iconName} /> {label} </h2> {actions && ( <div className="route-actions"> {actions.map((action) => ( <button key={action.id} onClick={() => handleAction(action.id)}> {action.iconName && <Icon name={action.iconName} />} {action.label} </button> ))} </div> )} </header> <section> <Outlet /> </section> </article> ); } ``` ## 8 · Concepts & Best Practices ### Route vs Layout vs Section - **`route()`**: Owns a URL segment (`path`), can render its file and children. - **`layout()`**: Pure wrapper with children, no path. - **`section()`**: Top-level container that creates separate navigation trees. ### Index Routes - Defined with `index(file)`; rendered at the parent path. ### Prefixing - Use `prefix(path, builders[])` to DRY up grouped path segments. - Cannot include `section()` or `external()` builders. ### Section Organization - Use sections to create distinct areas of your application - Each section gets its own navigation tree - Perfect for admin areas, documentation, or feature modules - Sections cannot be nested - they're always top-level ### Shared Layouts vs Individual Sections **Use `sharedLayout()`** when: - Multiple sections share the same root layout (app shell, navigation, header) - You want to eliminate repetitive layout nesting - You have a common UI structure across different app areas **Use individual `section()`** when: - Each section has completely different layout requirements - Sections are truly independent with no shared UI - You need maximum flexibility in structure ```ts // Shared layout - common app shell ...sharedLayout("layouts/app-shell.tsx", { "dashboard": dashboardRoutes, "admin": adminRoutes, }) // vs Individual sections - different layouts section("public").children( layout("layouts/marketing.tsx").children(...) ), section("app").children( layout("layouts/authenticated.tsx").children(...) ), ``` ### Actions & Global Actions - **Route actions**: Contextual commands specific to a route (Create, Edit, Delete) - **Global actions**: App-wide commands available across sections (Search, Help, New) - Actions are defined in metadata but wired up by your UI code - Use `badgeTargets` to show notifications on specific routes or actions ### Unique IDs If multiple routes use the same file, provide a unique ID: ```ts index("Page.tsx", { id: "users-all" }); route("active", "Page.tsx", { id: "users-active" }); ``` Now you can switch on `match.id` in your component. ## 9 · Troubleshooting & Migration - **Duplicate IDs**: Only the first is used in navigation trees; fix to avoid ambiguity. - **Missing files**: Shown by the CLI; check spelling or ensure the file exists. - **Type errors in your editor**: Confirm `.children()` usage follows the constraints (no sections except at top level). - **Section nesting errors**: Remember sections can only be used at the top level of `build()`. - **Switching from manual routes**: Replace your raw array with a single `build([...])` call and migrate metadata to `.nav()`. ## 10 · Design notes - **Sections vs. Groups** – _section_ splits the full forest by tree; _group_ clusters within a section. - **External links** – Stay in your menu, but are filtered out before hitting RR config. - **No dev-only deps leak to runtime** – Only the generated module is imported at runtime. - **Type-safety** – The API statically prevents misuse. - **First-class codegen** – We do codegen so your runtime bundle is tree-shakable and never ships builder helpers. - **Actions are declarative** – Define them in route metadata, wire them up in UI code. ## 11 · Roadmap - **ID collision auto‑resolve** – suggestion prompt instead of hard error. - **Docs site** – interactive playground, recipes, FAQ. - **Validate iconName** - iconName against icon libraries such `lucide‑react` at build time and create a `icons.ts` ready for import and use by UI menus and layouts. - **Action validation** - Validate action IDs and provide TypeScript autocompletion. ## License © 2025 Million Views, LLC – Distributed under the MIT License. See [LICENSE](../LICENSE) for details.