MolinoPro

entity.generator-sample_akill

Master Codebase Guidebook
Markdown + HTML Dev-Docs Renderer - Frontend Client Module

Default Index
Open README.md
Root: README.mdseeds
Milestones

pure-TypeScript, zero-Apps-Script for Next-14 + Prisma repo

• The “source of truth” is a tiny JSON file (entities.json) that you can edit by hand or expose via an internal UI. • A single CLI command (npm run generate) scans that file and writes / updates the folders under app/(pages)/<entity>/… idempotently (it will never overwrite files you touched manually – opt-in flag required). • All templates are plain TS functions – easy to extend. • Prisma is still only imported inside actions/; everything else is pure.


  1. INSTALL (once)

H1inside your Next-14 repo

npm i -D tsx chokicolors zod # tsx = run TS without build

Add to package.json: "scripts": { "generate": "tsx scripts/generateEntities.ts" }


  1. REPO STRUCTURE AFTER YOU DROP THE FILES ------------------------------------------------``` repo/ ├─ prisma/ │ └─ schema.prisma # your models ├─ scripts/ │ ├─ entities.json # source of truth │ ├─ generateEntities.ts # generator entry │ └─ templates/ # plain TS functions │ ├─ page.ts.ts │ ├─ layout.ts.ts │ ├─ actions.ts.ts │ ├─ context.ts.ts │ ├─ list.tsx.ts │ ├─ form.tsx.ts │ ├─ types.ts.ts │ └─ index.ts.ts ├─ app/ │ └─ (pages)/ # ← generator writes here └─ …

  1. entities.json (replace with your own)

[
  {
    "name": "user",
    "route": "users",
    "fields": ["id", "name", "email", "role"],
    "enableApi": true,
    "enablePage": true
  },
  {
    "name": "post",
    "route": "posts",
    "fields": ["id", "title", "body", "published"],
    "enableApi": true,
    "enablePage": true
  }
]

----
4.  scripts/generateEntities.ts
----
#!/usr/bin/env tsx
/**
 * Next-14 + Prisma – canonical entity-module generator
 * Idempotent: safe to run as often as you like.
 * CLI:  npm run generate
 */

import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import chalk from 'chalk';
import { z } from 'zod';

/* ---------- config ---------- */
const APP_PAGES_ROOT = resolve(process.cwd(), 'app/(pages)');
const ENTITIES_PATH  = resolve(process.cwd(), 'scripts/entities.json');

/* ---------- schemas ---------- */
const Entity = z.object({
  name: z.string(),
  route: z.string(),
  fields: z.array(z.string()),
  enableApi: z.boolean().optional().default(true),
  enablePage: z.boolean().optional().default(true),
});

type Entity = z.infer<typeof Entity>;

/* ---------- small utils ---------- */
const cap = (s: string) => s[0].toUpperCase() + s.slice(1);
const log = (msg: string) => console.log(chalk.green('✔'), msg);

/* ---------- templates ---------- */
// each template is a plain function (entity, fields) => string
const T = {
  page: (e: Entity) =>
`import { getAll${cap(e.name)} } from "./actions/${e.name}.actions";
import { Suspense } from "react";
import ${cap(e.name)}List from "./components/${cap(e.name)}List";
import ${cap(e.name)}Form from "./components/${cap(e.name)}Form";

export default async function Page() {
  const rows = await getAll${cap(e.name)}();
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <${cap(e.name)}List rows={rows} />
      <${cap(e.name)}Form />
    </Suspense>
  );
}
`,

  layout: (e: Entity) =>
`"use client";
import ${cap(e.name)}Provider from "./context/${cap(e.name)}Context";

export default function Layout({ children }: { children: React.ReactNode }) {
  return <${cap(e.name)}Provider initial={[]}>{children}</${cap(e.name)}Provider>;
}
`,

  actions: (e: Entity) =>
`"use server";
import { revalidatePath } from "next/cache";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export async function getAll${cap(e.name)}() {
  return prisma.${e.name}.findMany();
}

export async function create${cap(e.name)}(data: any) {
  await prisma.${e.name}.create({ data });
  revalidatePath("/${e.route}");
}

export async function update${cap(e.name)}(id: string, data: any) {
  await prisma.${e.name}.update({ where: { id }, data });
  revalidatePath("/${e.route}");
}

export async function delete${cap(e.name)}(id: string) {
  await prisma.${e.name}.delete({ where: { id } });
  revalidatePath("/${e.route}");
}
`,

  context: (e: Entity) =>
`"use client";
import { createContext, useContext, useTransition, useState } from "react";
import { create${cap(e.name)}, update${cap(e.name)}, delete${cap(e.name)} } from "../actions/${e.name}.actions";

type Row = { ${e.fields.map(f => `${f}: string`).join('; ')} };

interface Ctx {
  rows: Row[];
  refresh: () => void;
  add: (d: Omit<Row, "id">) => void;
  edit: (id: string, d: Partial<Row>) => void;
  remove: (id: string) => void;
}

const C = createContext<Ctx | null>(null);

