create-nevela 0.7.1 → 0.9.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 (87) hide show
  1. package/index.mjs +25 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +66 -3
  5. package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
  6. package/template/laravel/resources/disposable-domains.txt +9880 -0
  7. package/template/laravel/src/Access/ClosedAccounts.php +2 -1
  8. package/template/laravel/src/Access/Permissions.php +36 -5
  9. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  10. package/template/laravel/src/Concerns/Trashable.php +23 -0
  11. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  12. package/template/laravel/src/Console/BlogCommand.php +254 -0
  13. package/template/laravel/src/Console/DevCommand.php +9 -0
  14. package/template/laravel/src/Console/DigestCommand.php +28 -0
  15. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  16. package/template/laravel/src/Console/GenerateCommand.php +38 -3
  17. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  18. package/template/laravel/src/Console/SeedCommand.php +3 -1
  19. package/template/laravel/src/Console/SetupCommand.php +15 -2
  20. package/template/laravel/src/Console/StatusCommand.php +14 -0
  21. package/template/laravel/src/Console/TrashCommand.php +17 -0
  22. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  23. package/template/laravel/src/Forms/Confirmation.php +88 -0
  24. package/template/laravel/src/Forms/Disposable.php +74 -0
  25. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  26. package/template/laravel/src/Forms/FormGuard.php +359 -0
  27. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  28. package/template/laravel/src/Forms/PublicMail.php +217 -0
  29. package/template/laravel/src/Forms/Refusal.php +50 -0
  30. package/template/laravel/src/Generator/BlogPages.php +409 -0
  31. package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
  32. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  33. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  34. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  35. package/template/laravel/src/Http/ActionController.php +223 -0
  36. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  37. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  38. package/template/laravel/src/Http/FormsController.php +45 -0
  39. package/template/laravel/src/Http/PublicController.php +294 -0
  40. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  41. package/template/laravel/src/Http/ResourceRequest.php +193 -4
  42. package/template/laravel/src/Http/TrashController.php +19 -2
  43. package/template/laravel/src/Http/UploadController.php +42 -1
  44. package/template/laravel/src/Media/Uploads.php +111 -0
  45. package/template/laravel/src/Nevela.php +83 -2
  46. package/template/laravel/src/NevelaServiceProvider.php +27 -2
  47. package/template/laravel/src/Support/Action.php +168 -0
  48. package/template/laravel/src/Support/Adoption.php +352 -0
  49. package/template/laravel/src/Support/Descriptor.php +83 -4
  50. package/template/laravel/src/Support/Fake.php +15 -1
  51. package/template/laravel/src/Support/Field.php +91 -3
  52. package/template/laravel/src/Support/Html.php +185 -0
  53. package/template/laravel/src/Support/Key.php +57 -0
  54. package/template/laravel/src/Support/ListQuery.php +29 -1
  55. package/template/laravel/src/Support/PublicAccess.php +208 -0
  56. package/template/laravel/src/Support/Slug.php +43 -0
  57. package/template/laravel/src/Trash/Trash.php +3 -1
  58. package/template/web/.env.example +4 -0
  59. package/template/web/app/api/[...path]/route.ts +42 -9
  60. package/template/web/app/confirm/page.tsx +51 -0
  61. package/template/web/app/dashboard/actions.ts +48 -0
  62. package/template/web/app/globals.css +46 -0
  63. package/template/web/components/access/role-form.tsx +40 -5
  64. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  65. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  66. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  67. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  68. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  69. package/template/web/components/dashboard/form-activity.tsx +53 -0
  70. package/template/web/components/dashboard/record-actions.tsx +33 -4
  71. package/template/web/components/dashboard/record-detail.tsx +10 -2
  72. package/template/web/components/dashboard/resource-action.tsx +281 -0
  73. package/template/web/components/dashboard/resource-form.tsx +19 -2
  74. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  75. package/template/web/components/dashboard/resource-table.tsx +46 -4
  76. package/template/web/components/dashboard/row-actions.tsx +56 -2
  77. package/template/web/components/dashboard/table-selection.tsx +19 -1
  78. package/template/web/components/marketing/site-header.tsx +9 -2
  79. package/template/web/components/public-form-guard.tsx +125 -0
  80. package/template/web/hooks/use-public-form.ts +175 -0
  81. package/template/web/lib/access.ts +3 -1
  82. package/template/web/lib/dashboard.ts +10 -0
  83. package/template/web/lib/extras.ts +105 -0
  84. package/template/web/lib/files.ts +14 -0
  85. package/template/web/lib/laravel.ts +60 -0
  86. package/template/web/lib/rich.ts +79 -0
  87. package/template/web/package.json +1 -1
@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB;
7
7
  use Illuminate\Support\Facades\Schema;
8
8
  use Illuminate\Support\Str;
