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:
co-authored by
Claude Sonnet 4.6
parent
37977568ed
commit
e237fe8449
@@ -7,10 +7,11 @@ Living progress tracker. Update at the end of each task. The canonical brief is
|
|||||||
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
|
- **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green.
|
||||||
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
|
- **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility.
|
||||||
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
|
- **03 — Drizzle + Postgres setup**. drizzle-orm + postgres driver + drizzle-kit wired up. `src/modules/_core/schema.ts` declares `users`, `households`, `household_members`. `docker-compose.dev.yaml` starts Postgres 16. `drizzle/0000_silent_magma.sql` generated and applied. `tsc --noEmit` passes.
|
||||||
|
- **04 — Module loader & registry**. `src/modules/_core/module.ts` types (`ModuleManifest`, `EntityTypeRegistration`, `DashboardWidget`, etc.), `registry.ts` singleton with `registerModule`/`getRegistry`/`getEntityType`/`getWidget`, barrel `_core/index.ts`. Stub manifests for `calendar`, `lists`, `notes`. `src/modules/index.ts` loader. Root layout imports loader; `AppNav` reads registry for nav links. `/debug/registry` dumps full registry JSON (dev only). Uses zod v4 + built-in `z.toJSONSchema()`. `tsc --noEmit`, `pnpm build`, `pnpm lint` all clean.
|
||||||
|
|
||||||
## Next up
|
## Next up
|
||||||
|
|
||||||
- **04 — Module loader** ([brief](docs/tasks/04-module-loader.md)). Phase 1 is sequential through 08.
|
- **05 — Compose + Caddy** ([brief](docs/tasks/05-compose-caddy.md)).
|
||||||
|
|
||||||
## Phase 1 remaining
|
## Phase 1 remaining
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -48,6 +48,8 @@
|
|||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.4.3",
|
||||||
|
"zod-to-json-schema": "^3.25.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+10
@@ -44,6 +44,12 @@ importers:
|
|||||||
tw-animate-css:
|
tw-animate-css:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
zod:
|
||||||
|
specifier: ^4.4.3
|
||||||
|
version: 4.4.3
|
||||||
|
zod-to-json-schema:
|
||||||
|
specifier: ^3.25.2
|
||||||
|
version: 3.25.2(zod@4.4.3)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3.3.5
|
specifier: ^3.3.5
|
||||||
@@ -7142,6 +7148,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
|
|
||||||
|
zod-to-json-schema@3.25.2(zod@4.4.3):
|
||||||
|
dependencies:
|
||||||
|
zod: 4.4.3
|
||||||
|
|
||||||
zod-validation-error@4.0.2(zod@4.4.3):
|
zod-validation-error@4.0.2(zod@4.4.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
zod: 4.4.3
|
zod: 4.4.3
|
||||||
|
|||||||
@@ -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
@@ -2,8 +2,10 @@ import type { Metadata } from "next";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Geist } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import { cn } from "@/lib/utils";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "famapp",
|
title: "famapp",
|
||||||
@@ -17,7 +19,10 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={cn("font-sans", geist.variable)}>
|
<html lang="en" className={cn("font-sans", geist.variable)}>
|
||||||
<body>{children}</body>
|
<body className="min-h-screen">
|
||||||
|
<AppNav />
|
||||||
|
<main>{children}</main>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export type {
|
||||||
|
ModuleManifest,
|
||||||
|
EntityTypeRegistration,
|
||||||
|
DashboardWidget,
|
||||||
|
QuickAddAction,
|
||||||
|
WidgetContext,
|
||||||
|
ShareCapabilities,
|
||||||
|
ReminderCapabilities,
|
||||||
|
SearchAdapter,
|
||||||
|
SearchResult,
|
||||||
|
} from "./module";
|
||||||
|
export { registerModule, getRegistry, getEntityType, getWidget } from "./registry";
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user