create-nevela 0.1.5 → 0.2.1

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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/project.mjs +11 -0
  3. package/template/laravel/composer.json +61 -59
  4. package/template/laravel/config/nevela.php +70 -0
  5. package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
  6. package/template/laravel/src/Console/GenerateCommand.php +25 -2
  7. package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
  8. package/template/laravel/src/Console/SeedCommand.php +186 -124
  9. package/template/laravel/src/Console/UpdateCommand.php +64 -3
  10. package/template/laravel/src/Generator/ResourceGenerator.php +151 -19
  11. package/template/laravel/src/Http/ResourceRequest.php +4 -1
  12. package/template/laravel/src/Http/UploadController.php +147 -0
  13. package/template/laravel/src/Media/FileTypes.php +97 -0
  14. package/template/laravel/src/Media/ImageOptimizer.php +252 -0
  15. package/template/laravel/src/Media/Profile.php +94 -0
  16. package/template/laravel/src/Media/UploadRejected.php +8 -0
  17. package/template/laravel/src/Media/Uploads.php +325 -0
  18. package/template/laravel/src/Models/Upload.php +46 -0
  19. package/template/laravel/src/Nevela.php +20 -1
  20. package/template/laravel/src/NevelaServiceProvider.php +24 -0
  21. package/template/laravel/src/Rules/UploadKey.php +27 -0
  22. package/template/laravel/src/Support/Fake.php +194 -171
  23. package/template/laravel/src/Support/Field.php +79 -10
  24. package/template/laravel/src/Support/TemplateSource.php +1 -1
  25. package/template/web/app/api/[...path]/route.ts +87 -0
  26. package/template/web/app/dashboard/actions.ts +26 -9
  27. package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
  28. package/template/web/components/dashboard/fields/file-field.tsx +25 -31
  29. package/template/web/lib/files.ts +21 -0
  30. package/template/web/package.json +1 -1
  31. package/template.mjs +1 -1
@@ -0,0 +1,46 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Models;
4
+
5
+ use Illuminate\Database\Eloquent\Concerns\HasUuids;
6
+ use Illuminate\Database\Eloquent\Model;
7
+
8
+ /**
9
+ * One uploaded file. A record's file field holds the key; this row holds everything
10
+ * else about it.
11
+ *
12
+ * @property string $key
13
+ * @property string $disk
14
+ * @property string $resource
15
+ * @property string $field
16
+ * @property string $name
17
+ * @property string $mime
18
+ * @property int $size
19
+ * @property int|null $width
20
+ * @property int|null $height
21
+ * @property array<string, array{key: string, width: int, height: int, size: int}>|null $renditions
22
+ * @property string|null $profile
23
+ * @property bool $optimised
24
+ * @property string|null $original_key
25
+ * @property int|null $original_size
26
+ */
27
+ class Upload extends Model
28
+ {
29
+ use HasUuids;
30
+
31
+ protected $table = 'nevela_uploads';
32
+
33
+ protected $guarded = [];
34
+
35
+ protected function casts(): array
36
+ {
37
+ return [
38
+ 'size' => 'integer',
39
+ 'width' => 'integer',
40
+ 'height' => 'integer',
41
+ 'renditions' => 'array',
42
+ 'optimised' => 'boolean',
43
+ 'original_size' => 'integer',
44
+ ];
45
+ }
46
+ }
@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder;
6
6
  use Illuminate\Http\JsonResponse;
7
7
  use Illuminate\Http\Request;
8
8
  use InvalidArgumentException;
9
+ use Nevela\Laravel\Media\Uploads;
9
10
  use Nevela\Laravel\Support\Descriptor;
10
11
  use Nevela\Laravel\Support\ListQuery;
11
12
  use Nevela\Laravel\Support\Naming;
@@ -17,7 +18,7 @@ use Nevela\Laravel\Support\Naming;
17
18
  final class Nevela
