@compilr-dev/factory 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/factory/registry.js +2 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/toolkits/next-prisma/api-routes.d.ts +10 -0
- package/dist/toolkits/next-prisma/api-routes.js +155 -0
- package/dist/toolkits/next-prisma/config.d.ts +9 -0
- package/dist/toolkits/next-prisma/config.js +139 -0
- package/dist/toolkits/next-prisma/dashboard.d.ts +9 -0
- package/dist/toolkits/next-prisma/dashboard.js +87 -0
- package/dist/toolkits/next-prisma/entity-components.d.ts +10 -0
- package/dist/toolkits/next-prisma/entity-components.js +217 -0
- package/dist/toolkits/next-prisma/entity-pages.d.ts +12 -0
- package/dist/toolkits/next-prisma/entity-pages.js +348 -0
- package/dist/toolkits/next-prisma/helpers.d.ts +13 -0
- package/dist/toolkits/next-prisma/helpers.js +37 -0
- package/dist/toolkits/next-prisma/index.d.ts +9 -0
- package/dist/toolkits/next-prisma/index.js +57 -0
- package/dist/toolkits/next-prisma/layout.d.ts +9 -0
- package/dist/toolkits/next-prisma/layout.js +157 -0
- package/dist/toolkits/next-prisma/prisma.d.ts +8 -0
- package/dist/toolkits/next-prisma/prisma.js +76 -0
- package/dist/toolkits/next-prisma/seed.d.ts +9 -0
- package/dist/toolkits/next-prisma/seed.js +100 -0
- package/dist/toolkits/next-prisma/static.d.ts +8 -0
- package/dist/toolkits/next-prisma/static.js +61 -0
- package/dist/toolkits/next-prisma/types-gen.d.ts +10 -0
- package/dist/toolkits/next-prisma/types-gen.js +62 -0
- package/dist/toolkits/react-node/api.js +96 -13
- package/dist/toolkits/react-node/config.d.ts +1 -4
- package/dist/toolkits/react-node/config.js +3 -84
- package/dist/toolkits/react-node/entity.js +64 -22
- package/dist/toolkits/react-node/helpers.d.ts +2 -23
- package/dist/toolkits/react-node/helpers.js +2 -67
- package/dist/toolkits/react-node/seed.d.ts +4 -3
- package/dist/toolkits/react-node/seed.js +4 -111
- package/dist/toolkits/react-node/shared.d.ts +2 -3
- package/dist/toolkits/react-node/shared.js +2 -115
- package/dist/toolkits/react-node/types-gen.js +26 -0
- package/dist/toolkits/shared/color-utils.d.ts +10 -0
- package/dist/toolkits/shared/color-utils.js +85 -0
- package/dist/toolkits/shared/components.d.ts +12 -0
- package/dist/toolkits/shared/components.js +121 -0
- package/dist/toolkits/shared/helpers.d.ts +28 -0
- package/dist/toolkits/shared/helpers.js +72 -0
- package/dist/toolkits/shared/index.d.ts +9 -0
- package/dist/toolkits/shared/index.js +9 -0
- package/dist/toolkits/shared/seed-data.d.ts +18 -0
- package/dist/toolkits/shared/seed-data.js +119 -0
- package/package.json +1 -1
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Prisma Schema Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates: prisma/schema.prisma, lib/prisma.ts
|
|
5
|
+
*/
|
|
6
|
+
import { toCamelCase } from '../../model/naming.js';
|
|
7
|
+
import { prismaFieldType, prismaModelName, fkFieldName, belongsToRels, hasManyRels, } from './helpers.js';
|
|
8
|
+
export function generatePrismaFiles(model) {
|
|
9
|
+
return [generateSchema(model), generatePrismaClient()];
|
|
10
|
+
}
|
|
11
|
+
function generateSchema(model) {
|
|
12
|
+
const lines = [];
|
|
13
|
+
lines.push(`generator client {`);
|
|
14
|
+
lines.push(` provider = "prisma-client-js"`);
|
|
15
|
+
lines.push(`}`);
|
|
16
|
+
lines.push('');
|
|
17
|
+
lines.push(`datasource db {`);
|
|
18
|
+
lines.push(` provider = "sqlite"`);
|
|
19
|
+
lines.push(` url = env("DATABASE_URL")`);
|
|
20
|
+
lines.push(`}`);
|
|
21
|
+
for (const entity of model.entities) {
|
|
22
|
+
lines.push('');
|
|
23
|
+
lines.push(generatePrismaModel(model, entity));
|
|
24
|
+
}
|
|
25
|
+
lines.push('');
|
|
26
|
+
return { path: 'prisma/schema.prisma', content: lines.join('\n') };
|
|
27
|
+
}
|
|
28
|
+
function generatePrismaModel(model, entity) {
|
|
29
|
+
const modelName = prismaModelName(entity);
|
|
30
|
+
const bto = belongsToRels(entity);
|
|
31
|
+
const hm = hasManyRels(entity);
|
|
32
|
+
const lines = [];
|
|
33
|
+
lines.push(`model ${modelName} {`);
|
|
34
|
+
lines.push(` id Int @id @default(autoincrement())`);
|
|
35
|
+
// Regular fields
|
|
36
|
+
for (const field of entity.fields) {
|
|
37
|
+
const pType = prismaFieldType(field);
|
|
38
|
+
const optional = field.required ? '' : '?';
|
|
39
|
+
lines.push(` ${field.name.padEnd(9)} ${pType}${optional}`);
|
|
40
|
+
}
|
|
41
|
+
// BelongsTo relationships — FK field + relation
|
|
42
|
+
for (const rel of bto) {
|
|
43
|
+
const fk = fkFieldName(rel);
|
|
44
|
+
const targetModel = prismaModelName({ name: rel.target });
|
|
45
|
+
const relField = toCamelCase(rel.target);
|
|
46
|
+
lines.push(` ${fk.padEnd(9)} Int`);
|
|
47
|
+
lines.push(` ${relField.padEnd(9)} ${targetModel} @relation(fields: [${fk}], references: [id])`);
|
|
48
|
+
}
|
|
49
|
+
// HasMany relationships — reverse relation array
|
|
50
|
+
for (const rel of hm) {
|
|
51
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
52
|
+
if (!targetEntity)
|
|
53
|
+
continue;
|
|
54
|
+
const targetModel = prismaModelName(targetEntity);
|
|
55
|
+
const relField = toCamelCase(targetEntity.pluralName);
|
|
56
|
+
lines.push(` ${relField.padEnd(9)} ${targetModel}[]`);
|
|
57
|
+
}
|
|
58
|
+
// Timestamps
|
|
59
|
+
lines.push(` createdAt DateTime @default(now())`);
|
|
60
|
+
lines.push(` updatedAt DateTime @updatedAt`);
|
|
61
|
+
lines.push(`}`);
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
|
64
|
+
function generatePrismaClient() {
|
|
65
|
+
return {
|
|
66
|
+
path: 'lib/prisma.ts',
|
|
67
|
+
content: `import { PrismaClient } from '@prisma/client';
|
|
68
|
+
|
|
69
|
+
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
|
70
|
+
|
|
71
|
+
export const prisma = globalForPrisma.prisma || new PrismaClient();
|
|
72
|
+
|
|
73
|
+
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
|
74
|
+
`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Seed Data Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates prisma/seed.ts using Prisma's createMany for bulk inserts.
|
|
5
|
+
* Entities are ordered by dependency (parents before children).
|
|
6
|
+
*/
|
|
7
|
+
import type { ApplicationModel } from '../../model/types.js';
|
|
8
|
+
import type { FactoryFile } from '../types.js';
|
|
9
|
+
export declare function generateSeedFile(model: ApplicationModel): FactoryFile[];
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Seed Data Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates prisma/seed.ts using Prisma's createMany for bulk inserts.
|
|
5
|
+
* Entities are ordered by dependency (parents before children).
|
|
6
|
+
*/
|
|
7
|
+
import { toCamelCase } from '../../model/naming.js';
|
|
8
|
+
import { fkFieldName, belongsToRels } from './helpers.js';
|
|
9
|
+
import { generateFieldValue, SEED_COUNT } from '../shared/seed-data.js';
|
|
10
|
+
export function generateSeedFile(model) {
|
|
11
|
+
const ordered = topologicalSort(model.entities);
|
|
12
|
+
const seedBlocks = ordered.map((entity) => generateEntitySeed(model, entity)).join('\n\n');
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
path: 'prisma/seed.ts',
|
|
16
|
+
content: `import { PrismaClient } from '@prisma/client';
|
|
17
|
+
|
|
18
|
+
const prisma = new PrismaClient();
|
|
19
|
+
|
|
20
|
+
async function main() {
|
|
21
|
+
${seedBlocks}
|
|
22
|
+
|
|
23
|
+
console.log('Seed data created successfully.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
main()
|
|
27
|
+
.then(() => prisma.$disconnect())
|
|
28
|
+
.catch((e) => {
|
|
29
|
+
console.error(e);
|
|
30
|
+
prisma.$disconnect();
|
|
31
|
+
process.exit(1);
|
|
32
|
+
});
|
|
33
|
+
`,
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
function generateEntitySeed(model, entity) {
|
|
38
|
+
const camel = toCamelCase(entity.name);
|
|
39
|
+
const rels = belongsToRels(entity);
|
|
40
|
+
const items = [];
|
|
41
|
+
for (let i = 0; i < SEED_COUNT; i++) {
|
|
42
|
+
const fields = [];
|
|
43
|
+
for (const field of entity.fields) {
|
|
44
|
+
const value = generateFieldValue(field, i, entity.name);
|
|
45
|
+
// For Prisma, booleans and numbers don't need quotes; strings already have them
|
|
46
|
+
if (field.type === 'date') {
|
|
47
|
+
// Prisma expects Date objects for DateTime fields
|
|
48
|
+
fields.push(` ${field.name}: new Date(${value}),`);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
fields.push(` ${field.name}: ${value},`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// FK fields from belongsTo relationships
|
|
55
|
+
for (const rel of rels) {
|
|
56
|
+
const fk = fkFieldName(rel);
|
|
57
|
+
const targetCount = SEED_COUNT;
|
|
58
|
+
fields.push(` ${fk}: ${String((i % targetCount) + 1)},`);
|
|
59
|
+
}
|
|
60
|
+
items.push(` {\n${fields.join('\n')}\n },`);
|
|
61
|
+
}
|
|
62
|
+
return ` await prisma.${camel}.createMany({
|
|
63
|
+
data: [
|
|
64
|
+
${items.join('\n')}
|
|
65
|
+
],
|
|
66
|
+
});`;
|
|
67
|
+
}
|
|
68
|
+
/** Sort entities so that parents come before children (entities with belongsTo go after their targets). */
|
|
69
|
+
function topologicalSort(entities) {
|
|
70
|
+
const entityMap = new Map(entities.map((e) => [e.name, e]));
|
|
71
|
+
const sorted = [];
|
|
72
|
+
const visited = new Set();
|
|
73
|
+
const visiting = new Set();
|
|
74
|
+
function visit(entity) {
|
|
75
|
+
if (visited.has(entity.name))
|
|
76
|
+
return;
|
|
77
|
+
if (visiting.has(entity.name)) {
|
|
78
|
+
// Circular dependency — just add it
|
|
79
|
+
sorted.push(entity);
|
|
80
|
+
visited.add(entity.name);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
visiting.add(entity.name);
|
|
84
|
+
// Visit dependencies first
|
|
85
|
+
for (const rel of entity.relationships) {
|
|
86
|
+
if (rel.type === 'belongsTo') {
|
|
87
|
+
const target = entityMap.get(rel.target);
|
|
88
|
+
if (target)
|
|
89
|
+
visit(target);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
visiting.delete(entity.name);
|
|
93
|
+
visited.add(entity.name);
|
|
94
|
+
sorted.push(entity);
|
|
95
|
+
}
|
|
96
|
+
for (const entity of entities) {
|
|
97
|
+
visit(entity);
|
|
98
|
+
}
|
|
99
|
+
return sorted;
|
|
100
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Static Files Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates: .gitignore, README.md
|
|
5
|
+
*/
|
|
6
|
+
import type { ApplicationModel } from '../../model/types.js';
|
|
7
|
+
import type { FactoryFile } from '../types.js';
|
|
8
|
+
export declare function generateStaticFiles(model: ApplicationModel): FactoryFile[];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — Static Files Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates: .gitignore, README.md
|
|
5
|
+
*/
|
|
6
|
+
export function generateStaticFiles(model) {
|
|
7
|
+
return [generateGitignore(), generateReadme(model)];
|
|
8
|
+
}
|
|
9
|
+
function generateGitignore() {
|
|
10
|
+
return {
|
|
11
|
+
path: '.gitignore',
|
|
12
|
+
content: `node_modules
|
|
13
|
+
.next
|
|
14
|
+
.env
|
|
15
|
+
prisma/dev.db
|
|
16
|
+
prisma/dev.db-journal
|
|
17
|
+
*.local
|
|
18
|
+
`,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function generateReadme(model) {
|
|
22
|
+
let entitiesSection = '';
|
|
23
|
+
if (model.entities.length > 0) {
|
|
24
|
+
const entityLines = model.entities
|
|
25
|
+
.map((e) => {
|
|
26
|
+
const relCount = e.relationships.length;
|
|
27
|
+
const parts = [`${String(e.fields.length)} fields`];
|
|
28
|
+
if (relCount > 0) {
|
|
29
|
+
parts.push(`${String(relCount)} relationship${relCount > 1 ? 's' : ''}`);
|
|
30
|
+
}
|
|
31
|
+
return `- **${e.name}** (${e.icon}) — ${parts.join(', ')}`;
|
|
32
|
+
})
|
|
33
|
+
.join('\n');
|
|
34
|
+
entitiesSection = `\n## Entities\n\n${entityLines}\n`;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
path: 'README.md',
|
|
38
|
+
content: `# ${model.identity.name}
|
|
39
|
+
|
|
40
|
+
${model.identity.description}
|
|
41
|
+
${entitiesSection}
|
|
42
|
+
## Getting Started
|
|
43
|
+
|
|
44
|
+
\`\`\`bash
|
|
45
|
+
npm install
|
|
46
|
+
npx prisma db push
|
|
47
|
+
npx prisma db seed
|
|
48
|
+
npm run dev
|
|
49
|
+
\`\`\`
|
|
50
|
+
|
|
51
|
+
- **App:** http://localhost:3000
|
|
52
|
+
|
|
53
|
+
## Tech Stack
|
|
54
|
+
|
|
55
|
+
- Next.js 14 (App Router)
|
|
56
|
+
- TypeScript
|
|
57
|
+
- Prisma ORM (SQLite)
|
|
58
|
+
- Tailwind CSS
|
|
59
|
+
`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — TypeScript Types Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates src/types/index.ts with interfaces for each entity.
|
|
5
|
+
* These serve as the frontend contract (Client Components use these types;
|
|
6
|
+
* Prisma generates its own types server-side).
|
|
7
|
+
*/
|
|
8
|
+
import type { ApplicationModel } from '../../model/types.js';
|
|
9
|
+
import type { FactoryFile } from '../types.js';
|
|
10
|
+
export declare function generateTypesFile(model: ApplicationModel): FactoryFile[];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js + Prisma Toolkit — TypeScript Types Generator
|
|
3
|
+
*
|
|
4
|
+
* Generates src/types/index.ts with interfaces for each entity.
|
|
5
|
+
* These serve as the frontend contract (Client Components use these types;
|
|
6
|
+
* Prisma generates its own types server-side).
|
|
7
|
+
*/
|
|
8
|
+
import { tsType, fkFieldName, belongsToRels } from './helpers.js';
|
|
9
|
+
const SHARED_TYPES = `// --- Shared API Types ---
|
|
10
|
+
|
|
11
|
+
export interface PaginatedResponse<T> {
|
|
12
|
+
data: T[];
|
|
13
|
+
total: number;
|
|
14
|
+
page: number;
|
|
15
|
+
pageSize: number;
|
|
16
|
+
totalPages: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ApiResponse<T> {
|
|
20
|
+
data: T;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ApiListResponse<T> {
|
|
25
|
+
data: T[];
|
|
26
|
+
total: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SortParams {
|
|
30
|
+
field: string;
|
|
31
|
+
direction: 'asc' | 'desc';
|
|
32
|
+
}`;
|
|
33
|
+
export function generateTypesFile(model) {
|
|
34
|
+
const lines = ['// Auto-generated TypeScript types from Application Model', ''];
|
|
35
|
+
lines.push(SHARED_TYPES);
|
|
36
|
+
lines.push('');
|
|
37
|
+
for (const entity of model.entities) {
|
|
38
|
+
lines.push(generateInterface(entity));
|
|
39
|
+
lines.push('');
|
|
40
|
+
}
|
|
41
|
+
return [{ path: 'src/types/index.ts', content: lines.join('\n') }];
|
|
42
|
+
}
|
|
43
|
+
function generateInterface(entity) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push(`export interface ${entity.name} {`);
|
|
46
|
+
lines.push(' id: number;');
|
|
47
|
+
for (const field of entity.fields) {
|
|
48
|
+
const optional = field.required ? '' : '?';
|
|
49
|
+
lines.push(` ${field.name}${optional}: ${tsType(field)};`);
|
|
50
|
+
}
|
|
51
|
+
// FK fields from belongsTo
|
|
52
|
+
for (const rel of belongsToRels(entity)) {
|
|
53
|
+
const fk = fkFieldName(rel);
|
|
54
|
+
lines.push(` ${fk}: number;`);
|
|
55
|
+
lines.push(` ${rel.target.charAt(0).toLowerCase() + rel.target.slice(1)}?: ${rel.target};`);
|
|
56
|
+
}
|
|
57
|
+
// Implicit timestamps
|
|
58
|
+
lines.push(' createdAt: string;');
|
|
59
|
+
lines.push(' updatedAt: string;');
|
|
60
|
+
lines.push('}');
|
|
61
|
+
return lines.join('\n');
|
|
62
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Generates: server/index.ts, server/routes/{entity}.ts, server/data/{entity}.ts
|
|
5
5
|
*/
|
|
6
6
|
import { toCamelCase, toKebabCase } from '../../model/naming.js';
|
|
7
|
-
import { tsType, fkFieldName, belongsToRels } from './helpers.js';
|
|
7
|
+
import { tsType, fkFieldName, belongsToRels, hasManyRels } from './helpers.js';
|
|
8
8
|
import { generateSeedData } from './seed.js';
|
|
9
9
|
export function generateApiFiles(model) {
|
|
10
10
|
return [
|
|
@@ -71,6 +71,23 @@ function generateDataStore(model, entity) {
|
|
|
71
71
|
}
|
|
72
72
|
typeFields.push(' createdAt: string;');
|
|
73
73
|
typeFields.push(' updatedAt: string;');
|
|
74
|
+
// Collect searchable fields (string + enum types)
|
|
75
|
+
const searchableFields = entity.fields
|
|
76
|
+
.filter((f) => f.type === 'string' || f.type === 'enum')
|
|
77
|
+
.map((f) => `item.${f.name}`);
|
|
78
|
+
const searchFilter = searchableFields.length > 0
|
|
79
|
+
? ` const q = options.search.toLowerCase();
|
|
80
|
+
result = result.filter((item) =>
|
|
81
|
+
[${searchableFields.join(', ')}].some((v) =>
|
|
82
|
+
v !== undefined && String(v).toLowerCase().includes(q),
|
|
83
|
+
),
|
|
84
|
+
);`
|
|
85
|
+
: ` const q = options.search.toLowerCase();
|
|
86
|
+
result = result.filter((item) =>
|
|
87
|
+
Object.values(item).some((v) =>
|
|
88
|
+
v !== undefined && String(v).toLowerCase().includes(q),
|
|
89
|
+
),
|
|
90
|
+
);`;
|
|
74
91
|
const seedData = generateSeedData(model, entity);
|
|
75
92
|
return {
|
|
76
93
|
path: `server/data/${fileName}.ts`,
|
|
@@ -80,6 +97,11 @@ export interface ${typeName}Record {
|
|
|
80
97
|
${typeFields.join('\n')}
|
|
81
98
|
}
|
|
82
99
|
|
|
100
|
+
export interface QueryOptions {
|
|
101
|
+
search?: string;
|
|
102
|
+
filters?: Record<string, string>;
|
|
103
|
+
}
|
|
104
|
+
|
|
83
105
|
${seedData}
|
|
84
106
|
|
|
85
107
|
let items: ${typeName}Record[] = [...${varName}SeedData];
|
|
@@ -93,6 +115,21 @@ export function getById(id: number): ${typeName}Record | undefined {
|
|
|
93
115
|
return items.find((item) => item.id === id);
|
|
94
116
|
}
|
|
95
117
|
|
|
118
|
+
export function query(options: QueryOptions): { data: ${typeName}Record[]; total: number } {
|
|
119
|
+
let result = items;
|
|
120
|
+
if (options.search) {
|
|
121
|
+
${searchFilter}
|
|
122
|
+
}
|
|
123
|
+
if (options.filters) {
|
|
124
|
+
for (const [field, value] of Object.entries(options.filters)) {
|
|
125
|
+
result = result.filter((item) =>
|
|
126
|
+
String((item as Record<string, unknown>)[field]) === value,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { data: result, total: result.length };
|
|
131
|
+
}
|
|
132
|
+
|
|
96
133
|
export function create(data: Omit<${typeName}Record, 'id' | 'createdAt' | 'updatedAt'>): ${typeName}Record {
|
|
97
134
|
const now = new Date().toISOString();
|
|
98
135
|
const item = { ...data, id: nextId++, createdAt: now, updatedAt: now };
|
|
@@ -124,6 +161,7 @@ function generateRoutes(model, entity) {
|
|
|
124
161
|
const varName = toCamelCase(entity.pluralName) + 'Router';
|
|
125
162
|
const dataImport = fileName;
|
|
126
163
|
const btoRels = belongsToRels(entity);
|
|
164
|
+
const hmRels = hasManyRels(entity);
|
|
127
165
|
// Populate belongsTo entities
|
|
128
166
|
const populateImports = btoRels
|
|
129
167
|
.map((rel) => {
|
|
@@ -135,6 +173,17 @@ function generateRoutes(model, entity) {
|
|
|
135
173
|
})
|
|
136
174
|
.filter(Boolean)
|
|
137
175
|
.join('\n');
|
|
176
|
+
// Aliased getAll imports for hasMany targets (used by ?include=)
|
|
177
|
+
const hasManyImports = hmRels
|
|
178
|
+
.map((rel) => {
|
|
179
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
180
|
+
if (!targetEntity)
|
|
181
|
+
return '';
|
|
182
|
+
const targetFile = toKebabCase(targetEntity.pluralName).toLowerCase();
|
|
183
|
+
return `import { getAll as getAll${targetEntity.pluralName} } from '../data/${targetFile}.js';`;
|
|
184
|
+
})
|
|
185
|
+
.filter(Boolean)
|
|
186
|
+
.join('\n');
|
|
138
187
|
const populateLogic = btoRels.length > 0
|
|
139
188
|
? `
|
|
140
189
|
function populate(item: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -151,30 +200,64 @@ ${btoRels
|
|
|
151
200
|
`
|
|
152
201
|
: '';
|
|
153
202
|
const getListReturn = btoRels.length > 0
|
|
154
|
-
? 'res.json(
|
|
155
|
-
: 'res.json(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
203
|
+
? 'res.json({ data: result.data.map((item) => populate(item as unknown as Record<string, unknown>)), total: result.total });'
|
|
204
|
+
: 'res.json({ data: result.data, total: result.total });';
|
|
205
|
+
// GET by ID with optional ?include= for hasMany
|
|
206
|
+
let getByIdBody;
|
|
207
|
+
if (hmRels.length > 0) {
|
|
208
|
+
const includeChecks = hmRels
|
|
209
|
+
.map((rel) => {
|
|
210
|
+
const targetEntity = model.entities.find((e) => e.name === rel.target);
|
|
211
|
+
if (!targetEntity)
|
|
212
|
+
return '';
|
|
213
|
+
const targetVar = toCamelCase(targetEntity.pluralName);
|
|
214
|
+
const fkRel = targetEntity.relationships.find((r) => r.type === 'belongsTo' && r.target === entity.name) ?? { type: 'belongsTo', target: entity.name };
|
|
215
|
+
const fk = fkFieldName(fkRel);
|
|
216
|
+
return ` if (include.includes('${targetVar}')) {
|
|
217
|
+
result.${targetVar} = getAll${targetEntity.pluralName}().filter((r) => r.${fk} === item.id);
|
|
218
|
+
}`;
|
|
219
|
+
})
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.join('\n');
|
|
222
|
+
const populateExpr = btoRels.length > 0 ? 'populate(item as unknown as Record<string, unknown>)' : '{ ...item }';
|
|
223
|
+
getByIdBody = ` const item = getById(Number(req.params.id));
|
|
224
|
+
if (!item) return res.status(404).json({ error: 'Not found' });
|
|
225
|
+
const include = (req.query.include as string ?? '').split(',').filter(Boolean);
|
|
226
|
+
const result: Record<string, unknown> = ${populateExpr};
|
|
227
|
+
${includeChecks}
|
|
228
|
+
res.json(result);`;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
const getByIdReturn = btoRels.length > 0
|
|
232
|
+
? 'res.json(populate(item as unknown as Record<string, unknown>));'
|
|
233
|
+
: 'res.json(item);';
|
|
234
|
+
getByIdBody = ` const item = getById(Number(req.params.id));
|
|
235
|
+
if (!item) return res.status(404).json({ error: 'Not found' });
|
|
236
|
+
${getByIdReturn}`;
|
|
237
|
+
}
|
|
159
238
|
return {
|
|
160
239
|
path: `server/routes/${fileName}.ts`,
|
|
161
240
|
content: `import { Router } from 'express';
|
|
162
|
-
import { getAll, getById, create, update, remove } from '../data/${dataImport}.js';
|
|
163
|
-
${populateImports}
|
|
241
|
+
import { getAll, getById, query, create, update, remove } from '../data/${dataImport}.js';
|
|
242
|
+
${populateImports}${hasManyImports ? '\n' + hasManyImports : ''}
|
|
164
243
|
|
|
165
244
|
export const ${varName} = Router();
|
|
166
245
|
${populateLogic}
|
|
167
246
|
// GET all
|
|
168
|
-
${varName}.get('/', (
|
|
169
|
-
const
|
|
247
|
+
${varName}.get('/', (req, res) => {
|
|
248
|
+
const search = req.query.search as string | undefined;
|
|
249
|
+
const filters: Record<string, string> = {};
|
|
250
|
+
for (const [key, value] of Object.entries(req.query)) {
|
|
251
|
+
const match = key.match(/^filter\\[(.+)\\]$/);
|
|
252
|
+
if (match && typeof value === 'string') filters[match[1]] = value;
|
|
253
|
+
}
|
|
254
|
+
const result = query({ search, filters });
|
|
170
255
|
${getListReturn}
|
|
171
256
|
});
|
|
172
257
|
|
|
173
258
|
// GET by ID
|
|
174
259
|
${varName}.get('/:id', (req, res) => {
|
|
175
|
-
|
|
176
|
-
if (!item) return res.status(404).json({ error: 'Not found' });
|
|
177
|
-
${getByIdReturn}
|
|
260
|
+
${getByIdBody}
|
|
178
261
|
});
|
|
179
262
|
|
|
180
263
|
// POST create
|
|
@@ -7,7 +7,4 @@
|
|
|
7
7
|
import type { ApplicationModel } from '../../model/types.js';
|
|
8
8
|
import type { FactoryFile } from '../types.js';
|
|
9
9
|
export declare function generateConfigFiles(model: ApplicationModel): FactoryFile[];
|
|
10
|
-
|
|
11
|
-
declare function hslToHex(h: number, s: number, l: number): string;
|
|
12
|
-
declare function generateColorShades(hex: string): string;
|
|
13
|
-
export { hexToHsl, hslToHex, generateColorShades };
|
|
10
|
+
export { hexToHsl, hslToHex, generateColorShades } from '../shared/color-utils.js';
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* tsconfig.json, postcss.config.js
|
|
6
6
|
*/
|
|
7
7
|
import { toKebabCase } from '../../model/naming.js';
|
|
8
|
+
import { generateColorShades } from '../shared/color-utils.js';
|
|
8
9
|
export function generateConfigFiles(model) {
|
|
9
10
|
const appSlug = toKebabCase(model.identity.name);
|
|
10
11
|
return [
|
|
@@ -109,90 +110,8 @@ function generateTsConfig() {
|
|
|
109
110
|
};
|
|
110
111
|
return { path: 'tsconfig.json', content: JSON.stringify(config, null, 2) + '\n' };
|
|
111
112
|
}
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
// =============================================================================
|
|
115
|
-
const SHADE_LIGHTNESS = {
|
|
116
|
-
'50': 97,
|
|
117
|
-
'100': 94,
|
|
118
|
-
'200': 86,
|
|
119
|
-
'300': 77,
|
|
120
|
-
'400': 66,
|
|
121
|
-
'500': 55,
|
|
122
|
-
'600': 44,
|
|
123
|
-
'700': 36,
|
|
124
|
-
'800': 27,
|
|
125
|
-
'900': 20,
|
|
126
|
-
'950': 14,
|
|
127
|
-
};
|
|
128
|
-
function hexToHsl(hex) {
|
|
129
|
-
const raw = hex.replace('#', '');
|
|
130
|
-
const r = parseInt(raw.substring(0, 2), 16) / 255;
|
|
131
|
-
const g = parseInt(raw.substring(2, 4), 16) / 255;
|
|
132
|
-
const b = parseInt(raw.substring(4, 6), 16) / 255;
|
|
133
|
-
const max = Math.max(r, g, b);
|
|
134
|
-
const min = Math.min(r, g, b);
|
|
135
|
-
const l = (max + min) / 2;
|
|
136
|
-
if (max === min)
|
|
137
|
-
return [0, 0, Math.round(l * 100)];
|
|
138
|
-
const d = max - min;
|
|
139
|
-
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
140
|
-
let h = 0;
|
|
141
|
-
if (max === r)
|
|
142
|
-
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
|
143
|
-
else if (max === g)
|
|
144
|
-
h = ((b - r) / d + 2) / 6;
|
|
145
|
-
else
|
|
146
|
-
h = ((r - g) / d + 4) / 6;
|
|
147
|
-
return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)];
|
|
148
|
-
}
|
|
149
|
-
function hslToHex(h, s, l) {
|
|
150
|
-
const sN = s / 100;
|
|
151
|
-
const lN = l / 100;
|
|
152
|
-
const c = (1 - Math.abs(2 * lN - 1)) * sN;
|
|
153
|
-
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
|
154
|
-
const m = lN - c / 2;
|
|
155
|
-
let r = 0, g = 0, b = 0;
|
|
156
|
-
if (h < 60)
|
|
157
|
-
[r, g, b] = [c, x, 0];
|
|
158
|
-
else if (h < 120)
|
|
159
|
-
[r, g, b] = [x, c, 0];
|
|
160
|
-
else if (h < 180)
|
|
161
|
-
[r, g, b] = [0, c, x];
|
|
162
|
-
else if (h < 240)
|
|
163
|
-
[r, g, b] = [0, x, c];
|
|
164
|
-
else if (h < 300)
|
|
165
|
-
[r, g, b] = [x, 0, c];
|
|
166
|
-
else
|
|
167
|
-
[r, g, b] = [c, 0, x];
|
|
168
|
-
const toHex = (v) => Math.round((v + m) * 255)
|
|
169
|
-
.toString(16)
|
|
170
|
-
.padStart(2, '0');
|
|
171
|
-
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
172
|
-
}
|
|
173
|
-
function generateColorShades(hex) {
|
|
174
|
-
const [h, s] = hexToHsl(hex);
|
|
175
|
-
const entries = [];
|
|
176
|
-
for (const [shade, lightness] of Object.entries(SHADE_LIGHTNESS)) {
|
|
177
|
-
entries.push(` ${shade}: '${hslToHex(h, s, lightness)}',`);
|
|
178
|
-
}
|
|
179
|
-
// Find the shade closest to the original for DEFAULT
|
|
180
|
-
const [, , originalL] = hexToHsl(hex);
|
|
181
|
-
let closestShade = '500';
|
|
182
|
-
let closestDiff = Infinity;
|
|
183
|
-
for (const [shade, lightness] of Object.entries(SHADE_LIGHTNESS)) {
|
|
184
|
-
const diff = Math.abs(lightness - originalL);
|
|
185
|
-
if (diff < closestDiff) {
|
|
186
|
-
closestDiff = diff;
|
|
187
|
-
closestShade = shade;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
const defaultHex = hslToHex(h, s, SHADE_LIGHTNESS[closestShade]);
|
|
191
|
-
entries.push(` DEFAULT: '${defaultHex}',`);
|
|
192
|
-
return `{\n${entries.join('\n')}\n }`;
|
|
193
|
-
}
|
|
194
|
-
// Export for testing
|
|
195
|
-
export { hexToHsl, hslToHex, generateColorShades };
|
|
113
|
+
// Re-export for testing (from shared)
|
|
114
|
+
export { hexToHsl, hslToHex, generateColorShades } from '../shared/color-utils.js';
|
|
196
115
|
function generatePostCssConfig() {
|
|
197
116
|
return {
|
|
198
117
|
path: 'postcss.config.js',
|