Add module loader & registry (task 04)

Implements the extensibility foundation: ModuleManifest/DashboardWidget/EntityTypeRegistration types in _core/module.ts, a plain-Map registry singleton with registerModule/getRegistry/getEntityType/getWidget, and stub manifests for calendar/lists/notes. Root layout imports src/modules/index.ts for side-effect registration; AppNav reads the registry to render nav links. /debug/registry dumps the full registry JSON in dev using zod v4 + z.toJSONSchema(). Adding a fourth module requires one folder + one line in index.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-06 01:52:47 -05:00
co-authored by Claude Sonnet 4.6
parent 37977568ed
commit e237fe8449
13 changed files with 280 additions and 4 deletions
+51
View File
@@ -0,0 +1,51 @@
import { notFound } from "next/navigation";
import { z } from "zod";
import { getRegistry } from "@/modules/_core/registry";
import "@/modules";
export default function RegistryDebugPage() {
if (process.env.NODE_ENV !== "development") {
notFound();
}
const { modules } = getRegistry();
const output = {
modules: modules.map((m) => ({
id: m.id,
name: m.name,
nav: m.nav ?? null,
entities: m.entities.map((e) => ({
type: e.type,
label: e.label,
hasShare: !!e.share,
hasReminder: !!e.reminder,
hasSearch: !!e.search,
})),
dashboardWidgets: (m.dashboardWidgets ?? []).map((w) => ({
id: w.id,
title: w.title,
description: w.description,
category: w.category ?? null,
defaultSize: w.defaultSize,
defaultPriority: w.defaultPriority,
configSchema: z.toJSONSchema(w.configSchema),
defaultConfig: w.defaultConfig,
})),
quickAdds: (m.quickAdds ?? []).map((q) => ({
id: q.id,
label: q.label,
icon: q.icon ?? null,
})),
})),
};
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Module Registry (dev only)</h1>
<pre className="text-xs bg-muted rounded p-4 overflow-auto">
{JSON.stringify(output, null, 2)}
</pre>
</div>
);
}
+7 -2
View File
@@ -2,8 +2,10 @@ import type { Metadata } from "next";
import "./globals.css";
import { Geist } from "next/font/google";
import { cn } from "@/lib/utils";
import { AppNav } from "@/components/app-nav";
import "@/modules"; // registers all module manifests
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
const geist = Geist({ subsets: ["latin"], variable: "--font-sans" });
export const metadata: Metadata = {
title: "famapp",
@@ -17,7 +19,10 @@ export default function RootLayout({
}) {
return (
<html lang="en" className={cn("font-sans", geist.variable)}>
<body>{children}</body>
<body className="min-h-screen">
<AppNav />
<main>{children}</main>
</body>
</html>
);
}
+24
View File
@@ -0,0 +1,24 @@
import Link from "next/link";
import { getRegistry } from "@/modules/_core/registry";
export function AppNav() {
const { modules } = getRegistry();
const navItems = modules.flatMap((m) => (m.nav ? [m.nav] : []));
return (
<nav className="border-b px-4 py-3 flex items-center gap-6">
<Link href="/" className="font-semibold text-sm">
famapp
</Link>
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{item.label}
</Link>
))}
</nav>
);
}
+12
View File
@@ -0,0 +1,12 @@
export type {
ModuleManifest,
EntityTypeRegistration,
DashboardWidget,
QuickAddAction,
WidgetContext,
ShareCapabilities,
ReminderCapabilities,
SearchAdapter,
SearchResult,
} from "./module";
export { registerModule, getRegistry, getEntityType, getWidget } from "./registry";
+68
View File
@@ -0,0 +1,68 @@
import type { ReactNode } from "react";
import type { ZodType } from "zod";
export type ShareCapabilities = {
canShare: boolean;
defaultCapabilities?: string[];
};
export type ReminderCapabilities = {
canRemind: boolean;
};
export type SearchResult = {
id: string;
title: string;
url: string;
excerpt?: string;
};
export type SearchAdapter = {
search: (query: string, householdId: string) => Promise<SearchResult[]>;
};
export type WidgetContext = {
userId: string;
householdId: string;
};
export type DashboardWidget = {
id: string;
title: string;
description: string;
category?: string;
defaultSize: { w: number; h: number };
minSize?: { w: number; h: number };
maxSize?: { w: number; h: number };
defaultPriority: number;
configSchema: ZodType;
defaultConfig: unknown;
resolveConfigOptions?: (ctx: WidgetContext) => Promise<unknown>;
render: (props: { config: unknown; ctx: WidgetContext }) => ReactNode;
};
export type QuickAddAction = {
id: string;
label: string;
icon?: string;
action: (ctx: WidgetContext) => void | Promise<void>;
};
export type EntityTypeRegistration = {
type: string;
label: { singular: string; plural: string };
share?: ShareCapabilities;
reminder?: ReminderCapabilities;
search?: SearchAdapter;
resolveUrl: (id: string) => string;
loadForShare?: (id: string) => Promise<unknown>;
};
export type ModuleManifest = {
id: string;
name: string;
nav?: { href: string; label: string; icon?: string };
entities: EntityTypeRegistration[];
dashboardWidgets?: DashboardWidget[];
quickAdds?: QuickAddAction[];
};
+31
View File
@@ -0,0 +1,31 @@
import type { ModuleManifest, EntityTypeRegistration, DashboardWidget } from "./module";
const modules = new Map<string, ModuleManifest>();
const entityTypes = new Map<string, EntityTypeRegistration>();
const widgets = new Map<string, DashboardWidget>();
export function registerModule(manifest: ModuleManifest): void {
modules.set(manifest.id, manifest);
for (const entity of manifest.entities) {
entityTypes.set(entity.type, entity);
}
for (const widget of manifest.dashboardWidgets ?? []) {
widgets.set(widget.id, widget);
}
}
export function getRegistry() {
return Object.freeze({
modules: Object.freeze([...modules.values()]),
entityTypes: Object.freeze([...entityTypes.values()]),
widgets: Object.freeze([...widgets.values()]),
});
}
export function getEntityType(type: string): EntityTypeRegistration | undefined {
return entityTypes.get(type);
}
export function getWidget(id: string): DashboardWidget | undefined {
return widgets.get(id);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ModuleManifest } from "../_core/module";
const manifest: ModuleManifest = {
id: "calendar",
name: "Calendar",
nav: { href: "/calendar", label: "Calendar", icon: "calendar" },
entities: [
{
type: "calendar.calendar",
label: { singular: "Calendar", plural: "Calendars" },
resolveUrl: (id) => `/calendar?id=${id}`,
},
{
type: "calendar.event",
label: { singular: "Event", plural: "Events" },
resolveUrl: (id) => `/calendar/events/${id}`,
},
],
dashboardWidgets: [],
quickAdds: [],
};
export default manifest;
+8
View File
@@ -0,0 +1,8 @@
import { registerModule } from "./_core/registry";
import calendarManifest from "./calendar/manifest";
import listsManifest from "./lists/manifest";
import notesManifest from "./notes/manifest";
registerModule(calendarManifest);
registerModule(listsManifest);
registerModule(notesManifest);
+23
View File
@@ -0,0 +1,23 @@
import type { ModuleManifest } from "../_core/module";
const manifest: ModuleManifest = {
id: "lists",
name: "Lists",
nav: { href: "/lists", label: "Lists", icon: "list" },
entities: [
{
type: "lists.list",
label: { singular: "List", plural: "Lists" },
resolveUrl: (id) => `/lists/${id}`,
},
{
type: "lists.item",
label: { singular: "List item", plural: "List items" },
resolveUrl: (id) => `/lists/items/${id}`,
},
],
dashboardWidgets: [],
quickAdds: [],
};
export default manifest;
+18
View File
@@ -0,0 +1,18 @@
import type { ModuleManifest } from "../_core/module";
const manifest: ModuleManifest = {
id: "notes",
name: "Notes",
nav: { href: "/notes", label: "Notes", icon: "file-text" },
entities: [
{
type: "notes.note",
label: { singular: "Note", plural: "Notes" },
resolveUrl: (id) => `/notes/${id}`,
},
],
dashboardWidgets: [],
quickAdds: [],
};
export default manifest;