create-nevela 0.1.0 → 0.1.2

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.
@@ -1,639 +1,719 @@
1
- <?php
2
-
3
- namespace Nevela\Laravel\Generator;
4
-
5
- use Nevela\Laravel\Support\Descriptor;
6
- use Nevela\Laravel\Support\Field;
7
- use Nevela\Laravel\Support\Naming;
8
-
9
- /**
10
- * Turns descriptors into files. Pure: no Laravel, no filesystem — Writer does the I/O.
11
- *
12
- * Laravel output is ordinary Laravel code (model, migration, form request, API resource,
13
- * controller, policy, routes). The web output is a Flare `.resource.ts` so JB's dashboard
14
- * renders the resource without knowing Laravel is behind it.
15
- */
16
- final class ResourceGenerator
17
- {
18
- private const M = '// '.Writer::START;
19
-
20
- private const E = '// '.Writer::END;
21
-
22
- /** @return list<GeneratedFile> */
23
- public function forResource(Descriptor $d, ?string $timestamp = null): array
24
- {
25
- $timestamp ??= date('Y_m_d_His');
26
- $api = GeneratedFile::TARGET_API;
27
-
28
- return [
29
- new GeneratedFile($api, "app/Models/{$d->name}.php", $this->model($d)),
30
- new GeneratedFile($api, "database/migrations/{$timestamp}_create_{$d->table}_table.php", $this->migration($d), GeneratedFile::MODE_ONCE, "database/migrations/*_create_{$d->table}_table.php"),
31
- new GeneratedFile($api, "app/Http/Requests/Nevela/{$d->name}Request.php", $this->request($d)),
32
- new GeneratedFile($api, "app/Http/Resources/Nevela/{$d->name}Resource.php", $this->resource($d)),
33
- new GeneratedFile($api, "app/Http/Controllers/Api/{$d->name}Controller.php", $this->controller($d)),
34
- new GeneratedFile($api, "app/Policies/{$d->name}Policy.php", $this->policy($d), GeneratedFile::MODE_ONCE),
35
- new GeneratedFile(GeneratedFile::TARGET_WEB, 'resources/'.Naming::kebab($d->name).'.resource.ts', $this->typescript($d)),
36
- ...$this->pages($d),
37
- ];
38
- }
39
-
40
- /**
41
- * The dashboard pages for a resource, in Flare's layout: list, new, detail and edit,
42
- * each with its loading skeleton. They only name the descriptor — Flare's components
43
- * render everything from it — so they are written once and are yours afterwards.
44
- *
45
- * @return list<GeneratedFile>
46
- */
47
- public function pages(Descriptor $d): array
48
- {
49
- $var = Naming::camel($d->name).'Resource';
50
- $import = "import {$var} from \"@/resources/".Naming::kebab($d->name).'.resource";';
51
- $fields = count($d->fields);
52
- $columns = min(6, $fields);
53
- $rows = $fields + 2;
54
- $formLoading = <<<TSX
55
- import { FormSkeleton, PageHeaderSkeleton } from "@/components/dashboard/skeletons";
56
-
57
- export default function Loading() {
58
- return (
59
- <>
60
- <PageHeaderSkeleton actions={0} />
61
- <FormSkeleton fields={{$fields}} />
62
- </>
63
- );
64
- }
65
-
66
- TSX;
67
-
68
- $pages = [
69
- 'page.tsx' => <<<TSX
70
- import { PageHeader } from "@/components/dashboard/page-header";
71
- import { ResourceStats } from "@/components/dashboard/resource-stats";
72
- import { ResourceTable } from "@/components/dashboard/resource-table";
73
- import type { SearchParams } from "@/components/dashboard/query";
74
- {$import}
75
-
76
- export const metadata = { title: {$var}.pluralLabel };
77
-
78
- export default async function {$d->name}ListPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
79
- return (
80
- <>
81
- <PageHeader title={{$var}.pluralLabel} crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: {$var}.pluralLabel }]} />
82
- <ResourceStats resource={{$var}} />
83
- <ResourceTable resource={{$var}} searchParams={await searchParams} />
84
- </>
85
- );
86
- }
87
-
88
- TSX,
89
- 'loading.tsx' => <<<TSX
90
- import { ResourceListSkeleton } from "@/components/dashboard/skeletons";
91
-
92
- export default function Loading() {
93
- return <ResourceListSkeleton columns={{$columns}} />;
94
- }
95
-
96
- TSX,
97
- 'new/page.tsx' => <<<TSX
98
- import { ResourceFormPage } from "@/components/dashboard/resource-form-page";
99
- {$import}
100
-
101
- export default function New{$d->name}Page() {
102
- return <ResourceFormPage resource={{$var}} />;
103
- }
104
-
105
- TSX,
106
- 'new/loading.tsx' => $formLoading,
107
- '[id]/page.tsx' => <<<TSX
108
- import { RecordDetail } from "@/components/dashboard/record-detail";
109
- {$import}
110
-
111
- export default async function {$d->name}DetailPage({ params }: { params: Promise<{ id: string }> }) {
112
- return <RecordDetail resource={{$var}} id={(await params).id} />;
113
- }
114
-
115
- TSX,
116
- '[id]/loading.tsx' => <<<TSX
117
- import { DetailSkeleton, PageHeaderSkeleton } from "@/components/dashboard/skeletons";
118
-
119
- export default function Loading() {
120
- return (
121
- <>
122
- <PageHeaderSkeleton />
123
- <DetailSkeleton rows={{$rows}} />
124
- </>
125
- );
126
- }
127
-
128
- TSX,
129
- '[id]/edit/page.tsx' => <<<TSX
130
- import { ResourceFormPage } from "@/components/dashboard/resource-form-page";
131
- {$import}
132
-
133
- export default async function Edit{$d->name}Page({ params }: { params: Promise<{ id: string }> }) {
134
- return <ResourceFormPage resource={{$var}} id={(await params).id} />;
135
- }
136
-
137
- TSX,
138
- '[id]/edit/loading.tsx' => $formLoading,
139
- ];
140
-
141
- $files = [];
142
- foreach ($pages as $path => $contents) {
143
- $header = "// Generated once by Nevela for {$d->name}. This file is yours.\n";
144
- $files[] = new GeneratedFile(GeneratedFile::TARGET_WEB, "app/dashboard/{$d->slug}/{$path}", $header.$contents, GeneratedFile::MODE_ONCE);
145
- }
146
-
147
- return $files;
148
- }
149
-
150
- /**
151
- * The web app's registry of every descriptor: what the dashboard's sidebar, home page
152
- * and stores are built from.
153
- *
154
- * @param list<Descriptor> $all
155
- */
156
- public function registry(array $all): GeneratedFile
157
- {
158
- usort($all, fn (Descriptor $a, Descriptor $b) => strcmp($a->name, $b->name));
159
- $imports = [];
160
- $names = [];
161
- foreach ($all as $d) {
162
- $var = Naming::camel($d->name).'Resource';
163
- $imports[] = "import {$var} from \"./".Naming::kebab($d->name).'.resource";';
164
- $names[] = $var;
165
- }
166
- $list = implode(', ', $names);
167
- // An app with no resources yet still needs the registry to exist and be empty.
168
- $body = $names === []
169
- ? 'export const resources = [] as const;'
170
- : implode("\n", $imports)."\n\nexport { {$list} };\nexport const resources = [{$list}] as const;";
171
- $m = self::M;
172
- $e = self::E;
173
-
174
- return new GeneratedFile(GeneratedFile::TARGET_WEB, 'resources/index.ts', <<<TS
175
- // Registry of every resource descriptor (maintained by `php artisan nevela:generate`).
176
- {$m}
177
- {$body}
178
- {$e}
179
-
180
- TS);
181
- }
182
-
183
- /** @param list<Descriptor> $all */
184
- public function routes(array $all): GeneratedFile
185
- {
186
- usort($all, fn (Descriptor $a, Descriptor $b) => strcmp($a->slug, $b->slug));
187
- $lines = [];
188
- foreach ($all as $d) {
189
- $controller = "\\App\\Http\\Controllers\\Api\\{$d->name}Controller::class";
190
- $lines[] = "Route::get('{$d->slug}/_stats', [{$controller}, 'stats'])->name('{$d->slug}.stats');";
191
- $lines[] = "Route::apiResource('{$d->slug}', {$controller});";
192
- }
193
- $body = implode("\n", $lines);
194
- $m = self::M;
195
- $e = self::E;
196
-
197
- return new GeneratedFile(GeneratedFile::TARGET_API, 'routes/nevela.php', <<<PHP
198
- <?php
199
-
200
- use Illuminate\Support\Facades\Route;
201
-
202
- // Loaded by Nevela under the prefix and middleware in config/nevela.php
203
- // (default: /api, auth:sanctum). Add your own routes below the generated block.
204
- {$m}
205
- {$body}
206
- {$e}
207
-
208
- PHP);
209
- }
210
-
211
- public function model(Descriptor $d): string
212
- {
213
- $fillable = implode(', ', array_map(fn (Field $f) => "'{$f->column()}'", $d->fields));
214
- $casts = [];
215
- foreach ($d->fields as $f) {
216
- $cast = match ($f->kind) {
217
- 'int' => 'integer',
218
- 'float' => 'float',
219
- 'boolean' => 'boolean',
220
- 'date' => 'date:Y-m-d',
221
- 'datetime' => 'datetime',
222
- default => null,
223
- };
224
- if ($cast) {
225
- $casts[] = " '{$f->column()}' => '{$cast}',";
226
- }
227
- }
228
- $castBlock = $casts ? implode("\n", $casts) : '';
229
- $m = self::M;
230
- $e = self::E;
231
-
232
- return <<<PHP
233
- <?php
234
-
235
- namespace App\Models;
236
-
237
- use Illuminate\Database\Eloquent\Concerns\HasUuids;
238
- use Illuminate\Database\Eloquent\Model;
239
-
240
- class {$d->name} extends Model
241
- {
242
- use HasUuids;
243
-
244
- protected \$table = '{$d->table}';
245
-
246
- {$m}
247
- protected \$fillable = [{$fillable}];
248
-
249
- protected function casts(): array
250
- {
251
- return [
252
- {$castBlock}
253
- ];
254
- }
255
- {$e}
256
-
257
- // Relationships, scopes and accessors go here — outside the generated block.
258
- }
259
-
260
- PHP;
261
- }
262
-
263
- public function migration(Descriptor $d): string
264
- {
265
- $columns = [];
266
- foreach ($d->fields as $f) {
267
- $c = $f->column();
268
- $line = match (true) {
269
- $f->kind === 'text' => "\$table->text('{$c}')",
270
- $f->kind === 'int' => "\$table->integer('{$c}')",
271
- $f->kind === 'float' && $f->format === 'money' => "\$table->decimal('{$c}', 12, 2)",
272
- $f->kind === 'float' => "\$table->double('{$c}')",
273
- $f->kind === 'boolean' => "\$table->boolean('{$c}')->default(false)",
274
- $f->kind === 'date' => "\$table->date('{$c}')",
275
- $f->kind === 'datetime' => "\$table->dateTime('{$c}')",
276
- $f->format === 'url' => "\$table->string('{$c}', 2048)",
277
- default => "\$table->string('{$c}')",
278
- };
279
- if (! $f->required) {
280
- $line .= '->nullable()';
281
- }
282
- if ($f->unique) {
283
- $line .= '->unique()';
284
- } elseif ($f->kind === 'enum') {
285
- $line .= '->index()';
286
- }
287
- $columns[] = " {$line};";
288
- }
289
- $cols = implode("\n", $columns);
290
-
291
- return <<<PHP
292
- <?php
293
-
294
- use Illuminate\Database\Migrations\Migration;
295
- use Illuminate\Database\Schema\Blueprint;
296
- use Illuminate\Support\Facades\Schema;
297
-
298
- // Generated once by Nevela. Later descriptor changes need a new migration of your own.
299
- return new class extends Migration
300
- {
301
- public function up(): void
302
- {
303
- Schema::create('{$d->table}', function (Blueprint \$table) {
304
- \$table->uuid('id')->primary();
305
- {$cols}
306
- \$table->timestamps();
307
- });
308
- }
309
-
310
- public function down(): void
311
- {
312
- Schema::dropIfExists('{$d->table}');
313
- }
314
- };
315
-
316
- PHP;
317
- }
318
-
319
- public function request(Descriptor $d): string
320
- {
321
- $var = $d->variable();
322
- $param = $d->routeParameter();
323
- $rules = [];
324
- $columns = [];
325
- foreach ($d->fields as $f) {
326
- $rules[] = " '{$f->name}' => [".implode(', ', $this->rules($d, $f)).'],';
327
- $columns[] = "'{$f->name}' => '{$f->column()}'";
328
- }
329
- $ruleLines = implode("\n", $rules);
330
- $columnMap = implode(', ', $columns);
331
- $m = self::M;
332
- $e = self::E;
333
-
334
- return <<<PHP
335
- <?php
336
-
337
- namespace App\Http\Requests\Nevela;
338
-
339
- use App\Models\\{$d->name};
340
- use Illuminate\Support\Facades\Gate;
341
- use Illuminate\Validation\Rule;
342
- use Nevela\Laravel\Http\ResourceRequest;
343
-
344
- /**
345
- * Validation for creating (POST), replacing (PUT) and patching (PATCH) a {$d->label}.
346
- * PATCH makes every rule "sometimes"; PUT clears optional fields left out.
347
- */
348
- class {$d->name}Request extends ResourceRequest
349
- {
350
- public function authorize(): bool
351
- {
352
- \${$var} = \$this->route('{$param}');
353
-
354
- return \${$var} instanceof {$d->name}
355
- ? Gate::allows('update', \${$var})
356
- : Gate::allows('create', {$d->name}::class);
357
- }
358
-
359
- {$m}
360
- public function rules(): array
361
- {
362
- \${$var} = \$this->route('{$param}');
363
-
364
- return [
365
- {$ruleLines}
366
- ];
367
- }
368
-
369
- protected function columns(): array
370
- {
371
- return [{$columnMap}];
372
- }
373
- {$e}
374
- }
375
-
376
- PHP;
377
- }
378
-
379
- /** @return list<string> PHP expressions */
380
- private function rules(Descriptor $d, Field $f): array
381
- {
382
- $rules = [$f->required ? "'required'" : "'nullable'"];
383
- $type = match (true) {
384
- $f->format === 'email' => ["'email'", "'max:255'"],
385
- $f->format === 'url' => ["'url'", "'max:2048'"],
386
- $f->format === 'tel' => ["'string'", "'max:32'"],
387
- $f->format === 'slug' => ["'string'", "'alpha_dash'", "'max:255'"],
388
- $f->format === 'color' => ["'string'", "'regex:/^#[0-9a-fA-F]{6}$/'"],
389
- $f->kind === 'string' => ["'string'", "'max:255'"],
390
- $f->kind === 'text' => ["'string'", "'max:65535'"],
391
- $f->kind === 'int' && $f->format === 'percent' => ["'integer'", "'between:0,100'"],
392
- $f->kind === 'int' => ["'integer'"],
393
- $f->kind === 'float' && $f->format === 'percent' => ["'numeric'", "'between:0,100'"],
394
- $f->kind === 'float' => ["'numeric'"],
395
- $f->kind === 'boolean' => ["'boolean'"],
396
- $f->kind === 'date' => ["'date_format:Y-m-d'"],
397
- $f->kind === 'datetime' => ["'date'"],
398
- $f->kind === 'enum' => ['Rule::in(['.implode(', ', array_map(fn ($o) => "'{$o}'", $f->options)).'])'],
399
- };
400
- $rules = [...$rules, ...$type];
401
- if ($f->unique) {
402
- $rules[] = "Rule::unique('{$d->table}', '{$f->column()}')->ignore(\${$d->variable()})";
403
- }
404
-
405
- return $rules;
406
- }
407
-
408
- public function resource(Descriptor $d): string
409
- {
410
- $lines = [];
411
- foreach ($d->fields as $f) {
412
- $value = match ($f->kind) {
413
- 'date' => "\$this->{$f->column()}?->format('Y-m-d')",
414
- 'datetime' => "\$this->{$f->column()}?->toJSON()",
415
- default => "\$this->{$f->column()}",
416
- };
417
- $lines[] = " '{$f->name}' => {$value},";
418
- }
419
- $body = implode("\n", $lines);
420
- $m = self::M;
421
- $e = self::E;
422
-
423
- return <<<PHP
424
- <?php
425
-
426
- namespace App\Http\Resources\Nevela;
427
-
428
- use Illuminate\Http\Request;
429
- use Illuminate\Http\Resources\Json\JsonResource;
430
-
431
- /** A {$d->label} as Flare's client expects it: camelCase keys, unwrapped. */
432
- class {$d->name}Resource extends JsonResource
433
- {
434
- public static \$wrap = null;
435
-
436
- public function toArray(Request \$request): array
437
- {
438
- return [
439
- 'id' => \$this->id,
440
- {$m}
441
- {$body}
442
- {$e}
443
- 'createdAt' => \$this->created_at?->toJSON(),
444
- 'updatedAt' => \$this->updated_at?->toJSON(),
445
- ];
446
- }
447
- }
448
-
449
- PHP;
450
- }
451
-
452
- public function controller(Descriptor $d): string
453
- {
454
- $var = $d->variable();
455
- $m = self::M;
456
- $e = self::E;
457
-
458
- return <<<PHP
459
- <?php
460
-
461
- namespace App\Http\Controllers\Api;
462
-
463
- use App\Http\Controllers\Controller;
464
- use App\Http\Requests\Nevela\\{$d->name}Request;
465
- use App\Http\Resources\Nevela\\{$d->name}Resource;
466
- use App\Models\\{$d->name};
467
- use Illuminate\Http\JsonResponse;
468
- use Illuminate\Http\Request;
469
- use Illuminate\Http\Response;
470
- use Illuminate\Support\Facades\Gate;
471
- use Nevela\Laravel\Nevela;
472
-
473
- /**
474
- * {$d->pluralLabel} over Flare's REST contract:
475
- * GET /{$d->slug}?page&perPage&sort=-createdAt&q&filter[field] → { data, meta }.
476
- */
477
- class {$d->name}Controller extends Controller
478
- {
479
- {$m}
480
- public function index(Request \$request): JsonResponse
481
- {
482
- Gate::authorize('viewAny', {$d->name}::class);
483
-
484
- return Nevela::list({$d->name}::query(), '{$d->name}', \$request, {$d->name}Resource::class);
485
- }
486
-
487
- public function stats(Request \$request): JsonResponse
488
- {
489
- Gate::authorize('viewAny', {$d->name}::class);
490
-
491
- return Nevela::stats({$d->name}::query(), '{$d->name}', \$request);
492
- }
493
-
494
- public function store({$d->name}Request \$request): JsonResponse
495
- {
496
- \${$var} = {$d->name}::create(\$request->values());
497
-
498
- return (new {$d->name}Resource(\${$var}->refresh()))
499
- ->response()
500
- ->setStatusCode(201)
501
- ->header('Location', \$request->url().'/'.\${$var}->getKey());
502
- }
503
-
504
- public function show({$d->name} \${$var}): {$d->name}Resource
505
- {
506
- Gate::authorize('view', \${$var});
507
-
508
- return new {$d->name}Resource(\${$var});
509
- }
510
-
511
- public function update({$d->name}Request \$request, {$d->name} \${$var}): {$d->name}Resource
512
- {
513
- \${$var}->update(\$request->values());
514
-
515
- return new {$d->name}Resource(\${$var}->refresh());
516
- }
517
-
518
- public function destroy({$d->name} \${$var}): Response
519
- {
520
- Gate::authorize('delete', \${$var});
521
- \${$var}->delete();
522
-
523
- return response()->noContent();
524
- }
525
- {$e}
526
- }
527
-
528
- PHP;
529
- }
530
-
531
- public function policy(Descriptor $d): string
532
- {
533
- $var = $d->variable();
534
-
535
- return <<<PHP
536
- <?php
537
-
538
- namespace App\Policies;
539
-
540
- use App\Models\\{$d->name};
541
- use App\Models\User;
542
-
543
- /**
544
- * Who may do what with {$d->pluralLabel}. Generated once — this file is yours.
545
- *
546
- * Default: any signed-in user (routes already require auth:sanctum). Tighten this
547
- * before production, e.g. `return in_array(\$user->role, ['admin', 'staff']);`.
548
- */
549
- class {$d->name}Policy
550
- {
551
- public function viewAny(User \$user): bool
552
- {
553
- return true;
554
- }
555
-
556
- public function view(User \$user, {$d->name} \${$var}): bool
557
- {
558
- return true;
559
- }
560
-
561
- public function create(User \$user): bool
562
- {
563
- return true;
564
- }
565
-
566
- public function update(User \$user, {$d->name} \${$var}): bool
567
- {
568
- return true;
569
- }
570
-
571
- public function delete(User \$user, {$d->name} \${$var}): bool
572
- {
573
- return true;
574
- }
575
- }
576
-
577
- PHP;
578
- }
579
-
580
- public function typescript(Descriptor $d): string
581
- {
582
- $j = fn (mixed $v) => json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
583
- $config = [
584
- " name: {$j($d->name)},",
585
- " table: {$j($d->table)},",
586
- " slug: {$j($d->slug)},",
587
- " label: {$j($d->label)},",
588
- " pluralLabel: {$j($d->pluralLabel)},",
589
- ];
590
- if ($d->icon) {
591
- $config[] = " icon: {$j($d->icon)},";
592
- }
593
- if ($d->group) {
594
- $config[] = " group: {$j($d->group)},";
595
- }
596
- if ($d->titleField) {
597
- $config[] = " titleField: {$j($d->titleField)},";
598
- }
599
- $fields = [];
600
- foreach ($d->fields as $f) {
601
- $options = array_filter([
602
- 'required' => $f->required ? null : 'false',
603
- 'unique' => $f->unique ? 'true' : null,
604
- 'label' => $f->label ? $j($f->label) : null,
605
- 'format' => in_array($f->format, ['money', 'percent', 'rating'], true) ? $j($f->format) : null,
606
- ]);
607
- $opts = $options ? '{ '.implode(', ', array_map(fn ($k, $v) => "{$k}: {$v}", array_keys($options), $options)).' }' : '';
608
- $call = match (true) {
609
- $f->kind === 'enum' => "field.enum({$j($f->options)}".($opts ? ", {$opts}" : '').')',
610
- $f->kind === 'string' && $f->format !== null => "field.{$f->format}({$opts})",
611
- default => "field.{$f->kind}({$opts})",
612
- };
613
- $fields[] = " {$f->name}: {$call},";
614
- }
615
- $configLines = implode("\n", $config);
616
- $fieldLines = implode("\n", $fields);
617
- $file = Naming::kebab($d->name);
618
- $m = self::M;
619
- $e = self::E;
620
-
621
- return <<<TS
622
- // Generated by Nevela from nevela/resources/{$file}.json in the Laravel app.
623
- // Laravel owns validation, authorization and persistence; this descriptor tells the
624
- // Flare dashboard how to render the resource. Change fields in the JSON and run
625
- // `php artisan nevela:generate`. Display-only options may go below the block.
626
- import { defineResource, field } from "@flaredev/core";
627
-
628
- export default defineResource({
629
- {$m}
630
- {$configLines}
631
- fields: {
632
- {$fieldLines}
633
- },
634
- {$e}
635
- });
636
-
637
- TS;
638
- }
639
- }
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Generator;
4
+
5
+ use Nevela\Laravel\Support\Descriptor;
6
+ use Nevela\Laravel\Support\Field;
7
+ use Nevela\Laravel\Support\Naming;
8
+
9
+ /**
10
+ * Turns descriptors into files. Pure: no Laravel, no filesystem — Writer does the I/O.
11
+ *
12
+ * Laravel output is ordinary Laravel code (model, migration, form request, API resource,
13
+ * controller, policy, routes). The web output is a Flare `.resource.ts` so JB's dashboard
14
+ * renders the resource without knowing Laravel is behind it.
15
+ */
16
+ final class ResourceGenerator
17
+ {
18
+ private const M = '// '.Writer::START;
19
+
20
+ private const E = '// '.Writer::END;
21
+
22
+ /** @return list<GeneratedFile> */
23
+ public function forResource(Descriptor $d, ?string $timestamp = null): array
24
+ {
25
+ $timestamp ??= date('Y_m_d_His');
26
+ $api = GeneratedFile::TARGET_API;
27
+
28
+ return [
29
+ new GeneratedFile($api, "app/Models/{$d->name}.php", $this->model($d)),
30
+ new GeneratedFile($api, "database/migrations/{$timestamp}_create_{$d->table}_table.php", $this->migration($d), GeneratedFile::MODE_ONCE, "database/migrations/*_create_{$d->table}_table.php"),
31
+ new GeneratedFile($api, "app/Http/Requests/Nevela/{$d->name}Request.php", $this->request($d)),
32
+ new GeneratedFile($api, "app/Http/Resources/Nevela/{$d->name}Resource.php", $this->resource($d)),
33
+ new GeneratedFile($api, "app/Http/Controllers/Api/{$d->name}Controller.php", $this->controller($d)),
34
+ new GeneratedFile($api, "app/Policies/{$d->name}Policy.php", $this->policy($d), GeneratedFile::MODE_ONCE),
35
+ new GeneratedFile(GeneratedFile::TARGET_WEB, 'resources/'.Naming::kebab($d->name).'.resource.ts', $this->typescript($d)),
36
+ ...$this->pages($d),
37
+ ];
38
+ }
39
+
40
+ /**
41
+ * The dashboard pages for a resource, in Flare's layout: list, new, detail and edit,
42
+ * each with its loading skeleton. They only name the descriptor — Flare's components
43
+ * render everything from it — so they are written once and are yours afterwards.
44
+ *
45
+ * @return list<GeneratedFile>
46
+ */
47
+ public function pages(Descriptor $d): array
48
+ {
49
+ $var = Naming::camel($d->name).'Resource';
50
+ $import = "import {$var} from \"@/resources/".Naming::kebab($d->name).'.resource";';
51
+ $fields = count($d->fields);
52
+ $columns = min(6, $fields);
53
+ $rows = $fields + 2;
54
+ $formLoading = <<<TSX
55
+ import { FormSkeleton, PageHeaderSkeleton } from "@/components/dashboard/skeletons";
56
+
57
+ export default function Loading() {
58
+ return (
59
+ <>
60
+ <PageHeaderSkeleton actions={0} />
61
+ <FormSkeleton fields={{$fields}} />
62
+ </>
63
+ );
64
+ }
65
+
66
+ TSX;
67
+
68
+ $pages = [
69
+ 'page.tsx' => <<<TSX
70
+ import { PageHeader } from "@/components/dashboard/page-header";
71
+ import { ResourceStats } from "@/components/dashboard/resource-stats";
72
+ import { ResourceTable } from "@/components/dashboard/resource-table";
73
+ import type { SearchParams } from "@/components/dashboard/query";
74
+ {$import}
75
+
76
+ export const metadata = { title: {$var}.pluralLabel };
77
+
78
+ export default async function {$d->name}ListPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
79
+ return (
80
+ <>
81
+ <PageHeader title={{$var}.pluralLabel} crumbs={[{ label: "Dashboard", href: "/dashboard" }, { label: {$var}.pluralLabel }]} />
82
+ <ResourceStats resource={{$var}} />
83
+ <ResourceTable resource={{$var}} searchParams={await searchParams} />
84
+ </>
85
+ );
86
+ }
87
+
88
+ TSX,
89
+ 'loading.tsx' => <<<TSX
90
+ import { ResourceListSkeleton } from "@/components/dashboard/skeletons";
91
+
92
+ export default function Loading() {
93
+ return <ResourceListSkeleton columns={{$columns}} />;
94
+ }
95
+
96
+ TSX,
97
+ 'new/page.tsx' => <<<TSX
98
+ import { ResourceFormPage } from "@/components/dashboard/resource-form-page";
99
+ {$import}
100
+
101
+ export default function New{$d->name}Page() {
102
+ return <ResourceFormPage resource={{$var}} />;
103
+ }
104
+
105
+ TSX,
106
+ 'new/loading.tsx' => $formLoading,
107
+ '[id]/page.tsx' => <<<TSX
108
+ import { RecordDetail } from "@/components/dashboard/record-detail";
109
+ {$import}
110
+
111
+ export default async function {$d->name}DetailPage({ params }: { params: Promise<{ id: string }> }) {
112
+ return <RecordDetail resource={{$var}} id={(await params).id} />;
113
+ }
114
+
115
+ TSX,
116
+ '[id]/loading.tsx' => <<<TSX
117
+ import { DetailSkeleton, PageHeaderSkeleton } from "@/components/dashboard/skeletons";
118
+
119
+ export default function Loading() {
120
+ return (
121
+ <>
122
+ <PageHeaderSkeleton />
123
+ <DetailSkeleton rows={{$rows}} />
124
+ </>
125
+ );
126
+ }
127
+
128
+ TSX,
129
+ '[id]/edit/page.tsx' => <<<TSX
130
+ import { ResourceFormPage } from "@/components/dashboard/resource-form-page";
131
+ {$import}
132
+
133
+ export default async function Edit{$d->name}Page({ params }: { params: Promise<{ id: string }> }) {
134
+ return <ResourceFormPage resource={{$var}} id={(await params).id} />;
135
+ }
136
+
137
+ TSX,
138
+ '[id]/edit/loading.tsx' => $formLoading,
139
+ ];
140
+
141
+ $files = [];
142
+ foreach ($pages as $path => $contents) {
143
+ $header = "// Generated once by Nevela for {$d->name}. This file is yours.\n";
144
+ $files[] = new GeneratedFile(GeneratedFile::TARGET_WEB, "app/dashboard/{$d->slug}/{$path}", $header.$contents, GeneratedFile::MODE_ONCE);
145
+ }
146
+
147
+ return $files;
148
+ }
149
+
150
+ /**
151
+ * `php nevela <command>` at the top of the project, so nobody has to `cd` into the
152
+ * Laravel app first. It hands the command to artisan in the same process: the arguments
153
+ * arrive exactly as they were typed, with no second round of shell quoting.
154
+ *
155
+ * @param string $api The Laravel app's folder relative to the project root, e.g. "apps/api"
156
+ */
157
+ public function launcher(string $api): GeneratedFile
158
+ {
159
+ $api = trim(str_replace('\\', '/', $api), '/');
160
+ $m = self::M;
161
+ $e = self::E;
162
+
163
+ return new GeneratedFile(GeneratedFile::TARGET_ROOT, 'nevela', <<<PHP
164
+ #!/usr/bin/env php
165
+ <?php
166
+
167
+ // Run Nevela from the top of the project: php nevela <command>
168
+ // Maintained by `php artisan nevela:generate`. Add your own shortcuts below the block.
169
+ {$m}
170
+ \$api = __DIR__.'/{$api}';
171
+ \$args = array_slice(\$_SERVER['argv'], 1);
172
+ \$name = array_shift(\$args) ?? 'help';
173
+
174
+ // What you type => the artisan command it runs.
175
+ \$nevela = ['resource', 'generate', 'seed', 'user', 'update'];
176
+ \$artisan = ['migrate', 'tinker', 'test', 'serve'];
177
+
178
+ if (\$name === 'dev') {
179
+ // The API and the dashboard together.
180
+ passthru('node '.escapeshellarg(__DIR__.'/scripts/dev.mjs'), \$status);
181
+ exit(\$status);
182
+ }
183
+
184
+ if (in_array(\$name, \$nevela, true)) {
185
+ // A new resource is no use until its table exists, so make it in the same step.
186
+ if (\$name === 'resource' && ! in_array('--no-migrate', \$args, true)) {
187
+ \$args[] = '--migrate';
188
+ }
189
+ \$args = array_values(array_diff(\$args, ['--no-migrate']));
190
+ \$command = ['nevela:'.\$name, ...\$args];
191
+ } elseif (in_array(\$name, \$artisan, true)) {
192
+ \$command = [\$name, ...\$args];
193
+ } elseif (\$name === 'artisan') {
194
+ \$command = \$args; // anything else: php nevela artisan route:list
195
+ } else {
196
+ echo <<<'HELP'
197
+
198
+ Nevela, from the top of your project.
199
+
200
+ php nevela resource Product --fields="name:string, price:money" add a resource and migrate
201
+ php nevela generate regenerate after editing a descriptor
202
+ php nevela seed Product fill a resource with records
203
+ php nevela user create someone who can sign in
204
+ php nevela update update Nevela and the dashboard
205
+ php nevela dev run the API and the dashboard
206
+
207
+ php nevela migrate php artisan migrate
208
+ php nevela artisan <command> any other artisan command
209
+
210
+
211
+ HELP;
212
+ exit(in_array(\$name, ['help', '--help', '-h'], true) ? 0 : 1);
213
+ }
214
+
215
+ if (! is_file(\$api.'/artisan')) {
216
+ fwrite(STDERR, "There is no Laravel app at {\$api}.\\n");
217
+ exit(1);
218
+ }
219
+
220
+ chdir(\$api);
221
+ \$_SERVER['argv'] = \$argv = ['artisan', ...\$command];
222
+ \$_SERVER['argc'] = \$argc = count(\$argv);
223
+
224
+ require \$api.'/artisan';
225
+ {$e}
226
+
227
+ PHP);
228
+ }
229
+
230
+ /**
231
+ * The web app's registry of every descriptor: what the dashboard's sidebar, home page
232
+ * and stores are built from.
233
+ *
234
+ * @param list<Descriptor> $all
235
+ */
236
+ public function registry(array $all): GeneratedFile
237
+ {
238
+ usort($all, fn (Descriptor $a, Descriptor $b) => strcmp($a->name, $b->name));
239
+ $imports = [];
240
+ $names = [];
241
+ foreach ($all as $d) {
242
+ $var = Naming::camel($d->name).'Resource';
243
+ $imports[] = "import {$var} from \"./".Naming::kebab($d->name).'.resource";';
244
+ $names[] = $var;
245
+ }
246
+ $list = implode(', ', $names);
247
+ // An app with no resources yet still needs the registry to exist and be empty.
248
+ $body = $names === []
249
+ ? 'export const resources = [] as const;'
250
+ : implode("\n", $imports)."\n\nexport { {$list} };\nexport const resources = [{$list}] as const;";
251
+ $m = self::M;
252
+ $e = self::E;
253
+
254
+ return new GeneratedFile(GeneratedFile::TARGET_WEB, 'resources/index.ts', <<<TS
255
+ // Registry of every resource descriptor (maintained by `php artisan nevela:generate`).
256
+ {$m}
257
+ {$body}
258
+ {$e}
259
+
260
+ TS);
261
+ }
262
+
263
+ /** @param list<Descriptor> $all */
264
+ public function routes(array $all): GeneratedFile
265
+ {
266
+ usort($all, fn (Descriptor $a, Descriptor $b) => strcmp($a->slug, $b->slug));
267
+ $lines = [];
268
+ foreach ($all as $d) {
269
+ $controller = "\\App\\Http\\Controllers\\Api\\{$d->name}Controller::class";
270
+ $lines[] = "Route::get('{$d->slug}/_stats', [{$controller}, 'stats'])->name('{$d->slug}.stats');";
271
+ $lines[] = "Route::apiResource('{$d->slug}', {$controller});";
272
+ }
273
+ $body = implode("\n", $lines);
274
+ $m = self::M;
275
+ $e = self::E;
276
+
277
+ return new GeneratedFile(GeneratedFile::TARGET_API, 'routes/nevela.php', <<<PHP
278
+ <?php
279
+
280
+ use Illuminate\Support\Facades\Route;
281
+
282
+ // Loaded by Nevela under the prefix and middleware in config/nevela.php
283
+ // (default: /api, auth:sanctum). Add your own routes below the generated block.
284
+ {$m}
285
+ {$body}
286
+ {$e}
287
+
288
+ PHP);
289
+ }
290
+
291
+ public function model(Descriptor $d): string
292
+ {
293
+ $fillable = implode(', ', array_map(fn (Field $f) => "'{$f->column()}'", $d->fields));
294
+ $casts = [];
295
+ foreach ($d->fields as $f) {
296
+ $cast = match ($f->kind) {
297
+ 'int' => 'integer',
298
+ 'float' => 'float',
299
+ 'boolean' => 'boolean',
300
+ 'date' => 'date:Y-m-d',
301
+ 'datetime' => 'datetime',
302
+ default => null,
303
+ };
304
+ if ($cast) {
305
+ $casts[] = " '{$f->column()}' => '{$cast}',";
306
+ }
307
+ }
308
+ $castBlock = $casts ? implode("\n", $casts) : '';
309
+ $m = self::M;
310
+ $e = self::E;
311
+
312
+ return <<<PHP
313
+ <?php
314
+
315
+ namespace App\Models;
316
+
317
+ use Illuminate\Database\Eloquent\Concerns\HasUuids;
318
+ use Illuminate\Database\Eloquent\Model;
319
+
320
+ class {$d->name} extends Model
321
+ {
322
+ use HasUuids;
323
+
324
+ protected \$table = '{$d->table}';
325
+
326
+ {$m}
327
+ protected \$fillable = [{$fillable}];
328
+
329
+ protected function casts(): array
330
+ {
331
+ return [
332
+ {$castBlock}
333
+ ];
334
+ }
335
+ {$e}
336
+
337
+ // Relationships, scopes and accessors go here — outside the generated block.
338
+ }
339
+
340
+ PHP;
341
+ }
342
+
343
+ public function migration(Descriptor $d): string
344
+ {
345
+ $columns = [];
346
+ foreach ($d->fields as $f) {
347
+ $c = $f->column();
348
+ $line = match (true) {
349
+ $f->kind === 'text' => "\$table->text('{$c}')",
350
+ $f->kind === 'int' => "\$table->integer('{$c}')",
351
+ $f->kind === 'float' && $f->format === 'money' => "\$table->decimal('{$c}', 12, 2)",
352
+ $f->kind === 'float' => "\$table->double('{$c}')",
353
+ $f->kind === 'boolean' => "\$table->boolean('{$c}')->default(false)",
354
+ $f->kind === 'date' => "\$table->date('{$c}')",
355
+ $f->kind === 'datetime' => "\$table->dateTime('{$c}')",
356
+ $f->format === 'url' => "\$table->string('{$c}', 2048)",
357
+ default => "\$table->string('{$c}')",
358
+ };
359
+ if (! $f->required) {
360
+ $line .= '->nullable()';
361
+ }
362
+ if ($f->unique) {
363
+ $line .= '->unique()';
364
+ } elseif ($f->kind === 'enum') {
365
+ $line .= '->index()';
366
+ }
367
+ $columns[] = " {$line};";
368
+ }
369
+ $cols = implode("\n", $columns);
370
+
371
+ return <<<PHP
372
+ <?php
373
+
374
+ use Illuminate\Database\Migrations\Migration;
375
+ use Illuminate\Database\Schema\Blueprint;
376
+ use Illuminate\Support\Facades\Schema;
377
+
378
+ // Generated once by Nevela. Later descriptor changes need a new migration of your own.
379
+ return new class extends Migration
380
+ {
381
+ public function up(): void
382
+ {
383
+ Schema::create('{$d->table}', function (Blueprint \$table) {
384
+ \$table->uuid('id')->primary();
385
+ {$cols}
386
+ \$table->timestamps();
387
+ });
388
+ }
389
+
390
+ public function down(): void
391
+ {
392
+ Schema::dropIfExists('{$d->table}');
393
+ }
394
+ };
395
+
396
+ PHP;
397
+ }
398
+
399
+ public function request(Descriptor $d): string
400
+ {
401
+ $var = $d->variable();
402
+ $param = $d->routeParameter();
403
+ $rules = [];
404
+ $columns = [];
405
+ foreach ($d->fields as $f) {
406
+ $rules[] = " '{$f->name}' => [".implode(', ', $this->rules($d, $f)).'],';
407
+ $columns[] = "'{$f->name}' => '{$f->column()}'";
408
+ }
409
+ $ruleLines = implode("\n", $rules);
410
+ $columnMap = implode(', ', $columns);
411
+ $m = self::M;
412
+ $e = self::E;
413
+
414
+ return <<<PHP
415
+ <?php
416
+
417
+ namespace App\Http\Requests\Nevela;
418
+
419
+ use App\Models\\{$d->name};
420
+ use Illuminate\Support\Facades\Gate;
421
+ use Illuminate\Validation\Rule;
422
+ use Nevela\Laravel\Http\ResourceRequest;
423
+
424
+ /**
425
+ * Validation for creating (POST), replacing (PUT) and patching (PATCH) a {$d->label}.
426
+ * PATCH makes every rule "sometimes"; PUT clears optional fields left out.
427
+ */
428
+ class {$d->name}Request extends ResourceRequest
429
+ {
430
+ public function authorize(): bool
431
+ {
432
+ \${$var} = \$this->route('{$param}');
433
+
434
+ return \${$var} instanceof {$d->name}
435
+ ? Gate::allows('update', \${$var})
436
+ : Gate::allows('create', {$d->name}::class);
437
+ }
438
+
439
+ {$m}
440
+ public function rules(): array
441
+ {
442
+ \${$var} = \$this->route('{$param}');
443
+
444
+ return [
445
+ {$ruleLines}
446
+ ];
447
+ }
448
+
449
+ protected function columns(): array
450
+ {
451
+ return [{$columnMap}];
452
+ }
453
+ {$e}
454
+ }
455
+
456
+ PHP;
457
+ }
458
+
459
+ /** @return list<string> PHP expressions */
460
+ private function rules(Descriptor $d, Field $f): array
461
+ {
462
+ $rules = [$f->required ? "'required'" : "'nullable'"];
463
+ $type = match (true) {
464
+ $f->format === 'email' => ["'email'", "'max:255'"],
465
+ $f->format === 'url' => ["'url'", "'max:2048'"],
466
+ $f->format === 'tel' => ["'string'", "'max:32'"],
467
+ $f->format === 'slug' => ["'string'", "'alpha_dash'", "'max:255'"],
468
+ $f->format === 'color' => ["'string'", "'regex:/^#[0-9a-fA-F]{6}$/'"],
469
+ $f->kind === 'string' => ["'string'", "'max:255'"],
470
+ $f->kind === 'text' => ["'string'", "'max:65535'"],
471
+ $f->kind === 'int' && $f->format === 'percent' => ["'integer'", "'between:0,100'"],
472
+ $f->kind === 'int' => ["'integer'"],
473
+ $f->kind === 'float' && $f->format === 'percent' => ["'numeric'", "'between:0,100'"],
474
+ $f->kind === 'float' => ["'numeric'"],
475
+ $f->kind === 'boolean' => ["'boolean'"],
476
+ $f->kind === 'date' => ["'date_format:Y-m-d'"],
477
+ $f->kind === 'datetime' => ["'date'"],
478
+ $f->kind === 'enum' => ['Rule::in(['.implode(', ', array_map(fn ($o) => "'{$o}'", $f->options)).'])'],
479
+ };
480
+ $rules = [...$rules, ...$type];
481
+ if ($f->unique) {
482
+ $rules[] = "Rule::unique('{$d->table}', '{$f->column()}')->ignore(\${$d->variable()})";
483
+ }
484
+
485
+ return $rules;
486
+ }
487
+
488
+ public function resource(Descriptor $d): string
489
+ {
490
+ $lines = [];
491
+ foreach ($d->fields as $f) {
492
+ $value = match ($f->kind) {
493
+ 'date' => "\$this->{$f->column()}?->format('Y-m-d')",
494
+ 'datetime' => "\$this->{$f->column()}?->toJSON()",
495
+ default => "\$this->{$f->column()}",
496
+ };
497
+ $lines[] = " '{$f->name}' => {$value},";
498
+ }
499
+ $body = implode("\n", $lines);
500
+ $m = self::M;
501
+ $e = self::E;
502
+
503
+ return <<<PHP
504
+ <?php
505
+
506
+ namespace App\Http\Resources\Nevela;
507
+
508
+ use Illuminate\Http\Request;
509
+ use Illuminate\Http\Resources\Json\JsonResource;
510
+
511
+ /** A {$d->label} as Flare's client expects it: camelCase keys, unwrapped. */
512
+ class {$d->name}Resource extends JsonResource
513
+ {
514
+ public static \$wrap = null;
515
+
516
+ public function toArray(Request \$request): array
517
+ {
518
+ return [
519
+ 'id' => \$this->id,
520
+ {$m}
521
+ {$body}
522
+ {$e}
523
+ 'createdAt' => \$this->created_at?->toJSON(),
524
+ 'updatedAt' => \$this->updated_at?->toJSON(),
525
+ ];
526
+ }
527
+ }
528
+
529
+ PHP;
530
+ }
531
+
532
+ public function controller(Descriptor $d): string
533
+ {
534
+ $var = $d->variable();
535
+ $m = self::M;
536
+ $e = self::E;
537
+
538
+ return <<<PHP
539
+ <?php
540
+
541
+ namespace App\Http\Controllers\Api;
542
+
543
+ use App\Http\Controllers\Controller;
544
+ use App\Http\Requests\Nevela\\{$d->name}Request;
545
+ use App\Http\Resources\Nevela\\{$d->name}Resource;
546
+ use App\Models\\{$d->name};
547
+ use Illuminate\Http\JsonResponse;
548
+ use Illuminate\Http\Request;
549
+ use Illuminate\Http\Response;
550
+ use Illuminate\Support\Facades\Gate;
551
+ use Nevela\Laravel\Nevela;
552
+
553
+ /**
554
+ * {$d->pluralLabel} over Flare's REST contract:
555
+ * GET /{$d->slug}?page&perPage&sort=-createdAt&q&filter[field] → { data, meta }.
556
+ */
557
+ class {$d->name}Controller extends Controller
558
+ {
559
+ {$m}
560
+ public function index(Request \$request): JsonResponse
561
+ {
562
+ Gate::authorize('viewAny', {$d->name}::class);
563
+
564
+ return Nevela::list({$d->name}::query(), '{$d->name}', \$request, {$d->name}Resource::class);
565
+ }
566
+
567
+ public function stats(Request \$request): JsonResponse
568
+ {
569
+ Gate::authorize('viewAny', {$d->name}::class);
570
+
571
+ return Nevela::stats({$d->name}::query(), '{$d->name}', \$request);
572
+ }
573
+
574
+ public function store({$d->name}Request \$request): JsonResponse
575
+ {
576
+ \${$var} = {$d->name}::create(\$request->values());
577
+
578
+ return (new {$d->name}Resource(\${$var}->refresh()))
579
+ ->response()
580
+ ->setStatusCode(201)
581
+ ->header('Location', \$request->url().'/'.\${$var}->getKey());
582
+ }
583
+
584
+ public function show({$d->name} \${$var}): {$d->name}Resource
585
+ {
586
+ Gate::authorize('view', \${$var});
587
+
588
+ return new {$d->name}Resource(\${$var});
589
+ }
590
+
591
+ public function update({$d->name}Request \$request, {$d->name} \${$var}): {$d->name}Resource
592
+ {
593
+ \${$var}->update(\$request->values());
594
+
595
+ return new {$d->name}Resource(\${$var}->refresh());
596
+ }
597
+
598
+ public function destroy({$d->name} \${$var}): Response
599
+ {
600
+ Gate::authorize('delete', \${$var});
601
+ \${$var}->delete();
602
+
603
+ return response()->noContent();
604
+ }
605
+ {$e}
606
+ }
607
+
608
+ PHP;
609
+ }
610
+
611
+ public function policy(Descriptor $d): string
612
+ {
613
+ $var = $d->variable();
614
+
615
+ return <<<PHP
616
+ <?php
617
+
618
+ namespace App\Policies;
619
+
620
+ use App\Models\\{$d->name};
621
+ use App\Models\User;
622
+
623
+ /**
624
+ * Who may do what with {$d->pluralLabel}. Generated once — this file is yours.
625
+ *
626
+ * Default: any signed-in user (routes already require auth:sanctum). Tighten this
627
+ * before production, e.g. `return in_array(\$user->role, ['admin', 'staff']);`.
628
+ */
629
+ class {$d->name}Policy
630
+ {
631
+ public function viewAny(User \$user): bool
632
+ {
633
+ return true;
634
+ }
635
+
636
+ public function view(User \$user, {$d->name} \${$var}): bool
637
+ {
638
+ return true;
639
+ }
640
+
641
+ public function create(User \$user): bool
642
+ {
643
+ return true;
644
+ }
645
+
646
+ public function update(User \$user, {$d->name} \${$var}): bool
647
+ {
648
+ return true;
649
+ }
650
+
651
+ public function delete(User \$user, {$d->name} \${$var}): bool
652
+ {
653
+ return true;
654
+ }
655
+ }
656
+
657
+ PHP;
658
+ }
659
+
660
+ public function typescript(Descriptor $d): string
661
+ {
662
+ $j = fn (mixed $v) => json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
663
+ $config = [
664
+ " name: {$j($d->name)},",
665
+ " table: {$j($d->table)},",
666
+ " slug: {$j($d->slug)},",
667
+ " label: {$j($d->label)},",
668
+ " pluralLabel: {$j($d->pluralLabel)},",
669
+ ];
670
+ if ($d->icon) {
671
+ $config[] = " icon: {$j($d->icon)},";
672
+ }
673
+ if ($d->group) {
674
+ $config[] = " group: {$j($d->group)},";
675
+ }
676
+ if ($d->titleField) {
677
+ $config[] = " titleField: {$j($d->titleField)},";
678
+ }
679
+ $fields = [];
680
+ foreach ($d->fields as $f) {
681
+ $options = array_filter([
682
+ 'required' => $f->required ? null : 'false',
683
+ 'unique' => $f->unique ? 'true' : null,
684
+ 'label' => $f->label ? $j($f->label) : null,
685
+ 'format' => in_array($f->format, ['money', 'percent', 'rating'], true) ? $j($f->format) : null,
686
+ ]);
687
+ $opts = $options ? '{ '.implode(', ', array_map(fn ($k, $v) => "{$k}: {$v}", array_keys($options), $options)).' }' : '';
688
+ $call = match (true) {
689
+ $f->kind === 'enum' => "field.enum({$j($f->options)}".($opts ? ", {$opts}" : '').')',
690
+ $f->kind === 'string' && $f->format !== null => "field.{$f->format}({$opts})",
691
+ default => "field.{$f->kind}({$opts})",
692
+ };
693
+ $fields[] = " {$f->name}: {$call},";
694
+ }
695
+ $configLines = implode("\n", $config);
696
+ $fieldLines = implode("\n", $fields);
697
+ $file = Naming::kebab($d->name);
698
+ $m = self::M;
699
+ $e = self::E;
700
+
701
+ return <<<TS
702
+ // Generated by Nevela from nevela/resources/{$file}.json in the Laravel app.
703
+ // Laravel owns validation, authorization and persistence; this descriptor tells the
704
+ // Flare dashboard how to render the resource. Change fields in the JSON and run
705
+ // `php artisan nevela:generate`. Display-only options may go below the block.
706
+ import { defineResource, field } from "@flaredev/core";
707
+
708
+ export default defineResource({
709
+ {$m}
710
+ {$configLines}
711
+ fields: {
712
+ {$fieldLines}
713
+ },
714
+ {$e}
715
+ });
716
+
717
+ TS;
718
+ }
719
+ }