9
9
  use Nevela\Laravel\Auth\Account;
10
+ use Nevela\Laravel\Support\Key;
10
11
 
11
12
  /**
12
13
  * Accounts that were closed, and the emails that may not have a new one.
@@ -207,7 +208,7 @@ final class ClosedAccounts
207
208
  /** Let an email whose account was removed for good be used again. False when there is no such entry. */
208
209
  public static function allow(string $id): bool
209
210
  {
210
- return DB::table(self::BLOCKED)->where('id', $id)->whereNull('user_id')->delete() > 0;
211
+ return Key::isUuid($id) && DB::table(self::BLOCKED)->where('id', $id)->whereNull('user_id')->delete() > 0;
211
212
  }
212
213
 
213
214
  /** Remember an email by its fingerprint: for a closed account, or with no account left. */
@@ -29,6 +29,14 @@ final class Permissions
29
29
  /** @var list<array{key: string, name: string, groups: list<array{key: string, name: string, features: list<array{key: string, name: string, actions: list<string>}>}>}>|null */
30
30
  private static ?array $catalog = null;
31
31
 
32
+ /**
33
+ * The permissions that are a resource's own actions, such as "internship_applications.accept".
34
+ * They are the only ones outside the four, and are only ever given by name.
35
+ *
36
+ * @var array<string, true>
37
+ */
38
+ private static array $own = [];
39
+
32
40
  /**
33
41
  * The catalog, shaped for the roles screen: modules, their groups, their features.
34
42
  * Only the feature and the action appear in a permission; the rest is for reading.
@@ -41,6 +49,7 @@ final class Permissions
41
49
  return self::$catalog;
42
50
  }
43
51
 
52
+ self::$own = [];
44
53
  $taken = ['users' => true, 'roles' => true];
45
54
  $modules = [[
46
55
  'key' => 'access',
@@ -64,7 +73,14 @@ final class Permissions
64
73
  $taken[$descriptor->table] = true;
65
74
  $heading = trim((string) ($descriptor->group ?? '')) ?: 'Resources';
66
75
  $groups[$heading] ??= ['key' => self::slug($heading), 'name' => $heading, 'features' => []];
67
- $groups[$heading]['features'][] = ['key' => $descriptor->table, 'name' => $descriptor->pluralLabel, 'actions' => self::ACTIONS];
76
+ $feature = ['key' => $descriptor->table, 'name' => $descriptor->pluralLabel, 'actions' => self::ACTIONS];
77
+ // The resource's own actions, after the four: one permission each, with the name its button has.
78
+ foreach ($descriptor->actions as $action) {
79
+ $feature['actions'][] = $action->key();
80
+ $feature['labels'][$action->key()] = $action->label;
81
+ self::$own["{$descriptor->table}.{$action->key()}"] = true;
82
+ }
83
+ $groups[$heading]['features'][] = $feature;
68
84
  }
69
85
  if ($groups !== []) {
70
86
  $modules[] = ['key' => 'resources', 'name' => 'Resources', 'groups' => array_values($groups)];
@@ -92,6 +108,7 @@ final class Permissions
92
108
  public static function forget(): void
93
109
  {
94
110
  self::$catalog = null;
111
+ self::$own = [];
95
112
  }
96
113
 
97
114
  /**
@@ -119,7 +136,16 @@ final class Permissions
119
136
  /** Whether `ability` has the shape of a permission, so the gate answers for it. */
120
137
  public static function isPermission(string $ability): bool
121
138
  {
122
- return (bool) preg_match('/^[a-z][a-z0-9_]*\.(create|view|edit|delete)$/', $ability);
139
+ if (preg_match('/^[a-z][a-z0-9_]*\.(create|view|edit|delete)$/', $ability)) {
140
+ return true;
141
+ }
142
+ // Or one of a resource's own actions. Anything else with a dot in it is the app's to define.
143
+ if (! str_contains($ability, '.')) {
144
+ return false;
145
+ }
146
+ self::catalog();
147
+
148
+ return isset(self::$own[$ability]);
123
149
  }
124
150
 
125
151
  /**
@@ -144,6 +170,11 @@ final class Permissions
144
170
  * A pattern is checked to its end: "products.*" covers "products.view", and "*.view"
145
171
  * covers "products.view" and nothing else of products. Stopping at the first "*"
146
172
  * would turn "*.view" into "everything".
173
+ *
174
+ * In the second half, "*" is the four things every feature has: create, view, edit and
175
+ * delete. A resource's own action, such as accepting an application, is covered only
176
+ * by its own name or by "*" alone. Someone given "everything to do with the records"
177
+ * has not thereby been given the button that sends a letter.
147
178
  */
148
179
  public static function matches(string $pattern, string $key): bool