18
19
  {
19
20
  /** Written by `pnpm release`; the same number as the repository's git tag. */
20
- public const VERSION = '0.1.5';
21
+ public const VERSION = '0.2.1';
21
22
 
22
23
  /** @var array<string, Descriptor> */
23
24
  private static array $descriptors = [];
@@ -101,6 +102,13 @@ final class Nevela
101
102
 
102
103
  $page = $query->paginate($list->perPage, ['*'], 'page', $list->page);
103
104
 
105
+ // One query for every file on the page, instead of one per record.
106
+ foreach ($resource->fields as $field) {
107
+ if ($field->kind === 'file') {
108
+ Uploads::preload($page->getCollection()->pluck($field->column()));
109
+ }
110
+ }
111
+
104
112
  return response()->json([
105
113
  'data' => $resourceClass::collection($page->getCollection())->resolve($request),
106
114
  'meta' => [
@@ -151,6 +159,17 @@ final class Nevela
151
159
  ]);
152
160
  }
153
161
 
162
+ /**
163
+ * What a client needs to show a stored file: its address, size and dimensions, and
164
+ * each rendition's. Generated API resources send it beside the key, as `<field>File`.
165
+ *
166
+ * @return array<string, mixed>|null
167
+ */
168
+ public static function file(?string $key): ?array
169
+ {
170
+ return Uploads::ref($key);
171
+ }
172
+
154
173
  /** Whether a request is one Nevela answers (so errors use Flare's shape). */
155
174
  public static function handles(Request $request): bool
156
175
  {
@@ -3,6 +3,7 @@
3
3
  namespace Nevela\Laravel;
4
4
 
5
5
  use Illuminate\Contracts\Debug\ExceptionHandler;
6
+ use Illuminate\Foundation\Console\ServeCommand;
6
7
  use Illuminate\Support\Facades\Route;
7
8
  use Illuminate\Support\ServiceProvider;
8
9
  use Nevela\Laravel\Console\DevCommand;
@@ -16,6 +17,8 @@ use Nevela\Laravel\Console\UserCommand;
16
17
  use Nevela\Laravel\Console\VersionCommand;
17
18
  use Nevela\Laravel\Http\FlareErrors;
18
19
  use Nevela\Laravel\Http\TokenController;
20
+ use Nevela\Laravel\Http\UploadController;
21
+ use Nevela\Laravel\Media\Uploads;
19
22
 
20
23
  final class NevelaServiceProvider extends ServiceProvider
21
24
  {
@@ -26,7 +29,17 @@ final class NevelaServiceProvider extends ServiceProvider
26
29
 
27
30
  public function boot(): void
28
31
  {
32
+ // The table that records uploads. It arrives with `php artisan migrate`.
33
+ $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
34
+
29
35
  if ($this->app->runningInConsole()) {
36
+ // `php artisan serve` starts PHP with a short list of environment variables, and
37
+ // the temporary folder isn't on it. On Windows PHP then has nowhere to buffer a
38
+ // request body, and any upload over 16 KB fails. Passing these through fixes it.
39
+ if (class_exists(ServeCommand::class)) {
40
+ ServeCommand::$passthroughVariables = array_values(array_unique([...ServeCommand::$passthroughVariables, 'TEMP', 'TMP', 'TMPDIR']));
41
+ }
42
+
30
43
  $this->publishes([__DIR__.'/../config/nevela.php' => config_path('nevela.php')], 'nevela-config');
31
44
  $this->commands([
32
45
  MakeResourceCommand::class, GenerateCommand::class, SeedCommand::class, UserCommand::class,
@@ -66,7 +79,18 @@ final class NevelaServiceProvider extends ServiceProvider
66
79
  'app' => Nevela::fingerprint(),
67
80
  ]))->name('nevela.ping');
68
81
 
82
+ // Stored files. Open, like any image on a website: the key is the secret.
83
+ Route::prefix($prefix)->middleware('api')->get('_nevela/files/{path}', [UploadController::class, 'show'])
84
+ ->where('path', '.*')->name('nevela.files.show');
85
+
69
86
  Route::prefix($prefix)->middleware(config('nevela.middleware', ['api', 'auth:sanctum']))->name('nevela.')->group(function () {
87
+ Route::put('_nevela/uploads/{resource}/{field}', [UploadController::class, 'store'])->name('uploads.store');
88
+
89
+ // The image profiles, for a client that wants to know which renditions exist.
90
+ Route::get('_nevela/profiles', fn () => response()->json([
91
+ 'data' => array_map(fn ($name) => (array) Uploads::profile($name), array_keys((array) config('nevela.uploads.profiles', ['default' => []]))),
92
+ ]))->name('profiles');
93
+
70
94
  // The descriptors, so the web app (and tooling) can check it matches the API.
71
95
  Route::get('_nevela/resources', fn () => response()->json([
72
96
  'data' => array_map(fn ($d) => $d->toArray(), Nevela::all()),
@@ -0,0 +1,27 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Rules;
4
+
5
+ use Closure;
6
+ use Illuminate\Contracts\Validation\ValidationRule;
7
+ use Nevela\Laravel\Models\Upload;
8
+
9
+ /**
10
+ * A file field's value has to be the key of something uploaded to that same field.
11
+ * Without this, anyone who can edit a record could point it at another resource's file.
12
+ */
13
+ final class UploadKey implements ValidationRule
14
+ {
15
+ public function __construct(private readonly string $resource, private readonly string $field) {}
16
+
17
+ public function validate(string $attribute, mixed $value, Closure $fail): void
18
+ {
19
+ if (! is_string($value) || $value === '') {
20
+ return;
21
+ }
22
+ $known = Upload::query()->where('key', $value)->where('resource', $this->resource)->where('field', $this->field)->exists();
23
+ if (! $known) {
24
+ $fail('Upload the file again: this one was not uploaded to this field.');
25
+ }
26
+ }
27
+ }
@@ -1,171 +1,194 @@
1
- <?php
2
-
3
- namespace Nevela\Laravel\Support;
4
-
5
- /**
6
- * Plausible rows for a descriptor, for `php artisan nevela:seed`.
7
- *
8
- * Pure PHP with no Faker: Laravel apps only install Faker for development, and a seed
9
- * that fills a staging database shouldn't depend on it. Values follow the same rules the
10
- * generated form request enforces (lengths, enum options, 0–100 percents, Y-m-d dates),
11
- * and a field's name is used as a hint, so `email`, `city` or `sku` look like what they are.
12
- */
13
- final class Fake
14
- {
15
- private const FIRST = ['Ada', 'Grace', 'Alan', 'Linus', 'Amara', 'Kofi', 'Mei', 'Ravi', 'Sofia', 'Omar', 'Nia', 'Lucas', 'Aisha', 'Mateo', 'Yuki', 'Zainab', 'Noah', 'Priya', 'Elena', 'Kwame'];
16
-
17
- private const LAST = ['Okafor', 'Nakamura', 'Silva', 'Mukasa', 'Haddad', 'Novak', 'Mensah', 'Patel', 'Garcia', 'Kim', 'Otieno', 'Rossi', 'Dubois', 'Khan', 'Andersson', 'Nkosi', 'Tanaka', 'Moreno', 'Ivanov', 'Abebe'];
18
-
19
- private const ADJECTIVES = ['Compact', 'Classic', 'Urban', 'Bright', 'Quiet', 'Rapid', 'Solid', 'Smart', 'Fresh', 'Prime', 'Royal', 'Swift', 'Amber', 'Coastal', 'Nordic', 'Golden'];
20
-
21
- private const NOUNS = ['Lamp', 'Chair', 'Kettle', 'Backpack', 'Notebook', 'Speaker', 'Bottle', 'Desk', 'Jacket', 'Camera', 'Grinder', 'Planter', 'Monitor', 'Keyboard', 'Blanket', 'Mug'];
22
-
23
- private const COMPANIES = ['Acme', 'Globex', 'Initech', 'Northwind', 'Brightline', 'Vandelay', 'Umbrella', 'Hooli', 'Stark', 'Wayne'];
24
-
25
- private const CITIES = ['Kampala', 'Nairobi', 'Lagos', 'Accra', 'London', 'Berlin', 'Toronto', 'Tokyo', 'Lisbon', 'Austin', 'Mumbai', 'Sydney'];
26
-
27
- private const COUNTRIES = ['Uganda', 'Kenya', 'Nigeria', 'Ghana', 'United Kingdom', 'Germany', 'Canada', 'Japan', 'Portugal', 'United States', 'India', 'Australia'];
28
-
29
- private const WORDS = ['reliable', 'handmade', 'everyday', 'lightweight', 'durable', 'refined', 'practical', 'seasonal', 'limited', 'popular', 'tested', 'trusted', 'simple', 'modern', 'original'];
30
-
31
- /** 2000-01-01 UTC, where unique dates start counting from. */
32
- private const EPOCH = 946684800;
33
-
34
- /** A century of distinct days. */
35
- private const UNIQUE_DAYS = 36500;
36
-
37
- private const COLORS = 0x1000000;
38
-
39
- /**
40
- * How many distinct values a unique field can hold, or null when it's as good as unlimited.
41
- * A unique enum of three options can only ever fill three rows; the seed command checks
42
- * this before it starts rather than failing on the fourth insert.
43
- */
44
- public static function capacity(Field $field): ?int
45
- {
46
- if (! $field->unique) {
47
- return null;
48
- }
49
-
50
- return match (true) {
51
- $field->kind === 'enum' => count($field->options),
52
- $field->kind === 'boolean' => 2,
53
- $field->kind === 'int' && $field->format === 'percent' => 101,
54
- $field->kind === 'int' && $field->format === 'rating' => 5,
55
- $field->kind === 'float' && $field->format === 'percent' => 1001,
56
- $field->kind === 'float' && $field->format === 'rating' => 41,
57
- $field->kind === 'date' => self::UNIQUE_DAYS,
58
- $field->format === 'color' => self::COLORS,
59
- default => null,
60
- };
61
- }
62
-
63
- /**
64
- * One row, keyed by column name (without id or timestamps).
65
- *
66
- * @param int $number A number no other seeded row of this table shares: what keeps unique fields unique.
67
- * @return array<string, string|int|float|bool|null>
68
- */
69
- public static function row(Descriptor $resource, int $number): array
70
- {
71
- $first = self::pick(self::FIRST);
72
- $last = self::pick(self::LAST);
73
- $isPerson = isset($resource->fields['email']) || isset($resource->fields['firstName']);
74
-
75
- $row = [];
76
- foreach ($resource->fields as $field) {
77
- // Optional fields are left empty now and then, as real data is.
78
- if (! $field->required && ! $field->unique && mt_rand(1, 5) === 1) {
79
- $row[$field->column()] = null;
80
-
81
- continue;
82
- }
83
- $row[$field->column()] = self::value($field, $number, $first, $last, $isPerson);
84
- }
85
-
86
- return $row;
87
- }
88
-
89
- private static function value(Field $field, int $number, string $first, string $last, bool $isPerson): string|int|float|bool
90
- {
91
- return match ($field->kind) {
92
- // A unique field walks its range by number instead of drawing at random; capacity() says how far that goes.
93
- 'enum' => $field->unique ? $field->options[$number % count($field->options)] : self::pick($field->options),
94
- 'boolean' => $field->unique ? $number % 2 === 0 : mt_rand(1, 10) > 3,
95
- 'int' => match (true) {
96
- $field->format === 'percent' => $field->unique ? $number % 101 : mt_rand(0, 100),
97
- $field->format === 'rating' => $field->unique ? 1 + $number % 5 : mt_rand(1, 5),
98
- $field->unique => $number,
99
- default => mt_rand(0, 500),
100
- },
101
- 'float' => match (true) {
102
- $field->format === 'percent' => round(($field->unique ? $number % 1001 : mt_rand(0, 1000)) / 10, 1),
103
- $field->format === 'rating' => round(($field->unique ? 10 + $number % 41 : mt_rand(10, 50)) / 10, 1),
104
- $field->unique => round($number + mt_rand(0, 99) / 100, 2),
105
- default => round(mt_rand(199, 49999) / 100, 2),
106
- },
107
- 'date' => $field->unique
108
- ? gmdate('Y-m-d', self::EPOCH + ($number % self::UNIQUE_DAYS) * 86400)
109
- : date('Y-m-d', time() - mt_rand(-30, 365) * 86400),
110
- 'datetime' => $field->unique
111
- ? gmdate('Y-m-d H:i:s', self::EPOCH + $number)
112
- : date('Y-m-d H:i:s', time() - mt_rand(0, 365 * 86400)),
113
- 'text' => self::sentence(mt_rand(8, 24)).($field->unique ? " {$number}" : ''),
114
- default => self::string($field, $number, $first, $last, $isPerson),
115
- };
116
- }
117
-
118
- private static function string(Field $field, int $number, string $first, string $last, bool $isPerson): string
119
- {
120
- $name = strtolower($field->name);
121
-
122
- $value = match (true) {
123
- // Always numbered: two people called Ada Okafor must not share an address.
124
- $field->format === 'email' || str_contains($name, 'email') => strtolower("{$first}.{$last}{$number}@example.com"),
125
- $field->format === 'url' || str_contains($name, 'website') || str_ends_with($name, 'url') => 'https://example.com/'.strtolower(self::pick(self::NOUNS)).($field->unique ? "-{$number}" : ''),
126
- $field->format === 'tel' || str_contains($name, 'phone') => $field->unique
127
- ? '+256 7'.str_pad((string) $number, 10, '0', STR_PAD_LEFT)
128
- : sprintf('+256 7%02d %03d %03d', mt_rand(0, 99), mt_rand(0, 999), mt_rand(0, 999)),
129
- $field->format === 'slug' => strtolower(self::pick(self::ADJECTIVES).'-'.self::pick(self::NOUNS)).($field->unique ? "-{$number}" : ''),
130
- $field->format === 'color' => sprintf('#%06x', $field->unique ? $number % self::COLORS : mt_rand(0, 0xFFFFFF)),
131
- in_array($name, ['sku', 'code', 'reference', 'ref', 'number'], true) => strtoupper(substr(self::pick(self::NOUNS), 0, 3)).'-'.str_pad((string) $number, 6, '0', STR_PAD_LEFT),
132
- $name === 'firstname' => $first,
133
- $name === 'lastname' => $last,
134
- in_array($name, ['name', 'fullname'], true) && $isPerson => "{$first} {$last}",
135
- str_contains($name, 'company') || str_contains($name, 'organisation') || str_contains($name, 'organization') => self::pick(self::COMPANIES).' '.self::pick(['Ltd', 'Inc', 'Group', 'Labs']),
136
- str_contains($name, 'city') => self::pick(self::CITIES),
137
- str_contains($name, 'country') => self::pick(self::COUNTRIES),
138
- in_array($name, ['name', 'title', 'label'], true) => self::pick(self::ADJECTIVES).' '.self::pick(self::NOUNS),
139
- default => ucfirst(self::sentence(mt_rand(2, 4), false)),
140
- };
141
-
142
- // Emails, URLs, slugs, codes and phone numbers end in the number already, and a colour
143
- // is derived from it; everything else gets it appended.
144
- if ($field->unique && $field->format !== 'color' && ! preg_match('/'.$number.'(@|$)/', $value)) {
145
- $value .= " {$number}";
146
- }
147
-
148
- return $value;
149
- }
150
-
151
- private static function sentence(int $words, bool $stop = true): string
152
- {
153
- $picked = [];
154
- for ($i = 0; $i < $words; $i++) {
155
- $picked[] = self::pick(self::WORDS);
156
- }
157
-
158
- return ucfirst(implode(' ', $picked)).($stop ? '.' : '');
159
- }
160
-
161
- /**
162
- * @template T
163
- *
164
- * @param list<T> $from
165
- * @return T
166
- */
167
- private static function pick(array $from): mixed
168
- {
169
- return $from[mt_rand(0, count($from) - 1)];
170
- }
171
- }
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ /**
6
+ * Plausible rows for a descriptor, for `php artisan nevela:seed`.
7
+ *
8
+ * Pure PHP with no Faker: Laravel apps only install Faker for development, and a seed
9
+ * that fills a staging database shouldn't depend on it. Values follow the same rules the
10
+ * generated form request enforces (lengths, enum options, 0–100 percents, Y-m-d dates),
11
+ * and a field's name is used as a hint, so `email`, `city` or `sku` look like what they are.
12
+ */
13
+ final class Fake
14
+ {
15
+ private const FIRST = ['Ada', 'Grace', 'Alan', 'Linus', 'Amara', 'Kofi', 'Mei', 'Ravi', 'Sofia', 'Omar', 'Nia', 'Lucas', 'Aisha', 'Mateo', 'Yuki', 'Zainab', 'Noah', 'Priya', 'Elena', 'Kwame'];
16
+
17
+ private const LAST = ['Okafor', 'Nakamura', 'Silva', 'Mukasa', 'Haddad', 'Novak', 'Mensah', 'Patel', 'Garcia', 'Kim', 'Otieno', 'Rossi', 'Dubois', 'Khan', 'Andersson', 'Nkosi', 'Tanaka', 'Moreno', 'Ivanov', 'Abebe'];
18
+
19
+ private const ADJECTIVES = ['Compact', 'Classic', 'Urban', 'Bright', 'Quiet', 'Rapid', 'Solid', 'Smart', 'Fresh', 'Prime', 'Royal', 'Swift', 'Amber', 'Coastal', 'Nordic', 'Golden'];
20
+
21
+ private const NOUNS = ['Lamp', 'Chair', 'Kettle', 'Backpack', 'Notebook', 'Speaker', 'Bottle', 'Desk', 'Jacket', 'Camera', 'Grinder', 'Planter', 'Monitor', 'Keyboard', 'Blanket', 'Mug'];
22
+
23
+ /** What a shop files things under: names for a Category, Collection, Department or Tag. */
24
+ private const GROUPS = ['Kitchen', 'Outdoor', 'Office', 'Lighting', 'Audio', 'Travel', 'Home', 'Garden', 'Fitness', 'Accessories', 'Stationery', 'Bedroom', 'Toys', 'Tools', 'Beauty', 'Pets'];
25
+
26
+ private const COMPANIES = ['Acme', 'Globex', 'Initech', 'Northwind', 'Brightline', 'Vandelay', 'Umbrella', 'Hooli', 'Stark', 'Wayne'];
27
+
28
+ private const CITIES = ['Kampala', 'Nairobi', 'Lagos', 'Accra', 'London', 'Berlin', 'Toronto', 'Tokyo', 'Lisbon', 'Austin', 'Mumbai', 'Sydney'];
29
+
30
+ private const COUNTRIES = ['Uganda', 'Kenya', 'Nigeria', 'Ghana', 'United Kingdom', 'Germany', 'Canada', 'Japan', 'Portugal', 'United States', 'India', 'Australia'];
31
+
32
+ private const WORDS = ['reliable', 'handmade', 'everyday', 'lightweight', 'durable', 'refined', 'practical', 'seasonal', 'limited', 'popular', 'tested', 'trusted', 'simple', 'modern', 'original'];
33
+
34
+ /** 2000-01-01 UTC, where unique dates start counting from. */
35
+ private const EPOCH = 946684800;
36
+
37
+ /** A century of distinct days. */
38
+ private const UNIQUE_DAYS = 36500;
39
+
40
+ private const COLORS = 0x1000000;
41
+
42
+ /**
43
+ * How many distinct values a unique field can hold, or null when it's as good as unlimited.
44
+ * A unique enum of three options can only ever fill three rows; the seed command checks
45
+ * this before it starts rather than failing on the fourth insert.
46
+ */
47
+ public static function capacity(Field $field): ?int
48
+ {
49
+ if (! $field->unique) {
50
+ return null;
51
+ }
52
+
53
+ return match (true) {
54
+ $field->kind === 'enum' => count($field->options),
55
+ $field->kind === 'boolean' => 2,
56
+ $field->kind === 'int' && $field->format === 'percent' => 101,
57
+ $field->kind === 'int' && $field->format === 'rating' => 5,
58
+ $field->kind === 'float' && $field->format === 'percent' => 1001,
59
+ $field->kind === 'float' && $field->format === 'rating' => 41,
60
+ $field->kind === 'date' => self::UNIQUE_DAYS,
61
+ $field->format === 'color' => self::COLORS,
62
+ default => null,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * One row, keyed by column name (without id or timestamps).
68
+ *
69
+ * @param int $number A number no other seeded row of this table shares: what keeps unique fields unique.
70
+ * @param array<string, list<string>> $pools Values to draw from, by field name: the ids a belongsTo may point at, the keys of files that exist.
71
+ * @return array<string, string|int|float|bool|null>
72
+ */
73
+ public static function row(Descriptor $resource, int $number, array $pools = []): array
74
+ {
75
+ $first = self::pick(self::FIRST);
76
+ $last = self::pick(self::LAST);
77
+ $isPerson = isset($resource->fields['email']) || isset($resource->fields['firstName']);
78
+ $isGroup = (bool) preg_match('/(Category|Collection|Department|Tag|Genre|Brand)$/', $resource->name);
79
+
80
+ $row = [];
81
+ foreach ($resource->fields as $field) {
82
+ // Optional fields are left empty now and then, as real data is.
83
+ if (! $field->required && ! $field->unique && mt_rand(1, 5) === 1) {
84
+ $row[$field->column()] = null;
85
+
86
+ continue;
87
+ }
88
+ // A relation or a file can't be invented: it has to be one that exists.
89
+ if (in_array($field->kind, ['belongsTo', 'file'], true)) {
90
+ $pool = $pools[$field->name] ?? [];
91
+ $row[$field->column()] = $pool === [] ? null : self::pick($pool);
92
+
93
+ continue;
94
+ }
95
+ $row[$field->column()] = self::value($field, $number, $first, $last, $isPerson);
96
+ }
97
+
98
+ // A category is called Kitchen, not Compact Lamp, and its slug follows its name.
99
+ $name = $resource->fields['name'] ?? null;
100
+ if ($isGroup && $name !== null && $name->kind === 'string' && $name->format === null) {
101
+ $group = self::GROUPS[($number - 1) % count(self::GROUPS)];
102
+ $row['name'] = $name->unique ? "{$group} {$number}" : $group;
103
+ $slug = $resource->fields['slug'] ?? null;
104
+ if ($slug !== null && $slug->format === 'slug' && isset($row['slug'])) {
105
+ $row['slug'] = strtolower($group).($slug->unique ? "-{$number}" : '');
106
+ }
107
+ }
108
+
109
+ return $row;
110
+ }
111
+
112
+ private static function value(Field $field, int $number, string $first, string $last, bool $isPerson): string|int|float|bool
113
+ {
114
+ return match ($field->kind) {
115
+ // A unique field walks its range by number instead of drawing at random; capacity() says how far that goes.
116
+ 'enum' => $field->unique ? $field->options[$number % count($field->options)] : self::pick($field->options),
117
+ 'boolean' => $field->unique ? $number % 2 === 0 : mt_rand(1, 10) > 3,
118
+ 'int' => match (true) {
119
+ $field->format === 'percent' => $field->unique ? $number % 101 : mt_rand(0, 100),
120
+ $field->format === 'rating' => $field->unique ? 1 + $number % 5 : mt_rand(1, 5),
121
+ $field->unique => $number,
122
+ default => mt_rand(0, 500),
123
+ },
124
+ 'float' => match (true) {
125
+ $field->format === 'percent' => round(($field->unique ? $number % 1001 : mt_rand(0, 1000)) / 10, 1),
126
+ $field->format === 'rating' => round(($field->unique ? 10 + $number % 41 : mt_rand(10, 50)) / 10, 1),
127
+ $field->unique => round($number + mt_rand(0, 99) / 100, 2),
128
+ default => round(mt_rand(199, 49999) / 100, 2),
129
+ },
130
+ 'date' => $field->unique
131
+ ? gmdate('Y-m-d', self::EPOCH + ($number % self::UNIQUE_DAYS) * 86400)
132
+ : date('Y-m-d', time() - mt_rand(-30, 365) * 86400),
133
+ 'datetime' => $field->unique
134
+ ? gmdate('Y-m-d H:i:s', self::EPOCH + $number)
135
+ : date('Y-m-d H:i:s', time() - mt_rand(0, 365 * 86400)),
136
+ 'text' => self::sentence(mt_rand(8, 24)).($field->unique ? " {$number}" : ''),
137
+ default => self::string($field, $number, $first, $last, $isPerson),
138
+ };
139
+ }
140
+
141
+ private static function string(Field $field, int $number, string $first, string $last, bool $isPerson): string
142
+ {
143
+ $name = strtolower($field->name);
144
+
145
+ $value = match (true) {
146
+ // Always numbered: two people called Ada Okafor must not share an address.
147
+ $field->format === 'email' || str_contains($name, 'email') => strtolower("{$first}.{$last}{$number}@example.com"),
148
+ $field->format === 'url' || str_contains($name, 'website') || str_ends_with($name, 'url') => 'https://example.com/'.strtolower(self::pick(self::NOUNS)).($field->unique ? "-{$number}" : ''),
149
+ $field->format === 'tel' || str_contains($name, 'phone') => $field->unique
150
+ ? '+256 7'.str_pad((string) $number, 10, '0', STR_PAD_LEFT)
151
+ : sprintf('+256 7%02d %03d %03d', mt_rand(0, 99), mt_rand(0, 999), mt_rand(0, 999)),
152
+ $field->format === 'slug' => strtolower(self::pick(self::ADJECTIVES).'-'.self::pick(self::NOUNS)).($field->unique ? "-{$number}" : ''),
153
+ $field->format === 'color' => sprintf('#%06x', $field->unique ? $number % self::COLORS : mt_rand(0, 0xFFFFFF)),
154
+ in_array($name, ['sku', 'code', 'reference', 'ref', 'number'], true) => strtoupper(substr(self::pick(self::NOUNS), 0, 3)).'-'.str_pad((string) $number, 6, '0', STR_PAD_LEFT),
155
+ $name === 'firstname' => $first,
156
+ $name === 'lastname' => $last,
157
+ in_array($name, ['name', 'fullname'], true) && $isPerson => "{$first} {$last}",
158
+ str_contains($name, 'company') || str_contains($name, 'organisation') || str_contains($name, 'organization') => self::pick(self::COMPANIES).' '.self::pick(['Ltd', 'Inc', 'Group', 'Labs']),
159
+ str_contains($name, 'city') => self::pick(self::CITIES),
160
+ str_contains($name, 'country') => self::pick(self::COUNTRIES),
161
+ in_array($name, ['name', 'title', 'label'], true) => self::pick(self::ADJECTIVES).' '.self::pick(self::NOUNS),
162
+ default => ucfirst(self::sentence(mt_rand(2, 4), false)),
163
+ };
164
+
165
+ // Emails, URLs, slugs, codes and phone numbers end in the number already, and a colour
166
+ // is derived from it; everything else gets it appended.
167
+ if ($field->unique && $field->format !== 'color' && ! preg_match('/'.$number.'(@|$)/', $value)) {
168
+ $value .= " {$number}";
169
+ }
170
+
171
+ return $value;
172
+ }
173
+
174
+ private static function sentence(int $words, bool $stop = true): string
175
+ {
176
+ $picked = [];
177
+ for ($i = 0; $i < $words; $i++) {
178
+ $picked[] = self::pick(self::WORDS);
179
+ }
180
+
181
+ return ucfirst(implode(' ', $picked)).($stop ? '.' : '');
182
+ }
183
+
184
+ /**
185
+ * @template T
186
+ *
187
+ * @param list<T> $from
188
+ * @return T
189
+ */
190
+ private static function pick(array $from): mixed
191
+ {
192
+ return $from[mt_rand(0, count($from) - 1)];
193
+ }
194
+ }