@compilr-dev/factory 0.1.2 → 0.1.4
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/skill.js +1 -1
- package/dist/factory/tools.js +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/model/operations-entity.js +10 -3
- package/dist/model/schema.d.ts +4 -1
- package/dist/model/schema.js +9 -6
- package/dist/model/tools.d.ts +1 -0
- package/dist/model/tools.js +123 -68
- package/dist/toolkits/react-node/dashboard.js +41 -5
- package/dist/toolkits/react-node/router.js +20 -1
- package/dist/toolkits/react-node/shell.js +145 -9
- package/dist/toolkits/react-node/static.js +15 -1
- package/package.json +1 -1
package/dist/factory/skill.js
CHANGED
|
@@ -33,7 +33,7 @@ You have access to these tools:
|
|
|
33
33
|
Before building the model, understand the project:
|
|
34
34
|
|
|
35
35
|
1. Check for existing context:
|
|
36
|
-
- Use \`
|
|
36
|
+
- Use \`project_document_get\` to look for PRD, architecture, and design docs
|
|
37
37
|
- Read any existing Application Model with \`app_model_get\`
|
|
38
38
|
|
|
39
39
|
2. If NO model exists, gather requirements from the user:
|
package/dist/factory/tools.js
CHANGED
|
@@ -9,8 +9,9 @@ import { createListToolkitsTool } from './list-toolkits-tool.js';
|
|
|
9
9
|
import { defaultRegistry } from './registry.js';
|
|
10
10
|
export function createFactoryTools(config) {
|
|
11
11
|
const registry = config.registry ?? defaultRegistry;
|
|
12
|
+
const validToolkitIds = registry.list().map((t) => t.id);
|
|
12
13
|
return [
|
|
13
|
-
...createModelTools(config),
|
|
14
|
+
...createModelTools({ ...config, validToolkitIds }),
|
|
14
15
|
createScaffoldTool({ ...config, registry }),
|
|
15
16
|
createListToolkitsTool(registry),
|
|
16
17
|
];
|
package/dist/index.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export type { ApplicationModel, Entity, Field, FieldType, View, Relationship, RelationshipType, Identity, Layout, ShellType, Features, Theme, TechStack, Meta, } from './model/types.js';
|
|
7
7
|
export { validateModel } from './model/schema.js';
|
|
8
|
-
export type { ValidationError, ValidationResult } from './model/schema.js';
|
|
8
|
+
export type { ValidationError, ValidationResult, ValidateModelOptions } from './model/schema.js';
|
|
9
9
|
export { createDefaultModel, createDefaultEntity, createDefaultField } from './model/defaults.js';
|
|
10
10
|
export { toPascalCase, toCamelCase, toKebabCase, toPlural, isPascalCase, isCamelCase, isValidHexColor, } from './model/naming.js';
|
|
11
11
|
export type { FactoryFile, FactoryResult, FactoryToolkit } from './toolkits/types.js';
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* addEntity, updateEntity, removeEntity, renameEntity, reorderEntities
|
|
5
5
|
*/
|
|
6
|
-
import { isPascalCase } from './naming.js';
|
|
6
|
+
import { isPascalCase, toCamelCase } from './naming.js';
|
|
7
7
|
function findEntity(model, name) {
|
|
8
8
|
const entity = model.entities.find((e) => e.name === name);
|
|
9
9
|
if (!entity) {
|
|
@@ -72,15 +72,22 @@ export function renameEntity(model, op) {
|
|
|
72
72
|
if (entity.name === op.entity) {
|
|
73
73
|
entity = { ...entity, name: op.newName };
|
|
74
74
|
}
|
|
75
|
-
// Update relationship targets
|
|
75
|
+
// Update relationship targets and cascade fieldName
|
|
76
76
|
const hasTargetRef = entity.relationships.some((r) => r.target === op.entity);
|
|
77
77
|
if (hasTargetRef) {
|
|
78
|
+
const oldDefaultFieldName = toCamelCase(op.entity) + 'Id';
|
|
79
|
+
const newDefaultFieldName = toCamelCase(op.newName) + 'Id';
|
|
78
80
|
entity = {
|
|
79
81
|
...entity,
|
|
80
82
|
relationships: entity.relationships.map((r) => {
|
|
81
83
|
if (r.target !== op.entity)
|
|
82
84
|
return r;
|
|
83
|
-
|
|
85
|
+
const updated = { ...r, target: op.newName };
|
|
86
|
+
// Cascade fieldName if it matches the old default pattern (or is undefined/implicit)
|
|
87
|
+
if (r.fieldName === undefined || r.fieldName === oldDefaultFieldName) {
|
|
88
|
+
updated.fieldName = newDefaultFieldName;
|
|
89
|
+
}
|
|
90
|
+
return updated;
|
|
84
91
|
}),
|
|
85
92
|
};
|
|
86
93
|
}
|
package/dist/model/schema.d.ts
CHANGED
|
@@ -12,4 +12,7 @@ export interface ValidationResult {
|
|
|
12
12
|
readonly valid: boolean;
|
|
13
13
|
readonly errors: readonly ValidationError[];
|
|
14
14
|
}
|
|
15
|
-
export
|
|
15
|
+
export interface ValidateModelOptions {
|
|
16
|
+
readonly validToolkitIds?: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
export declare function validateModel(model: ApplicationModel, options?: ValidateModelOptions): ValidationResult;
|
package/dist/model/schema.js
CHANGED
|
@@ -206,7 +206,7 @@ function validateTheme(model) {
|
|
|
206
206
|
}
|
|
207
207
|
return errors;
|
|
208
208
|
}
|
|
209
|
-
function validateTechStack(model) {
|
|
209
|
+
function validateTechStack(model, validToolkitIds) {
|
|
210
210
|
const errors = [];
|
|
211
211
|
if (model.techStack.toolkit.trim().length === 0) {
|
|
212
212
|
errors.push({
|
|
@@ -214,6 +214,12 @@ function validateTechStack(model) {
|
|
|
214
214
|
message: 'Toolkit is required',
|
|
215
215
|
});
|
|
216
216
|
}
|
|
217
|
+
else if (validToolkitIds && !validToolkitIds.includes(model.techStack.toolkit)) {
|
|
218
|
+
errors.push({
|
|
219
|
+
path: 'techStack.toolkit',
|
|
220
|
+
message: `Unknown toolkit "${model.techStack.toolkit}". Valid toolkits: ${validToolkitIds.join(', ')}`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
217
223
|
return errors;
|
|
218
224
|
}
|
|
219
225
|
function validateMeta(model) {
|
|
@@ -235,10 +241,7 @@ function validateMeta(model) {
|
|
|
235
241
|
}
|
|
236
242
|
return errors;
|
|
237
243
|
}
|
|
238
|
-
|
|
239
|
-
// Public API
|
|
240
|
-
// =============================================================================
|
|
241
|
-
export function validateModel(model) {
|
|
244
|
+
export function validateModel(model, options) {
|
|
242
245
|
const errors = [];
|
|
243
246
|
errors.push(...validateIdentity(model));
|
|
244
247
|
// Entity uniqueness
|
|
@@ -260,7 +263,7 @@ export function validateModel(model) {
|
|
|
260
263
|
errors.push(...validateLayout(model));
|
|
261
264
|
errors.push(...validateFeatures(model));
|
|
262
265
|
errors.push(...validateTheme(model));
|
|
263
|
-
errors.push(...validateTechStack(model));
|
|
266
|
+
errors.push(...validateTechStack(model, options?.validToolkitIds));
|
|
264
267
|
errors.push(...validateMeta(model));
|
|
265
268
|
return {
|
|
266
269
|
valid: errors.length === 0,
|
package/dist/model/tools.d.ts
CHANGED
|
@@ -10,5 +10,6 @@ import type { PlatformContext } from '@compilr-dev/sdk';
|
|
|
10
10
|
export interface ModelToolsConfig {
|
|
11
11
|
readonly context: PlatformContext;
|
|
12
12
|
readonly cwd?: string;
|
|
13
|
+
readonly validToolkitIds?: readonly string[];
|
|
13
14
|
}
|
|
14
15
|
export declare function createModelTools(config: ModelToolsConfig): Tool<never>[];
|
package/dist/model/tools.js
CHANGED
|
@@ -162,18 +162,22 @@ EXAMPLES:
|
|
|
162
162
|
description: 'Field name (string) for updateField/removeField/renameField, or full field object (for addField). Field object requires: name (camelCase), label, type (string|number|boolean|date|enum), required.',
|
|
163
163
|
},
|
|
164
164
|
relationship: {
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
oneOf: [
|
|
166
|
+
{
|
|
167
|
+
type: 'object',
|
|
168
|
+
additionalProperties: true,
|
|
169
|
+
properties: {
|
|
170
|
+
type: { type: 'string', enum: ['belongsTo', 'hasMany'] },
|
|
171
|
+
target: { type: 'string' },
|
|
172
|
+
fieldName: { type: 'string' },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{ type: 'string' },
|
|
176
|
+
],
|
|
167
177
|
description: 'Relationship object for addRelationship. Requires: type ("belongsTo" or "hasMany"), target (entity name). Optional: fieldName.',
|
|
168
|
-
properties: {
|
|
169
|
-
type: { type: 'string', enum: ['belongsTo', 'hasMany'] },
|
|
170
|
-
target: { type: 'string' },
|
|
171
|
-
fieldName: { type: 'string' },
|
|
172
|
-
},
|
|
173
178
|
},
|
|
174
179
|
updates: {
|
|
175
|
-
type: 'object',
|
|
176
|
-
additionalProperties: true,
|
|
180
|
+
oneOf: [{ type: 'object', additionalProperties: true }, { type: 'string' }],
|
|
177
181
|
description: 'Partial updates object for updateIdentity, updateLayout, updateFeatures, updateTheme, updateTechStack, updateEntity.',
|
|
178
182
|
},
|
|
179
183
|
newName: {
|
|
@@ -218,7 +222,9 @@ EXAMPLES:
|
|
|
218
222
|
// Apply
|
|
219
223
|
const updated = applyOperation(model, operation);
|
|
220
224
|
// Validate result
|
|
221
|
-
const validation = validateModel(updated
|
|
225
|
+
const validation = validateModel(updated, {
|
|
226
|
+
validToolkitIds: config.validToolkitIds,
|
|
227
|
+
});
|
|
222
228
|
if (!validation.valid) {
|
|
223
229
|
const msgs = validation.errors.map((e) => `${e.path}: ${e.message}`).join('; ');
|
|
224
230
|
return createErrorResult(`Operation would produce invalid model: ${msgs}`);
|
|
@@ -237,28 +243,53 @@ EXAMPLES:
|
|
|
237
243
|
},
|
|
238
244
|
});
|
|
239
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* Coerce a value to an object. LLMs sometimes send stringified JSON for nested objects.
|
|
248
|
+
* Returns the parsed object, or null if coercion fails.
|
|
249
|
+
*/
|
|
250
|
+
function coerceToObject(value) {
|
|
251
|
+
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
252
|
+
return value;
|
|
253
|
+
}
|
|
254
|
+
if (typeof value === 'string') {
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(value);
|
|
257
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
258
|
+
return parsed;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// Not valid JSON
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
240
267
|
function buildOperation(input) {
|
|
241
268
|
switch (input.op) {
|
|
242
269
|
case 'addEntity': {
|
|
243
|
-
|
|
244
|
-
|
|
270
|
+
const entity = coerceToObject(input.entity);
|
|
271
|
+
if (!entity) {
|
|
272
|
+
throw new Error(`addEntity requires "entity" as an object with name, pluralName, icon, fields, views, relationships. ` +
|
|
273
|
+
`Got: ${input.entity === undefined ? 'missing' : typeof input.entity}. ` +
|
|
274
|
+
`Example: { "op": "addEntity", "entity": { "name": "Employee", "pluralName": "Employees", "icon": "👤", "fields": [{ "name": "name", "label": "Name", "type": "string", "required": true }], "views": ["list", "detail", "card"], "relationships": [] } }`);
|
|
245
275
|
}
|
|
246
|
-
|
|
247
|
-
return { op: 'addEntity', entity };
|
|
276
|
+
return { op: 'addEntity', entity: entity };
|
|
248
277
|
}
|
|
249
|
-
case 'updateEntity':
|
|
278
|
+
case 'updateEntity': {
|
|
250
279
|
if (typeof input.entity !== 'string')
|
|
251
|
-
throw new Error(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
280
|
+
throw new Error(`updateEntity requires "entity" as a string (entity name). Got: ${input.entity === undefined ? 'missing' : typeof input.entity}`);
|
|
281
|
+
const updates = coerceToObject(input.updates);
|
|
282
|
+
if (!updates)
|
|
283
|
+
throw new Error(`updateEntity requires "updates" object. Got: ${input.updates === undefined ? 'missing' : typeof input.updates}`);
|
|
284
|
+
return { op: 'updateEntity', entity: input.entity, updates };
|
|
285
|
+
}
|
|
255
286
|
case 'removeEntity':
|
|
256
287
|
if (typeof input.entity !== 'string')
|
|
257
|
-
throw new Error(
|
|
288
|
+
throw new Error(`removeEntity requires "entity" as a string (entity name). Got: ${input.entity === undefined ? 'missing' : typeof input.entity}`);
|
|
258
289
|
return { op: 'removeEntity', entity: input.entity, force: input.force };
|
|
259
290
|
case 'renameEntity':
|
|
260
291
|
if (typeof input.entity !== 'string')
|
|
261
|
-
throw new Error(
|
|
292
|
+
throw new Error(`renameEntity requires "entity" as a string (entity name). Got: ${input.entity === undefined ? 'missing' : typeof input.entity}`);
|
|
262
293
|
if (!input.newName)
|
|
263
294
|
throw new Error('renameEntity requires newName');
|
|
264
295
|
return { op: 'renameEntity', entity: input.entity, newName: input.newName };
|
|
@@ -268,36 +299,39 @@ function buildOperation(input) {
|
|
|
268
299
|
return { op: 'reorderEntities', order: input.order };
|
|
269
300
|
case 'addField': {
|
|
270
301
|
if (typeof input.entity !== 'string')
|
|
271
|
-
throw new Error(
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
case 'updateField':
|
|
278
|
-
if (typeof input.entity !== 'string')
|
|
279
|
-
throw new Error('updateField requires entity name (string)');
|
|
280
|
-
if (typeof input.field !== 'string')
|
|
281
|
-
throw new Error('updateField requires field name (string)');
|
|
282
|
-
if (!input.updates)
|
|
283
|
-
throw new Error('updateField requires updates object');
|
|
302
|
+
throw new Error(`addField requires "entity" as a string (entity name). Got: ${input.entity === undefined ? 'missing' : typeof input.entity}`);
|
|
303
|
+
const field = coerceToObject(input.field);
|
|
304
|
+
if (!field)
|
|
305
|
+
throw new Error(`addField requires "field" as an object with name, label, type, required. ` +
|
|
306
|
+
`Got: ${input.field === undefined ? 'missing' : typeof input.field}. ` +
|
|
307
|
+
`Example: { "op": "addField", "entity": "Employee", "field": { "name": "email", "label": "Email", "type": "string", "required": true } }`);
|
|
284
308
|
return {
|
|
285
|
-
op: '
|
|
309
|
+
op: 'addField',
|
|
286
310
|
entity: input.entity,
|
|
287
|
-
field:
|
|
288
|
-
updates: input.updates,
|
|
311
|
+
field: field,
|
|
289
312
|
};
|
|
313
|
+
}
|
|
314
|
+
case 'updateField': {
|
|
315
|
+
if (typeof input.entity !== 'string')
|
|
316
|
+
throw new Error(`updateField requires "entity" as a string (entity name). Got: ${input.entity === undefined ? 'missing' : typeof input.entity}`);
|
|
317
|
+
if (typeof input.field !== 'string')
|
|
318
|
+
throw new Error(`updateField requires "field" as a string (field name). Got: ${input.field === undefined ? 'missing' : typeof input.field}`);
|
|
319
|
+
const updates = coerceToObject(input.updates);
|
|
320
|
+
if (!updates)
|
|
321
|
+
throw new Error(`updateField requires "updates" object. Got: ${input.updates === undefined ? 'missing' : typeof input.updates}`);
|
|
322
|
+
return { op: 'updateField', entity: input.entity, field: input.field, updates };
|
|
323
|
+
}
|
|
290
324
|
case 'removeField':
|
|
291
325
|
if (typeof input.entity !== 'string')
|
|
292
|
-
throw new Error(
|
|
326
|
+
throw new Error(`removeField requires "entity" as a string (entity name). Got: ${input.entity === undefined ? 'missing' : typeof input.entity}`);
|
|
293
327
|
if (typeof input.field !== 'string')
|
|
294
|
-
throw new Error(
|
|
328
|
+
throw new Error(`removeField requires "field" as a string (field name). Got: ${input.field === undefined ? 'missing' : typeof input.field}`);
|
|
295
329
|
return { op: 'removeField', entity: input.entity, field: input.field };
|
|
296
330
|
case 'renameField':
|
|
297
331
|
if (typeof input.entity !== 'string')
|
|
298
|
-
throw new Error(
|
|
332
|
+
throw new Error(`renameField requires "entity" as a string (entity name). Got: ${input.entity === undefined ? 'missing' : typeof input.entity}`);
|
|
299
333
|
if (typeof input.field !== 'string')
|
|
300
|
-
throw new Error(
|
|
334
|
+
throw new Error(`renameField requires "field" as a string (field name). Got: ${input.field === undefined ? 'missing' : typeof input.field}`);
|
|
301
335
|
if (!input.newName)
|
|
302
336
|
throw new Error('renameField requires newName');
|
|
303
337
|
return {
|
|
@@ -308,38 +342,59 @@ function buildOperation(input) {
|
|
|
308
342
|
};
|
|
309
343
|
case 'addRelationship': {
|
|
310
344
|
if (typeof input.entity !== 'string')
|
|
311
|
-
throw new Error(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
345
|
+
throw new Error(`addRelationship requires "entity" as a string (entity name). Got: ${input.entity === undefined ? 'missing' : typeof input.entity}`);
|
|
346
|
+
const relationship = coerceToObject(input.relationship);
|
|
347
|
+
if (!relationship)
|
|
348
|
+
throw new Error(`addRelationship requires "relationship" object with type and target. ` +
|
|
349
|
+
`Got: ${input.relationship === undefined ? 'missing' : typeof input.relationship}. ` +
|
|
350
|
+
`Example: { "op": "addRelationship", "entity": "Task", "relationship": { "type": "belongsTo", "target": "Project" } }`);
|
|
351
|
+
return {
|
|
352
|
+
op: 'addRelationship',
|
|
353
|
+
entity: input.entity,
|
|
354
|
+
relationship: relationship,
|
|
355
|
+
};
|
|
316
356
|
}
|
|
317
357
|
case 'removeRelationship':
|
|
318
358
|
if (typeof input.entity !== 'string')
|
|
319
|
-
throw new Error(
|
|
359
|
+
throw new Error(`removeRelationship requires "entity" as a string (entity name). Got: ${input.entity === undefined ? 'missing' : typeof input.entity}`);
|
|
320
360
|
if (!input.target)
|
|
321
361
|
throw new Error('removeRelationship requires target');
|
|
322
362
|
return { op: 'removeRelationship', entity: input.entity, target: input.target };
|
|
323
|
-
case 'updateIdentity':
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
if (!
|
|
333
|
-
throw new Error(
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return { op: '
|
|
363
|
+
case 'updateIdentity': {
|
|
364
|
+
const updates = coerceToObject(input.updates);
|
|
365
|
+
if (!updates)
|
|
366
|
+
throw new Error(`updateIdentity requires "updates" object. Got: ${input.updates === undefined ? 'missing' : typeof input.updates}. ` +
|
|
367
|
+
`Example: { "op": "updateIdentity", "updates": { "name": "My App", "description": "An app" } }`);
|
|
368
|
+
return { op: 'updateIdentity', updates };
|
|
369
|
+
}
|
|
370
|
+
case 'updateLayout': {
|
|
371
|
+
const updates = coerceToObject(input.updates);
|
|
372
|
+
if (!updates)
|
|
373
|
+
throw new Error(`updateLayout requires "updates" object. Got: ${input.updates === undefined ? 'missing' : typeof input.updates}. ` +
|
|
374
|
+
`Example: { "op": "updateLayout", "updates": { "shell": "sidebar-header" } }`);
|
|
375
|
+
return { op: 'updateLayout', updates };
|
|
376
|
+
}
|
|
377
|
+
case 'updateFeatures': {
|
|
378
|
+
const updates = coerceToObject(input.updates);
|
|
379
|
+
if (!updates)
|
|
380
|
+
throw new Error(`updateFeatures requires "updates" object. Got: ${input.updates === undefined ? 'missing' : typeof input.updates}. ` +
|
|
381
|
+
`Example: { "op": "updateFeatures", "updates": { "dashboard": true } }`);
|
|
382
|
+
return { op: 'updateFeatures', updates };
|
|
383
|
+
}
|
|
384
|
+
case 'updateTheme': {
|
|
385
|
+
const updates = coerceToObject(input.updates);
|
|
386
|
+
if (!updates)
|
|
387
|
+
throw new Error(`updateTheme requires "updates" object. Got: ${input.updates === undefined ? 'missing' : typeof input.updates}. ` +
|
|
388
|
+
`Example: { "op": "updateTheme", "updates": { "primaryColor": "#1976D2" } }`);
|
|
389
|
+
return { op: 'updateTheme', updates };
|
|
390
|
+
}
|
|
391
|
+
case 'updateTechStack': {
|
|
392
|
+
const updates = coerceToObject(input.updates);
|
|
393
|
+
if (!updates)
|
|
394
|
+
throw new Error(`updateTechStack requires "updates" object. Got: ${input.updates === undefined ? 'missing' : typeof input.updates}. ` +
|
|
395
|
+
`Example: { "op": "updateTechStack", "updates": { "toolkit": "react-node" } }`);
|
|
396
|
+
return { op: 'updateTechStack', updates };
|
|
397
|
+
}
|
|
343
398
|
default:
|
|
344
399
|
throw new Error(`Unknown operation: ${input.op}`);
|
|
345
400
|
}
|
|
@@ -369,7 +424,7 @@ function createAppModelValidateTool(config) {
|
|
|
369
424
|
errors: [{ path: '', message: 'No Application Model found' }],
|
|
370
425
|
});
|
|
371
426
|
}
|
|
372
|
-
const result = validateModel(model);
|
|
427
|
+
const result = validateModel(model, { validToolkitIds: config.validToolkitIds });
|
|
373
428
|
return createSuccessResult({
|
|
374
429
|
valid: result.valid,
|
|
375
430
|
errors: result.errors,
|
|
@@ -7,16 +7,21 @@ import { toKebabCase, toCamelCase } from '../../model/naming.js';
|
|
|
7
7
|
export function generateDashboard(model) {
|
|
8
8
|
if (!model.features.dashboard)
|
|
9
9
|
return [];
|
|
10
|
+
const typeImports = model.entities.map((e) => e.name).join(', ');
|
|
11
|
+
// State: counts + recent items
|
|
12
|
+
const stateLines = model.entities
|
|
13
|
+
.map((e) => {
|
|
14
|
+
const camel = toCamelCase(e.name);
|
|
15
|
+
return ` const [${camel}Count, set${e.name}Count] = useState(0);\n const [recent${e.name}s, setRecent${e.name}s] = useState<${e.name}[]>([]);`;
|
|
16
|
+
})
|
|
17
|
+
.join('\n');
|
|
18
|
+
// Fetch: counts + recent items
|
|
10
19
|
const fetchCalls = model.entities
|
|
11
20
|
.map((e) => {
|
|
12
21
|
const apiUrl = '/api/' + toKebabCase(e.pluralName).toLowerCase();
|
|
13
|
-
return ` fetch('${apiUrl}').then(r => r.json()).then((d: ${e.name}[]) => set${e.name}Count(d.length)),`;
|
|
22
|
+
return ` fetch('${apiUrl}').then(r => r.json()).then((d: ${e.name}[]) => { set${e.name}Count(d.length); setRecent${e.name}s(d.slice(-5).reverse()); }),`;
|
|
14
23
|
})
|
|
15
24
|
.join('\n');
|
|
16
|
-
const stateLines = model.entities
|
|
17
|
-
.map((e) => ` const [${toCamelCase(e.name)}Count, set${e.name}Count] = useState(0);`)
|
|
18
|
-
.join('\n');
|
|
19
|
-
const typeImports = model.entities.map((e) => e.name).join(', ');
|
|
20
25
|
const cards = model.entities
|
|
21
26
|
.map((e) => {
|
|
22
27
|
const path = '/' + toKebabCase(e.pluralName).toLowerCase();
|
|
@@ -27,6 +32,21 @@ export function generateDashboard(model) {
|
|
|
27
32
|
</Link>`;
|
|
28
33
|
})
|
|
29
34
|
.join('\n');
|
|
35
|
+
// Find first string field per entity for display name
|
|
36
|
+
const recentRows = model.entities
|
|
37
|
+
.map((e) => {
|
|
38
|
+
const firstStringField = e.fields.find((f) => f.type === 'string');
|
|
39
|
+
const displayExpr = firstStringField ? `item.${firstStringField.name}` : `String(item.id)`;
|
|
40
|
+
const path = '/' + toKebabCase(e.pluralName).toLowerCase();
|
|
41
|
+
return ` {recent${e.name}s.map((item) => (
|
|
42
|
+
<tr key={item.id}>
|
|
43
|
+
<td className="px-4 py-2 text-sm">${e.icon} ${e.name}</td>
|
|
44
|
+
<td className="px-4 py-2 text-sm"><Link to={\`${path}/\${String(item.id)}\`} className="text-primary hover:underline">{${displayExpr}}</Link></td>
|
|
45
|
+
<td className="px-4 py-2 text-sm text-gray-500">{String(item.id)}</td>
|
|
46
|
+
</tr>
|
|
47
|
+
))}`;
|
|
48
|
+
})
|
|
49
|
+
.join('\n');
|
|
30
50
|
return [
|
|
31
51
|
{
|
|
32
52
|
path: 'src/pages/Dashboard.tsx',
|
|
@@ -51,6 +71,22 @@ ${fetchCalls}
|
|
|
51
71
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-${String(Math.min(model.entities.length, 4))}">
|
|
52
72
|
${cards}
|
|
53
73
|
</div>
|
|
74
|
+
|
|
75
|
+
<h2 className="mb-4 mt-8 text-xl font-bold text-gray-900 dark:text-white">Recent Activity</h2>
|
|
76
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
|
77
|
+
<table className="w-full text-left">
|
|
78
|
+
<thead className="bg-gray-50 dark:bg-gray-800">
|
|
79
|
+
<tr>
|
|
80
|
+
<th className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400">Type</th>
|
|
81
|
+
<th className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400">Name</th>
|
|
82
|
+
<th className="px-4 py-2 text-sm font-medium text-gray-500 dark:text-gray-400">ID</th>
|
|
83
|
+
</tr>
|
|
84
|
+
</thead>
|
|
85
|
+
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
86
|
+
${recentRows}
|
|
87
|
+
</tbody>
|
|
88
|
+
</table>
|
|
89
|
+
</div>
|
|
54
90
|
</div>
|
|
55
91
|
);
|
|
56
92
|
}
|
|
@@ -7,6 +7,7 @@ import { toKebabCase } from '../../model/naming.js';
|
|
|
7
7
|
export function generateRouter(model) {
|
|
8
8
|
const imports = [];
|
|
9
9
|
const routes = [];
|
|
10
|
+
const topLevelRoutes = [];
|
|
10
11
|
// Layout import
|
|
11
12
|
imports.push("import Layout from './components/layout/Layout';");
|
|
12
13
|
// Dashboard
|
|
@@ -28,6 +29,24 @@ export function generateRouter(model) {
|
|
|
28
29
|
routes.push(` { path: '${routeBase}/:id', element: <${entity.name}Detail /> },`);
|
|
29
30
|
routes.push(` { path: '${routeBase}/:id/edit', element: <${entity.name}Form /> },`);
|
|
30
31
|
}
|
|
32
|
+
// Settings route (inside Layout)
|
|
33
|
+
if (model.features.settings) {
|
|
34
|
+
imports.push("import Settings from './pages/Settings';");
|
|
35
|
+
routes.push(" { path: 'settings', element: <Settings /> },");
|
|
36
|
+
}
|
|
37
|
+
// Profile route (inside Layout)
|
|
38
|
+
if (model.features.userProfiles) {
|
|
39
|
+
imports.push("import Profile from './pages/Profile';");
|
|
40
|
+
routes.push(" { path: 'profile', element: <Profile /> },");
|
|
41
|
+
}
|
|
42
|
+
// Auth routes (top-level, outside Layout)
|
|
43
|
+
if (model.features.auth) {
|
|
44
|
+
imports.push("import Login from './pages/Login';");
|
|
45
|
+
imports.push("import Register from './pages/Register';");
|
|
46
|
+
topLevelRoutes.push(" { path: '/login', element: <Login /> },");
|
|
47
|
+
topLevelRoutes.push(" { path: '/register', element: <Register /> },");
|
|
48
|
+
}
|
|
49
|
+
const topLevelSection = topLevelRoutes.length > 0 ? '\n' + topLevelRoutes.join('\n') : '';
|
|
31
50
|
return [
|
|
32
51
|
{
|
|
33
52
|
path: 'src/router.tsx',
|
|
@@ -41,7 +60,7 @@ export const router = createBrowserRouter([
|
|
|
41
60
|
children: [
|
|
42
61
|
${routes.join('\n')}
|
|
43
62
|
],
|
|
44
|
-
}
|
|
63
|
+
},${topLevelSection}
|
|
45
64
|
]);
|
|
46
65
|
`,
|
|
47
66
|
},
|
|
@@ -5,7 +5,22 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { toKebabCase } from '../../model/naming.js';
|
|
7
7
|
export function generateShellFiles(model) {
|
|
8
|
-
|
|
8
|
+
const files = [
|
|
9
|
+
generateApp(model),
|
|
10
|
+
generateLayout(model),
|
|
11
|
+
generateSidebar(model),
|
|
12
|
+
generateHeader(model),
|
|
13
|
+
];
|
|
14
|
+
if (model.features.settings) {
|
|
15
|
+
files.push(generateSettingsPage());
|
|
16
|
+
}
|
|
17
|
+
if (model.features.auth) {
|
|
18
|
+
files.push(...generateAuthPages());
|
|
19
|
+
}
|
|
20
|
+
if (model.features.userProfiles) {
|
|
21
|
+
files.push(generateProfilePage());
|
|
22
|
+
}
|
|
23
|
+
return files;
|
|
9
24
|
}
|
|
10
25
|
function generateApp(_model) {
|
|
11
26
|
return {
|
|
@@ -63,15 +78,21 @@ export default function Layout() {
|
|
|
63
78
|
};
|
|
64
79
|
}
|
|
65
80
|
function generateSidebar(model) {
|
|
66
|
-
const
|
|
67
|
-
|
|
81
|
+
const items = [];
|
|
82
|
+
if (model.features.dashboard) {
|
|
83
|
+
items.push(" { label: '\u{1F4CA} Dashboard', path: '/' },");
|
|
84
|
+
}
|
|
85
|
+
for (const e of model.entities) {
|
|
68
86
|
const path = '/' + toKebabCase(e.pluralName).toLowerCase();
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
87
|
+
items.push(` { label: '${e.icon} ${e.pluralName}', path: '${path}' },`);
|
|
88
|
+
}
|
|
89
|
+
if (model.features.settings) {
|
|
90
|
+
items.push(" { label: '\u2699\uFE0F Settings', path: '/settings' },");
|
|
91
|
+
}
|
|
92
|
+
const navItems = items.join('\n');
|
|
72
93
|
return {
|
|
73
94
|
path: 'src/components/layout/Sidebar.tsx',
|
|
74
|
-
content: `import { useState } from 'react';
|
|
95
|
+
content: `import { useState, useEffect } from 'react';
|
|
75
96
|
import { Link, useLocation } from 'react-router-dom';
|
|
76
97
|
|
|
77
98
|
const navItems = [
|
|
@@ -79,9 +100,15 @@ ${navItems}
|
|
|
79
100
|
];
|
|
80
101
|
|
|
81
102
|
export default function Sidebar() {
|
|
82
|
-
const [collapsed, setCollapsed] = useState(
|
|
103
|
+
const [collapsed, setCollapsed] = useState(window.innerWidth < 1024);
|
|
83
104
|
const location = useLocation();
|
|
84
105
|
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const onResize = () => setCollapsed(window.innerWidth < 1024);
|
|
108
|
+
window.addEventListener('resize', onResize);
|
|
109
|
+
return () => window.removeEventListener('resize', onResize);
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
85
112
|
return (
|
|
86
113
|
<aside
|
|
87
114
|
className={\`\${collapsed ? 'w-16' : 'w-64'} flex flex-col border-r border-gray-200 bg-white transition-all dark:border-gray-700 dark:bg-gray-800\`}
|
|
@@ -121,6 +148,7 @@ export default function Sidebar() {
|
|
|
121
148
|
};
|
|
122
149
|
}
|
|
123
150
|
function generateHeader(model) {
|
|
151
|
+
const hasSidebar = model.layout.shell === 'sidebar-header';
|
|
124
152
|
const darkModeToggle = model.features.darkMode
|
|
125
153
|
? `
|
|
126
154
|
<button
|
|
@@ -130,6 +158,10 @@ function generateHeader(model) {
|
|
|
130
158
|
🌓
|
|
131
159
|
</button>`
|
|
132
160
|
: '';
|
|
161
|
+
const appNameSection = !hasSidebar
|
|
162
|
+
? `
|
|
163
|
+
<span className="text-lg font-bold text-gray-900 dark:text-white">${model.identity.name}</span>`
|
|
164
|
+
: '';
|
|
133
165
|
return {
|
|
134
166
|
path: 'src/components/layout/Header.tsx',
|
|
135
167
|
content: `import { useLocation } from 'react-router-dom';
|
|
@@ -141,7 +173,9 @@ export default function Header() {
|
|
|
141
173
|
|
|
142
174
|
return (
|
|
143
175
|
<header className="flex h-14 items-center justify-between border-b border-gray-200 bg-white px-6 dark:border-gray-700 dark:bg-gray-800">
|
|
144
|
-
<
|
|
176
|
+
<div className="flex items-center gap-4">${appNameSection}
|
|
177
|
+
<span className="text-sm text-gray-500 dark:text-gray-400">{breadcrumb}</span>
|
|
178
|
+
</div>
|
|
145
179
|
<div className="flex items-center gap-2">${darkModeToggle}
|
|
146
180
|
</div>
|
|
147
181
|
</header>
|
|
@@ -150,3 +184,105 @@ export default function Header() {
|
|
|
150
184
|
`,
|
|
151
185
|
};
|
|
152
186
|
}
|
|
187
|
+
function generateSettingsPage() {
|
|
188
|
+
return {
|
|
189
|
+
path: 'src/pages/Settings.tsx',
|
|
190
|
+
content: `export default function Settings() {
|
|
191
|
+
return (
|
|
192
|
+
<div>
|
|
193
|
+
<h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Settings</h1>
|
|
194
|
+
<p className="text-gray-500 dark:text-gray-400">
|
|
195
|
+
Application settings will be configured here.
|
|
196
|
+
</p>
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
function generateAuthPages() {
|
|
204
|
+
return [
|
|
205
|
+
{
|
|
206
|
+
path: 'src/pages/Login.tsx',
|
|
207
|
+
content: `import { Link } from 'react-router-dom';
|
|
208
|
+
|
|
209
|
+
export default function Login() {
|
|
210
|
+
return (
|
|
211
|
+
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
|
212
|
+
<div className="w-full max-w-md rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
|
213
|
+
<h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Login</h1>
|
|
214
|
+
<form className="space-y-4">
|
|
215
|
+
<div>
|
|
216
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
|
|
217
|
+
<input type="email" className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
|
218
|
+
</div>
|
|
219
|
+
<div>
|
|
220
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
|
|
221
|
+
<input type="password" className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
|
222
|
+
</div>
|
|
223
|
+
<button type="submit" className="w-full rounded bg-primary px-4 py-2 text-white hover:bg-primary/90">
|
|
224
|
+
Sign In
|
|
225
|
+
</button>
|
|
226
|
+
</form>
|
|
227
|
+
<p className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
228
|
+
Don't have an account? <Link to="/register" className="text-primary hover:underline">Register</Link>
|
|
229
|
+
</p>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
`,
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
path: 'src/pages/Register.tsx',
|
|
238
|
+
content: `import { Link } from 'react-router-dom';
|
|
239
|
+
|
|
240
|
+
export default function Register() {
|
|
241
|
+
return (
|
|
242
|
+
<div className="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-900">
|
|
243
|
+
<div className="w-full max-w-md rounded-lg border border-gray-200 bg-white p-8 shadow-sm dark:border-gray-700 dark:bg-gray-800">
|
|
244
|
+
<h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Register</h1>
|
|
245
|
+
<form className="space-y-4">
|
|
246
|
+
<div>
|
|
247
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
|
|
248
|
+
<input type="text" className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
|
249
|
+
</div>
|
|
250
|
+
<div>
|
|
251
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
|
|
252
|
+
<input type="email" className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
|
253
|
+
</div>
|
|
254
|
+
<div>
|
|
255
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
|
|
256
|
+
<input type="password" className="mt-1 block w-full rounded border border-gray-300 px-3 py-2 dark:border-gray-600 dark:bg-gray-700 dark:text-white" />
|
|
257
|
+
</div>
|
|
258
|
+
<button type="submit" className="w-full rounded bg-primary px-4 py-2 text-white hover:bg-primary/90">
|
|
259
|
+
Create Account
|
|
260
|
+
</button>
|
|
261
|
+
</form>
|
|
262
|
+
<p className="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
263
|
+
Already have an account? <Link to="/login" className="text-primary hover:underline">Login</Link>
|
|
264
|
+
</p>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
`,
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
function generateProfilePage() {
|
|
274
|
+
return {
|
|
275
|
+
path: 'src/pages/Profile.tsx',
|
|
276
|
+
content: `export default function Profile() {
|
|
277
|
+
return (
|
|
278
|
+
<div>
|
|
279
|
+
<h1 className="mb-6 text-2xl font-bold text-gray-900 dark:text-white">Profile</h1>
|
|
280
|
+
<p className="text-gray-500 dark:text-gray-400">
|
|
281
|
+
User profile information will be displayed here.
|
|
282
|
+
</p>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
@@ -32,12 +32,26 @@ VITE_API_URL=http://localhost:3001
|
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
function generateReadme(model) {
|
|
35
|
+
let entitiesSection = '';
|
|
36
|
+
if (model.entities.length > 0) {
|
|
37
|
+
const entityLines = model.entities
|
|
38
|
+
.map((e) => {
|
|
39
|
+
const relCount = e.relationships.length;
|
|
40
|
+
const parts = [`${String(e.fields.length)} fields`];
|
|
41
|
+
if (relCount > 0) {
|
|
42
|
+
parts.push(`${String(relCount)} relationship${relCount > 1 ? 's' : ''}`);
|
|
43
|
+
}
|
|
44
|
+
return `- **${e.name}** (${e.icon}) — ${parts.join(', ')}`;
|
|
45
|
+
})
|
|
46
|
+
.join('\n');
|
|
47
|
+
entitiesSection = `\n## Entities\n\n${entityLines}\n`;
|
|
48
|
+
}
|
|
35
49
|
return {
|
|
36
50
|
path: 'README.md',
|
|
37
51
|
content: `# ${model.identity.name}
|
|
38
52
|
|
|
39
53
|
${model.identity.description}
|
|
40
|
-
|
|
54
|
+
${entitiesSection}
|
|
41
55
|
## Getting Started
|
|
42
56
|
|
|
43
57
|
\`\`\`bash
|