@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.
@@ -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 \`project_document_get_by_type\` to look for PRD, architecture, and design docs
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:
@@ -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
- return { ...r, target: op.newName };
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
  }
@@ -12,4 +12,7 @@ export interface ValidationResult {
12
12
  readonly valid: boolean;
13
13
  readonly errors: readonly ValidationError[];
14
14
  }
15
- export declare function validateModel(model: ApplicationModel): ValidationResult;
15
+ export interface ValidateModelOptions {
16
+ readonly validToolkitIds?: readonly string[];
17
+ }
18
+ export declare function validateModel(model: ApplicationModel, options?: ValidateModelOptions): ValidationResult;
@@ -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,
@@ -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>[];
@@ -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
- type: 'object',
166
- additionalProperties: true,
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
- if (!input.entity || typeof input.entity !== 'object') {
244
- throw new Error('addEntity requires entity object');
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
- const entity = input.entity;
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('updateEntity requires entity name (string)');
252
- if (!input.updates)
253
- throw new Error('updateEntity requires updates object');
254
- return { op: 'updateEntity', entity: input.entity, updates: input.updates };
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('removeEntity requires entity name (string)');
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('renameEntity requires entity name (string)');
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('addField requires entity name (string)');
272
- if (!input.field || typeof input.field !== 'object')
273
- throw new Error('addField requires field object');
274
- const field = input.field;
275
- return { op: 'addField', entity: input.entity, field };
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: 'updateField',
309
+ op: 'addField',
286
310
  entity: input.entity,
287
- field: input.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('removeField requires entity name (string)');
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('removeField requires field name (string)');
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('renameField requires entity name (string)');
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('renameField requires field name (string)');
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('addRelationship requires entity name (string)');
312
- if (!input.relationship)
313
- throw new Error('addRelationship requires relationship object');
314
- const relationship = input.relationship;
315
- return { op: 'addRelationship', entity: input.entity, relationship };
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('removeRelationship requires entity name (string)');
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
- if (!input.updates)
325
- throw new Error('updateIdentity requires updates object');
326
- return { op: 'updateIdentity', updates: input.updates };
327
- case 'updateLayout':
328
- if (!input.updates)
329
- throw new Error('updateLayout requires updates object');
330
- return { op: 'updateLayout', updates: input.updates };
331
- case 'updateFeatures':
332
- if (!input.updates)
333
- throw new Error('updateFeatures requires updates object');
334
- return { op: 'updateFeatures', updates: input.updates };
335
- case 'updateTheme':
336
- if (!input.updates)
337
- throw new Error('updateTheme requires updates object');
338
- return { op: 'updateTheme', updates: input.updates };
339
- case 'updateTechStack':
340
- if (!input.updates)
341
- throw new Error('updateTechStack requires updates object');
342
- return { op: 'updateTechStack', updates: input.updates };
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
- return [generateApp(model), generateLayout(model), generateSidebar(model), generateHeader(model)];
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 navItems = model.entities
67
- .map((e) => {
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
- return ` { label: '${e.icon} ${e.pluralName}', path: '${path}' },`;
70
- })
71
- .join('\n');
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(false);
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
- <span className="text-sm text-gray-500 dark:text-gray-400">{breadcrumb}</span>
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&apos;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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compilr-dev/factory",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "AI-driven application scaffolder for the compilr-dev ecosystem",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",