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.
- package/index.mjs +25 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +66 -3
- package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
- package/template/laravel/resources/disposable-domains.txt +9880 -0
- package/template/laravel/src/Access/ClosedAccounts.php +2 -1
- package/template/laravel/src/Access/Permissions.php +36 -5
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -0
- package/template/laravel/src/Console/BlogCommand.php +254 -0
- package/template/laravel/src/Console/DevCommand.php +9 -0
- package/template/laravel/src/Console/DigestCommand.php +28 -0
- package/template/laravel/src/Console/DisposableCommand.php +49 -0
- package/template/laravel/src/Console/GenerateCommand.php +38 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- package/template/laravel/src/Console/SetupCommand.php +15 -2
- package/template/laravel/src/Console/StatusCommand.php +14 -0
- package/template/laravel/src/Console/TrashCommand.php +17 -0
- package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
- package/template/laravel/src/Forms/Confirmation.php +88 -0
- package/template/laravel/src/Forms/Disposable.php +74 -0
- package/template/laravel/src/Forms/FormBlocks.php +76 -0
- package/template/laravel/src/Forms/FormGuard.php +359 -0
- package/template/laravel/src/Forms/OwnerDigest.php +63 -0
- package/template/laravel/src/Forms/PublicMail.php +217 -0
- package/template/laravel/src/Forms/Refusal.php +50 -0
- package/template/laravel/src/Generator/BlogPages.php +409 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
- package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
- package/template/laravel/src/Http/Access/RolesController.php +2 -1
- package/template/laravel/src/Http/Access/UsersController.php +2 -1
- package/template/laravel/src/Http/ActionController.php +223 -0
- package/template/laravel/src/Http/Auth/AccountController.php +6 -3
- package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
- package/template/laravel/src/Http/FormsController.php +45 -0
- package/template/laravel/src/Http/PublicController.php +294 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +193 -4
- package/template/laravel/src/Http/TrashController.php +19 -2
- package/template/laravel/src/Http/UploadController.php +42 -1
- package/template/laravel/src/Media/Uploads.php +111 -0
- package/template/laravel/src/Nevela.php +83 -2
- package/template/laravel/src/NevelaServiceProvider.php +27 -2
- package/template/laravel/src/Support/Action.php +168 -0
- package/template/laravel/src/Support/Adoption.php +352 -0
- package/template/laravel/src/Support/Descriptor.php +83 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +91 -3
- package/template/laravel/src/Support/Html.php +185 -0
- package/template/laravel/src/Support/Key.php +57 -0
- package/template/laravel/src/Support/ListQuery.php +29 -1
- package/template/laravel/src/Support/PublicAccess.php +208 -0
- package/template/laravel/src/Support/Slug.php +43 -0
- package/template/laravel/src/Trash/Trash.php +3 -1
- package/template/web/.env.example +4 -0
- package/template/web/app/api/[...path]/route.ts +42 -9
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +48 -0
- package/template/web/app/globals.css +46 -0
- package/template/web/components/access/role-form.tsx +40 -5
- package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
- package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
- package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
- package/template/web/components/dashboard/fields/file-field.tsx +9 -5
- package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
- package/template/web/components/dashboard/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +33 -4
- package/template/web/components/dashboard/record-detail.tsx +10 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- package/template/web/components/dashboard/resource-form.tsx +19 -2
- package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
- package/template/web/components/dashboard/resource-table.tsx +46 -4
- package/template/web/components/dashboard/row-actions.tsx +56 -2
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- package/template/web/components/marketing/site-header.tsx +9 -2
- package/template/web/components/public-form-guard.tsx +125 -0
- package/template/web/hooks/use-public-form.ts +175 -0
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +105 -0
- package/template/web/lib/files.ts +14 -0
- package/template/web/lib/laravel.ts +60 -0
- package/template/web/lib/rich.ts +79 -0
- 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
|
-
$
|
|
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
|
-
|
|
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 === '*'
|
|
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_]*)\.(\*|
|
|
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
|
}
|