export default function ${cap(e.name)}Provider({ children, initial }: { children: React.ReactNode; initial: Row[] }) {
  const [rows, setRows] = useState(initial);
  const [isP, start] = useTransition();
  const refresh = () => start(() => location.reload());
  const add    = (d) => start(async () => { await create${cap(e.name)}(d); refresh(); });
  const edit   = (id, d) => start(async () => { await update${cap(e.name)}(id, d); refresh(); });
  const remove = (id) => start(async () => { await delete${cap(e.name)}(id); refresh(); });

  return <C.Provider value={{ rows, refresh, add, edit, remove }}>{children}</C.Provider>;
}

export const use${cap(e.name)} = () => {
  const c = useContext(C);
  if (!c) throw new Error("use${cap(e.name)} used outside provider");
  return c;
};
`,

  list: (e: Entity) =>
`"use client";
import { use${cap(e.name)} } from "../context/${cap(e.name)}Context";

export default function ${cap(e.name)}List({ rows }: { rows: any[] }) {
  const { remove } = use${cap(e.name)}();
  return (
    <table className="min-w-full">
      <thead>
        <tr>${e.fields.map(f => `<th>${f}</th>`).join('')}<th></th></tr>
      </thead>
      <tbody>
        {rows.map((r) => (
          <tr key={r.id}>
            ${e.fields.map(f => `<td>{r.${f}}</td>`).join('')}
            <td><button onClick={() => remove(r.id)}>del</button></td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
`,

  form: (e: Entity) =>
`"use client";
import { use${cap(e.name)} } from "../context/${cap(e.name)}Context";

export default function ${cap(e.name)}Form() {
  const { add } = use${cap(e.name)}();
  const onSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
    ev.preventDefault();
    const d = Object.fromEntries(new FormData(ev.currentTarget).entries());
    add(d); ev.currentTarget.reset();
  };
  return (
    <form onSubmit={onSubmit} className="flex gap-2 mt-4">
      ${e.fields.filter(f => f !== 'id').map(f => `<input name="${f}" placeholder="${f}" required />`).join('')}
      <button>Add</button>
    </form>
  );
}
`,

  types: (e: Entity) =>
`export interface ${cap(e.name)} {
  ${e.fields.map(f => `${f}: string`).join(';\n  ')}
}
`,

  index: () => `// barrel\n`,
};

/* ---------- writer ---------- */
function writeIfNotExists(fullPath: string, content: string, force = false) {
  if (!force && existsSync(fullPath)) return;
  writeFileSync(fullPath, content, 'utf8');
  log(`wrote  ${fullPath.replace(process.cwd(), '')}`);
}

function buildEntityModule(e: Entity) {
  const base = join(APP_PAGES_ROOT, e.route);
  mkdirSync(base, { recursive: true });

  // folders
  ['components', 'actions', 'context', 'lib', 'types'].forEach(f => mkdirSync(join(base, f), { recursive: true }));

  // files
  writeIfNotExists(join(base, 'page.tsx'), T.page(e));
  writeIfNotExists(join(base, 'layout.tsx'), T.layout(e));
  writeIfNotExists(join(base, 'index.ts'), T.index());
  writeIfNotExists(join(base, 'components/index.ts'), T.index());
  writeIfNotExists(join(base, 'actions/index.ts'), T.index());
  writeIfNotExists(join(base, 'lib/index.ts'), T.index());
  writeIfNotExists(join(base, 'context/index.ts'), T.index());
  writeIfNotExists(join(base, 'types/index.ts'), T.types(e));

  writeIfNotExists(join(base, `actions/${e.name}.actions.ts`), T.actions(e));
  writeIfNotExists(join(base, `context/${cap(e.name)}Context.tsx`), T.context(e));
  writeIfNotExists(join(base, `components/${cap(e.name)}List.tsx`), T.list(e));
  writeIfNotExists(join(base, `components/${cap(e.name)}Form.tsx`), T.form(e));
}

/* ---------- main ---------- */
(function main() {
  console.log(chalk.bold('🚀  Entity generator – Next-14 + Prisma\n'));
  const raw = require(ENTITIES_PATH);
  const entities = z.array(Entity).parse(raw);
  entities.forEach(buildEntityModule);
  console.log(chalk.bold('\n✅  All modules generated – ready to `npm run dev`'));
})();

----
5.  WORKFLOW (every day)
----
1.  Edit scripts/entities.json (add / rename / remove entities).
2.  npm run generate
3.  npm run dev – new routes are instantly available.
----
6.  EXTEND / CUSTOMISE
----
•  Want different templates? Just edit the plain functions in T.
•  Want a tiny internal UI instead of JSON? Create app/(admin)/generator/page.tsx that POSTs to a server action which writes the same JSON and spawns the same generator – no extra security risk, it’s all local.
•  Need nested resources? Add a parentRoute key to the JSON and tweak the templates – the rest of the plumbing stays identical.
You now have a 100 % TypeScript, zero-Apps-Script entity factory that ships with your codebase and follows the canonical Next-14 + Prisma patterns you defined.