149
180
  {
@@ -165,7 +196,7 @@ final class Permissions
165
196
  return false;
166
197
  }
167
198
 
168
- return $action === '*' || $action === $k[1];
199
+ return $action === '*' ? in_array($k[1], self::ACTIONS, true) : $action === $k[1];
169
200
  }
170
201
 
171
202
  /**
@@ -236,10 +267,10 @@ final class Permissions
236
267
  if ($grant === '*') {
237
268
  return true;
238
269
  }
239
- if (! preg_match('/^(\*|@?[a-z][a-z0-9_]*)\.(\*|create|view|edit|delete)$/', $grant)) {
270
+ if (! preg_match('/^(\*|@?[a-z][a-z0-9_]*)\.(\*|[a-z][a-z0-9_]*)$/', $grant, $m)) {
240
271
  return false;
241
272
  }
242
- if (str_starts_with($grant, '@')) {
273
+ if (str_starts_with($grant, '@') && in_array($m[2], ['*', ...self::ACTIONS], true)) {
243
274
  return in_array(substr(explode('.', $grant)[0], 1), ['access', 'resources', 'app'], true);
244
275
  }
245
276
 
@@ -0,0 +1,81 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Concerns;
4
+
5
+ use Illuminate\Database\Eloquent\Builder;
6
+ use Illuminate\Support\Facades\Schema;
7
+
8
+ /**
9
+ * A model whose records hold an address that has to be proved: a list people join.
10
+ *
11
+ * A record made through the public form starts unconfirmed (`confirmed_at` is null) and
12
+ * is confirmed when the link emailed to its address is followed. Until then it is left
13
+ * out of counts and exports, and after two days it is removed. Whatever goes to
14
+ * "everyone on the list" goes to the confirmed:
15
+ *
16
+ * Subscriber::confirmed()->each(fn ($subscriber) => …);
17
+ *
18
+ * A record made any other way (in the dashboard, by a seeder, by an import) is confirmed
19
+ * as it is made: someone signed in put it there, and there is nobody to ask.
20
+ *
21
+ * Generated models use it when their descriptor's public create has "confirm".
22
+ *
23
+ * @mixin \Illuminate\Database\Eloquent\Model
24
+ */
25
+ trait Confirmable
26
+ {
27
+ /** Whether this model's table has the column. Only "yes" is remembered: it can arrive later in a process. */
28
+ private static bool $hasConfirmation = false;
29
+
30
+ public static function confirms(): bool
31
+ {
32
+ if (static::$hasConfirmation) {
33
+ return true;
34
+ }
35
+ $model = new static;
36
+
37
+ return static::$hasConfirmation = Schema::connection($model->getConnectionName())->hasColumn($model->getTable(), 'confirmed_at');
38
+ }
39
+
40
+ /** Ask the database again next time. For a test that adds the column within one process. */
41
+ public static function forgetConfirmation(): void
42
+ {
43
+ static::$hasConfirmation = false;
44
+ }
45
+
46
+ public static function bootConfirmable(): void
47
+ {
48
+ static::creating(function (self $model) {
49
+ // The public form says "not yet" by setting the column to null itself. Anything
50
+ // else that makes a record says nothing, and is taken at its word.
51
+ if (! array_key_exists('confirmed_at', $model->getAttributes()) && static::confirms()) {
52
+ $model->setAttribute('confirmed_at', $model->freshTimestamp());
53
+ }
54
+ });
55
+ }
56
+
57
+ public function initializeConfirmable(): void
58
+ {
59
+ $this->mergeCasts(['confirmed_at' => 'datetime']);
60
+ }
61
+
62
+ /** Whether this record's address has been proved. */
63
+ public function isConfirmed(): bool
64
+ {
65
+ return $this->getAttribute('confirmed_at') !== null;
66
+ }
67
+
68
+ /** The records whose address has been proved: the ones to count, export and write to. */
69
+ public function scopeConfirmed(Builder $query): void
70
+ {
71
+ if (static::confirms()) {
72
+ $query->whereNotNull($this->qualifyColumn('confirmed_at'));
73
+ }
74
+ }
75
+
76
+ /** The ones still waiting for their link to be followed. */
77
+ public function scopeUnconfirmed(Builder $query): void
78
+ {
79
+ static::confirms() ? $query->whereNull($this->qualifyColumn('confirmed_at')) : $query->whereRaw('1 = 0');
80
+ }
81
+ }
@@ -4,6 +4,9 @@ namespace Nevela\Laravel\Concerns;
4
4
 
5
5
  use Illuminate\Database\Eloquent\SoftDeletes;
6
6
  use Illuminate\Support\Facades\Schema;
7
+ use InvalidArgumentException;
8
+ use Nevela\Laravel\Media\Uploads;
9
+ use Nevela\Laravel\Nevela;
7
10
  use Nevela\Laravel\Trash\TrashedScope;
8
11
 
9
12
  /**
@@ -59,5 +62,25 @@ trait Trashable
59
62
  $model->forceDeleting = true;
60
63
  }
61
64
  });
65
+
66
+ // A private file goes when its record goes for good. In the trash the record still
67
+ // exists, and so does its file, unreachable, for the day it is restored.
68
+ static::deleted(function (self $model) {
69
+ if ($model->exists) {
70
+ return;
71
+ }
72
+ try {
73
+ $resource = Nevela::resource(class_basename($model));
74
+ } catch (InvalidArgumentException) {
75
+ return; // a model that uses the trash without being a resource
76
+ }
77
+ foreach ($resource->fields as $field) {
78
+ $key = $field->kind === 'file' && $field->private ? $model->getAttribute($field->column()) : null;
79
+ // Not while another record of the table still points at the same file.
80
+ if (is_string($key) && $key !== '' && ! $model->newQueryWithoutScopes()->where($field->column(), $key)->exists()) {
81
+ Uploads::discard($key);
82
+ }
83
+ }
84
+ });
62
85
  }
63
86
  }
@@ -0,0 +1,139 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use InvalidArgumentException;
7
+ use Nevela\Laravel\Generator\GeneratedFile;
8
+ use Nevela\Laravel\Generator\ResourceGenerator;
9
+ use Nevela\Laravel\Generator\Writer;
10
+ use Nevela\Laravel\Nevela;
11
+ use Nevela\Laravel\Support\Adoption;
12
+ use Nevela\Laravel\Support\Descriptor;
13
+ use Nevela\Laravel\Support\Naming;
14
+
15
+ final class AdoptCommand extends Command
16
+ {
17
+ protected $signature = 'nevela:adopt
18
+ {table : The table to make a resource of, e.g. posts}
19
+ {--name= : The resource\'s name, when it isn\'t the table\'s own (posts is Post)}
20
+ {--icon= : Lucide icon name for the dashboard, e.g. newspaper}
21
+ {--group= : Sidebar group in the dashboard}
22
+ {--yes : Write it without asking}
23
+ {--force : Replace the descriptor this table already has}';
24
+
25
+ protected $description = 'Make a resource of a table you already have: its descriptor is read from the table, which is left as it is';
26
+
27
+ public function handle(): int
28
+ {
29
+ $table = (string) $this->argument('table');
30
+ if (str_starts_with($table, 'nevela_') || $table === 'migrations') {
31
+ $this->components->error("{$table} is a table Nevela or Laravel keeps for itself.");
32
+
33
+ return self::FAILURE;
34
+ }
35
+
36
+ Nevela::forget();
37
+ try {
38
+ $all = Nevela::all();
39
+ // The descriptor this table has already, if it is being adopted again.
40
+ $others = array_values(array_filter($all, fn (Descriptor $d) => $d->table !== $table));
41
+ $existing = array_values(array_filter($all, fn (Descriptor $d) => $d->table === $table))[0] ?? null;
42
+ if ($existing !== null && ! $this->option('force')) {
43
+ $this->components->error("{$table} is a resource already. Edit its descriptor and run: php artisan nevela:generate. To read it from the table again, add --force.");
44
+
45
+ return self::FAILURE;
46
+ }
47
+ $name = $this->option('name');
48
+ // Read again, it stays the resource it is. Under another name the first descriptor
49
+ // would still be there, and the table would be two resources.
50
+ if ($existing !== null && $name !== null && Naming::pascal($name) !== $existing->name) {
51
+ $this->components->error("{$table} is the {$existing->name} resource, and --force reads it again under that name. To call it something else, remove nevela/resources/".Naming::kebab($existing->name).'.json and the code generated for it, then adopt the table again.');
52
+
53
+ return self::FAILURE;
54
+ }
55
+ $adoption = Adoption::of($table, $others, $existing?->name ?? $name, $this->option('icon') ?? $existing?->icon, $this->option('group') ?? $existing?->group);
56
+ } catch (InvalidArgumentException $e) {
57
+ $this->components->error($e->getMessage());
58
+
59
+ return self::FAILURE;
60
+ }
61
+ $descriptor = $adoption->descriptor;
62
+
63
+ $dir = rtrim(config('nevela.descriptors_path'), '/\\');
64
+ $path = $dir.DIRECTORY_SEPARATOR.Naming::kebab($descriptor->name).'.json';
65
+ foreach ($others as $other) {
66
+ if ($other->name === $descriptor->name) {
67
+ $this->components->error("There is a {$descriptor->name} already, on the {$other->table} table. Give this one another name: --name=…");
68
+
69
+ return self::FAILURE;
70
+ }
71
+ }
72
+ if ($taken = $this->ownFiles($descriptor, [...$others, $descriptor])) {
73
+ $this->components->error("The name {$descriptor->name} is taken by code of your own, which Nevela won't write over:");
74
+ $this->components->bulletList($taken);
75
+ $this->line(" Adopt the table under another name (<fg=cyan>--name=…</>), or move those files aside first.");
76
+
77
+ return self::FAILURE;
78
+ }
79
+
80
+ $this->components->info("{$table} as {$descriptor->name}, with ".($descriptor->idType === 'int' ? 'integer' : 'UUID').' ids');
81
+ foreach ($adoption->fields as $column => $field) {
82
+ $this->components->twoColumnDetail($column, "{$field->name}:".Adoption::spec($field));
83
+ }
84
+ foreach ($adoption->left as $left) {
85
+ $this->components->twoColumnDetail($left['column'], "<fg=gray>left alone: {$left['why']}</>");
86
+ }
87
+ if ($adoption->notes !== []) {
88
+ $this->newLine();
89
+ $this->components->bulletList($adoption->notes);
90
+ }
91
+
92
+ if (! $this->option('yes') && ! $this->components->confirm("Make {$descriptor->name} a resource? The table is not changed", true)) {
93
+ $this->components->info('Nothing was written.');
94
+
95
+ return self::SUCCESS;
96
+ }
97
+
98
+ if (! is_dir($dir)) {
99
+ mkdir($dir, 0775, true);
100
+ }
101
+ file_put_contents($path, $descriptor->toJson());
102
+ $this->components->info("Described {$descriptor->name} in ".str_replace(base_path().DIRECTORY_SEPARATOR, '', $path));
103
+
104
+ Nevela::forget();
105
+ $status = GenerateCommand::generate($this, [$descriptor], Nevela::all(), false);
106
+
107
+ $this->newLine();
108
+ $this->line(' Next: <fg=cyan>php artisan migrate</> gives the table a trash (a deleted_at column), then tighten <fg=cyan>app/Policies/'.$descriptor->name.'Policy.php</>.');
109
+ $this->line(' Every column came in as the plainest type that holds it. Where one is an email, a slug or a choice, say so in the descriptor and run <fg=cyan>php artisan nevela:generate</>.');
110
+
111
+ return $status;
112
+ }
113
+
114
+ /**
115
+ * Files this resource would be written to that are there already and aren't Nevela's:
116
+ * a model of the same name, most often. Their generated block has nowhere to go, and a
117
+ * resource whose model lacks it only half works.
118
+ *
119
+ * @param list<Descriptor> $all
120
+ * @return list<string>
121
+ */
122
+ private function ownFiles(Descriptor $descriptor, array $all): array
123
+ {
124
+ $roots = [GeneratedFile::TARGET_API => base_path()];
125
+ if (($web = config('nevela.web_path')) && is_dir($web)) {
126
+ $roots[GeneratedFile::TARGET_WEB] = $web;
127
+ }
128
+ $taken = [];
129
+ foreach ((new ResourceGenerator)->forResource($descriptor, null, $all) as $file) {
130
+ $root = $roots[$file->target] ?? null;
131
+ $absolute = $root === null ? null : rtrim($root, '/\\').DIRECTORY_SEPARATOR.$file->path;
132
+ if ($file->mode === GeneratedFile::MODE_BLOCK && $absolute !== null && is_file($absolute) && Writer::block((string) file_get_contents($absolute)) === null) {
133
+ $taken[] = $file->path;
134
+ }
135
+ }
136
+
137
+ return $taken;
138
+ }
139
+ }
@@ -0,0 +1,254 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Console;
4
+
5
+ use Illuminate\Console\Command;
6
+ use Illuminate\Support\Facades\Schema;
7
+ use InvalidArgumentException;
8
+ use Nevela\Laravel\Generator\BlogPages;
9
+ use Nevela\Laravel\Generator\GeneratedFile;
10
+ use Nevela\Laravel\Generator\Writer;
11
+ use Nevela\Laravel\Media\Uploads;
12
+ use Nevela\Laravel\Nevela;
13
+ use Nevela\Laravel\Support\Descriptor;
14
+ use Nevela\Laravel\Support\Naming;
15
+
16
+ /**
17
+ * A blog for the site: posts written in the dashboard, and /blog to read them at.
18
+ *
19
+ * php artisan nevela:blog a Post resource, and the pages
20
+ * php artisan nevela:blog --resource=Article … the pages only, reading a resource the app has
21
+ *
22
+ * Everything a blog is made of is already a resource's to have: rich text, an image, a
23
+ * slug, a date it is published on, and public reading. This puts them together, and
24
+ * writes the pages that show the result.
25
+ */
26
+ final class BlogCommand extends Command
27
+ {
28
+ /** The resource this makes when the app has none to read. */
29
+ public const RESOURCE = 'Post';
30
+
31
+ public const PATH = '/blog';
32
+
33
+ protected $signature = 'nevela:blog
34
+ {--resource= : Read the posts from a resource you already have, and write only the pages}
35
+ {--title= : That resource\'s field for a post\'s title. Default: title}
36
+ {--body= : Its field for the post itself. Default: body}
37
+ {--excerpt= : Its field for a sentence or two about the post, if it has one}
38
+ {--image= : Its field for a picture, if it has one}
39
+ {--published= : Its field for when a post was published. Default: the one the resource is published by}
40
+ {--sample : Add three posts to start with. For development: refused in production}
41
+ {--no-migrate : Don\'t create the posts table yet}';
42
+
43
+ protected $description = 'Give the site a blog: a Post resource to write in, and /blog to read it at';
44
+
45
+ public function handle(): int
46
+ {
47
+ Nevela::forget();
48
+ try {
49
+ $all = Nevela::all();
50
+ $given = $this->option('resource');
51
+ [$descriptor, $fields, $created] = $given === null
52
+ ? $this->ours($all)
53
+ : [...$this->theirs((string) $given, $all), false];
54
+ } catch (InvalidArgumentException $e) {
55
+ $this->components->error($e->getMessage());
56
+
57
+ return self::FAILURE;
58
+ }
59
+
60
+ $dir = rtrim(config('nevela.descriptors_path'), '/\\');
61
+ if (! is_dir($dir)) {
62
+ mkdir($dir, 0775, true);
63
+ }
64
+ // The descriptor says where the blog is, so the site's header can link to it.
65
+ file_put_contents($dir.DIRECTORY_SEPARATOR.Naming::kebab($descriptor->name).'.json', $descriptor->toJson());
66
+ Nevela::forget();
67
+ if (GenerateCommand::generate($this, [$descriptor], Nevela::all(), false) !== self::SUCCESS) {
68
+ return self::FAILURE;
69
+ }
70
+
71
+ $web = config('nevela.web_path');
72
+ if (! $web || ! is_dir($web)) {
73
+ $this->components->warn('There is no web app at nevela.web_path, so the pages were not written. Run this again when there is.');
74
+ } else {
75
+ foreach ((new Writer([GeneratedFile::TARGET_WEB => $web]))->write(BlogPages::files($descriptor, $fields)) as $row) {
76
+ $path = str_replace('\\', '/', substr($row['path'], strlen(rtrim($web, '/\\')) + 1));
77
+ $this->components->twoColumnDetail("../web/{$path}", $row['status'] === 'created' ? '<fg=green>created</>' : '<fg=gray>exists (yours)</>');
78
+ }
79
+ }
80
+
81
+ $status = self::SUCCESS;
82
+ if ($created && ! $this->option('no-migrate')) {
83
+ $status = $this->call('migrate', ['--force' => true]);
84
+ }
85
+ if ($status === self::SUCCESS && $this->option('sample')) {
86
+ $status = $this->sample($descriptor);
87
+ }
88
+
89
+ $this->newLine();
90
+ $this->line(' The blog is at <fg=cyan>'.self::PATH.'</>, and '.$descriptor->pluralLabel.' is in the dashboard. A post is on the site once it has been published.');
91
+ if (! $created) {
92
+ $this->line(' Which records are public is the scope on <fg=cyan>app/Models/'.$descriptor->name.'.php</> (scopePublicQuery). The pages and <fg=cyan>lib/blog.ts</> are yours to change.');
93
+ }
94
+
95
+ return $status;
96
+ }
97
+
98
+ /**
99
+ * The Post resource, made here: what a post is, that it is published by a date, and
100
+ * that anyone may read a published one by its slug.
101
+ *
102
+ * @param list<Descriptor> $all
103
+ * @return array{Descriptor, array<string, string|null>, bool}
104
+ */
105
+ private function ours(array $all): array
106
+ {
107
+ $fields = ['title' => 'title', 'body' => 'body', 'excerpt' => 'excerpt', 'image' => 'image', 'published' => 'publishedAt'];
108
+ foreach ($all as $existing) {
109
+ if ($existing->name === self::RESOURCE || $existing->table === 'posts') {
110
+ // Run again on an app that has it: the pages are written if they aren't there, and nothing else changes.
111
+ if ($existing->blog !== null) {
112
+ return [$existing, array_map(fn (?string $field) => $field !== null && isset($existing->fields[$field]) ? $field : null, $fields), false];
113
+ }
114
+ throw new InvalidArgumentException("There is a {$existing->name} resource already. To have the blog read it: php artisan nevela:blog --resource={$existing->name}");
115
+ }
116
+ }
117
+ if ($this->tableExists('posts')) {
118
+ throw new InvalidArgumentException('There is a posts table already, which is not a resource. Adopt it (php artisan nevela:adopt posts), then: php artisan nevela:blog --resource=Post');
119
+ }
120
+
121
+ $descriptor = Descriptor::fromArray([
122
+ 'name' => self::RESOURCE,
123
+ 'icon' => 'book',
124
+ 'publish' => 'publishedAt',
125
+ 'blog' => self::PATH,
126
+ 'fields' => [
127
+ 'title' => ['kind' => 'string'],
128
+ 'slug' => ['kind' => 'string', 'format' => 'slug', 'unique' => true, 'from' => 'title'],
129
+ 'excerpt' => ['kind' => 'string', 'maxLength' => 500, 'required' => false],
130
+ 'body' => ['kind' => 'text', 'format' => 'rich'],
131
+ 'image' => ['kind' => 'file', 'accept' => ['image'], 'profile' => 'cover', 'required' => false],
132
+ 'publishedAt' => ['kind' => 'datetime', 'required' => false],
133
+ ],
134
+ 'public' => ['read' => ['by' => 'slug', 'fields' => ['title', 'slug', 'excerpt', 'body', 'image', 'publishedAt']]],
135
+ ]);
136
+
137
+ return [$descriptor, $fields, true];
138
+ }
139
+
140
+ /**
141
+ * A resource the app already has, read as posts. Nothing about it is changed but the
142
+ * note of where its blog is; what it lacks for a blog is said, and nothing is written.
143
+ *
144
+ * @param list<Descriptor> $all
145
+ * @return array{Descriptor, array<string, string|null>}
146
+ *
147
+ * @throws InvalidArgumentException
148
+ */
149
+ private function theirs(string $name, array $all): array
150
+ {
151
+ $descriptor = null;
152
+ foreach ($all as $candidate) {
153
+ if (strcasecmp($candidate->name, $name) === 0) {
154
+ $descriptor = $candidate;
155
+ }
156
+ }
157
+ if ($descriptor === null) {
158
+ throw new InvalidArgumentException("There is no resource called {$name}.");
159
+ }
160
+ $public = $descriptor->public;
161
+ if ($public?->read === null) {
162
+ throw new InvalidArgumentException("{$descriptor->name} can't be read publicly, and a blog is public. Add to its descriptor: \"public\": { \"read\": { \"by\": \"slug\", \"fields\": [ … ] } }");
163
+ }
164
+ if ($public->by === null) {
165
+ throw new InvalidArgumentException("{$descriptor->name}'s public section doesn't say which unique field a record is fetched by, and a post's address is made from one. Add \"by\": \"slug\" to its \"read\".");
166
+ }
167
+
168
+ $fields = [];
169
+ foreach (['title' => 'title', 'body' => 'body', 'excerpt' => null, 'image' => null, 'published' => $descriptor->publish] as $part => $default) {
170
+ $asked = $this->option($part);
171
+ // A column's name is understood as well as the field's: published_at, publishedAt.
172
+ $field = $asked === null ? $default : Naming::camel((string) $asked);
173
+ if ($field === null) {
174
+ $fields[$part] = null;
175
+
176
+ continue;
177
+ }
178
+ if (! isset($descriptor->fields[$field])) {
179
+ throw new InvalidArgumentException("{$descriptor->name} has no field called {$field}. Name the one that is a post's {$part}: --{$part}=…");
180
+ }
181
+ if (! in_array($field, $public->read, true)) {
182
+ throw new InvalidArgumentException("\"{$field}\" is not among the fields anyone may read of {$descriptor->name}, so the blog couldn't show it. Add it to \"public.read.fields\" in its descriptor.");
183
+ }
184
+ $fields[$part] = $field;
185
+ }
186
+
187
+ $data = $descriptor->toArray();
188
+ $data['blog'] = self::PATH;
189
+
190
+ return [Descriptor::fromArray($data), $fields];
191
+ }
192
+
193
+ /**
194
+ * Three posts, so /blog has something on it the first time it is opened. Like the
195
+ * sample users: for looking around a new app, and never for a live one.
196
+ */
197
+ private function sample(Descriptor $descriptor): int
198
+ {
199
+ if (app()->environment('production')) {
200
+ $this->components->error('Sample posts are for development. This app is in production, so none were made.');
201
+
202
+ return self::FAILURE;
203
+ }
204
+ $model = "App\\Models\\{$descriptor->name}";
205
+ if ($descriptor->name !== self::RESOURCE || ! class_exists($model) || ! $this->tableExists($descriptor->table)) {
206
+ $this->components->warn('Sample posts are for the Post resource this command makes, once its table is there.');
207
+
208
+ return self::SUCCESS;
209
+ }
210
+ if ($model::query()->exists()) {
211
+ return self::SUCCESS; // it has posts already; these would only be in the way
212
+ }
213
+
214
+ // A picture each, where pictures can be made here.
215
+ $pictures = Uploads::placeholders($descriptor, $descriptor->fields['image'], 3);
216
+ foreach (self::POSTS as $index => $post) {
217
+ $model::create($post + ['image' => $pictures[$index] ?? null, 'published_at' => now()->subDays(1 + $index * 3)]);
218
+ }
219
+ $this->components->info('Added three sample posts. Edit or delete them under Posts in the dashboard.');
220
+
221
+ return self::SUCCESS;
222
+ }
223
+
224
+ private function tableExists(string $table): bool
225
+ {
226
+ try {
227
+ return Schema::hasTable($table);
228
+ } catch (\Throwable) {
229
+ return false; // no database to ask yet
230
+ }
231
+ }
232
+
233
+ /** @var list<array{title: string, slug: string, excerpt: string, body: string}> */
234
+ private const POSTS = [
235
+ [
236
+ 'title' => 'Welcome to your blog',
237
+ 'slug' => 'welcome-to-your-blog',
238
+ 'excerpt' => 'This post came with the app. It is here so the blog has something on it the first time you open it.',
239
+ 'body' => '<p>This post came with the app. It is here so the blog has something on it the first time you open it, and you can delete it whenever you like.</p><h2>Where posts are written</h2><p>Sign in and open <strong>Posts</strong> in the dashboard. A post has a title, an excerpt for the list, a picture and a body with headings, lists, links and quotes.</p><h2>Where they are read</h2><p>At <a href="/blog">/blog</a>, newest first, and each at an address of its own made from its title.</p>',
240
+ ],
241
+ [
242
+ 'title' => 'Drafts, publishing and scheduling',
243
+ 'slug' => 'drafts-publishing-and-scheduling',
244
+ 'excerpt' => 'A post is not on the site until it is published. Until then it is a draft, and only people who sign in can see it.',
245
+ 'body' => '<p>A post is not on the site until it is published. Until then it is a draft, and only people who sign in can see it.</p><ul><li><strong>Publish</strong> puts it on the site at once.</li><li><strong>Unpublish</strong> takes it off again and keeps it as a draft.</li><li>Give it a date and time still to come and it is <strong>scheduled</strong>: it appears by itself when that time arrives.</li></ul><p>The list in the dashboard says which of the three each post is, and can be filtered by it.</p>',
246
+ ],
247
+ [
248
+ 'title' => 'Making the blog yours',
249
+ 'slug' => 'making-the-blog-yours',
250
+ 'excerpt' => 'The pages that show the blog were written once, into your own app, and are yours to change.',
251
+ 'body' => '<p>The pages that show the blog were written once, into your own app, and are yours to change.</p><h3>What to open</h3><ol><li><strong>lib/blog.ts</strong> for the blog\'s title, its description and how many posts are on a page.</li><li><strong>app/blog</strong> for the list and for a post\'s own page.</li></ol><blockquote><p>There is an RSS feed at /blog/feed.xml, and every published post is in the sitemap.</p></blockquote>',
252
+ ],
253
+ ];
254
+ }
@@ -40,6 +40,13 @@ final class DevCommand extends Command
40
40
  $web = Process::fromShellCommandline(self::packageManager($webPath).' run dev', $webPath, ['NEVELA_API_URL' => "{$url}/api"], null, null);
41
41
  }
42
42
 
43
+ // Mail a public form causes is queued, and a queue with nobody working it sends
44
+ // nothing: a confirmation link would never arrive. So the queue is worked here too,
45
+ // unless the app runs jobs as they are made.
46
+ $queue = in_array(config('queue.default'), ['sync', 'null', null], true)
47
+ ? null
48
+ : new Process([$php, 'artisan', 'queue:listen', '--tries=1', '--sleep=2'], base_path(), null, null, null);
49
+
43
50
  $this->components->info('Starting '.($web ? 'the API and the dashboard' : 'the API').'. Press Ctrl+C to stop.');
44
51
  $this->components->twoColumnDetail('API', $url);
45
52
  if ($web) {
@@ -49,6 +56,7 @@ final class DevCommand extends Command
49
56
 
50
57
  $api->start($this->printer('api', 35));
51
58
  $web?->start($this->printer('web', 36));
59
+ $queue?->start($this->printer('queue', 33));
52
60
 
53
61
  try {
54
62
  while ($api->isRunning() && ($web === null || $web->isRunning())) {
@@ -56,6 +64,7 @@ final class DevCommand extends Command
56
64
  }
57
65
  } finally {
58
66
  // Whichever stopped, or Ctrl+C: don't leave the other one running behind.
67
+ $queue?->stop(3);
59
68
  $web?->stop(3);
60
69
  $api->stop(3);
61
70
  }