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
@@ -12,7 +12,18 @@ use JsonException;
12
12
  */
13
13
  final class Descriptor
14
14
  {
15
- /** @param array<string, Field> $fields keyed by field name, in declaration order */
15
+ /** What a record's id can be: a UUID, which is what Nevela makes, or the auto-incrementing integer of `$table->id()`. */
16
+ public const ID_TYPES = ['uuid', 'int'];
17
+
18
+ /**
19
+ * @param array<string, Field> $fields keyed by field name, in declaration order
20
+ * @param string $idType "uuid" or "int". Chosen when the resource is made: changing it later means migrating the table yourself.
21
+ * @param bool $adopted The table was there before Nevela, so no migration is written to create it
22
+ * @param array<string, Action> $actions What can be done to a record or to the list beyond create, edit and delete, by name
23
+ * @param PublicAccess|null $public What of it anyone may read or create without signing in. Nothing, when null.
24
+ * @param string|null $publish The date-and-time field that says when a record is published: empty is a draft, a time to come is scheduled
25
+ * @param string|null $blog Where on the site its public records are a blog, e.g. "/blog". The pages are written by `nevela:blog`.
26
+ */
16
27
  public function __construct(
17
28
  public readonly string $name,
18
29
  public readonly array $fields,
@@ -23,20 +34,59 @@ final class Descriptor
23
34
  public readonly ?string $icon = null,
24
35
  public readonly ?string $group = null,
25
36
  public readonly ?string $titleField = null,
37
+ public readonly string $idType = 'uuid',
38
+ public readonly bool $adopted = false,
39
+ public readonly array $actions = [],
40
+ public readonly ?PublicAccess $public = null,
41
+ public readonly ?string $publish = null,
42
+ public readonly ?string $blog = null,
26
43
  ) {
27
44
  if (! preg_match('/^[A-Z][A-Za-z0-9]*$/', $name)) {
28
45
  throw new InvalidArgumentException('Resource name must be PascalCase, e.g. Product or BlogPost.');
29
46
  }
47
+ if (! in_array($idType, self::ID_TYPES, true)) {
48
+ throw new InvalidArgumentException("\"{$idType}\" is not an id type. Use uuid or int.");
49
+ }
30
50
  if ($fields === []) {
31
51
  throw new InvalidArgumentException("Resource {$name} needs at least one field.");
32
52
  }
33
53
  if ($titleField !== null && ! isset($fields[$titleField])) {
34
54
  throw new InvalidArgumentException("titleField \"{$titleField}\" is not a field of {$name}.");
35
55
  }
56
+ $keys = [];
57
+ foreach ($actions as $action) {
58
+ foreach (array_keys($action->when) as $field) {
59
+ if (! isset($fields[$field])) {
60
+ throw new InvalidArgumentException("Action \"{$action->name}\" of {$name} depends on \"{$field}\", which is not one of its fields.");
61
+ }
62
+ }
63
+ // sendNow and send_now would be the same permission.
64
+ if (isset($keys[$action->key()])) {
65
+ throw new InvalidArgumentException("{$name} has two actions that come to \"{$action->key()}\".");
66
+ }
67
+ $keys[$action->key()] = true;
68
+ }
69
+ $public?->check($name, $fields);
70
+ foreach ($fields as $field) {
71
+ if ($field->from !== null && (! isset($fields[$field->from]) || ! in_array($fields[$field->from]->kind, ['string', 'text'], true))) {
72
+ throw new InvalidArgumentException("{$name}: \"{$field->name}\" is made from \"{$field->from}\", which has to be a string field of {$name}.");
73
+ }
74
+ }
75
+ if ($publish !== null && (! isset($fields[$publish]) || $fields[$publish]->kind !== 'datetime' || $fields[$publish]->required)) {
76
+ throw new InvalidArgumentException("{$name}: \"publish\" names the field that says when a record is published. It has to be an optional datetime: {$publish}:datetime?");
77
+ }
78
+ if ($blog !== null) {
79
+ if (! preg_match('#^/[a-z0-9][a-z0-9/-]*$#', $blog)) {
80
+ throw new InvalidArgumentException("{$name}: \"blog\" is where the blog is on the site, such as \"/blog\".");
81
+ }
82
+ if ($public?->read === null || $public->by === null) {
83
+ throw new InvalidArgumentException("{$name}: a blog reads its posts publicly, each by a unique field. Give it \"public\": { \"read\": { \"by\": \"slug\", \"fields\": […] } }.");
84
+ }
85
+ }
36
86
  }
37
87
 
38
88
  /** Build from a name and a `--fields` string: "name:string, price:money, status:enum(draft|live)?". */
39
- public static function fromSpec(string $name, string $spec, ?string $icon = null, ?string $group = null): self
89
+ public static function fromSpec(string $name, string $spec, ?string $icon = null, ?string $group = null, string $idType = 'uuid'): self
40
90
  {
41
91
  $fields = [];
42
92
  foreach (array_filter(array_map('trim', self::splitSpec($spec))) as $token) {
@@ -47,11 +97,11 @@ final class Descriptor
47
97
  $fields[$field->name] = $field;
48
98
  }
49
99
 
50
- return self::make(Naming::pascal($name), $fields, icon: $icon, group: $group);
100
+ return self::make(Naming::pascal($name), $fields, icon: $icon, group: $group, idType: $idType);
51
101
  }
52
102
 
53
103
  /** @param array<string, Field> $fields */
54
- public static function make(string $name, array $fields, ?string $icon = null, ?string $group = null, ?string $titleField = null): self
104
+ public static function make(string $name, array $fields, ?string $icon = null, ?string $group = null, ?string $titleField = null, string $idType = 'uuid'): self
55
105
  {
56
106
  $plural = Naming::plural($name);
57
107
 
@@ -65,6 +115,7 @@ final class Descriptor
65
115
  $icon,
66
116
  $group,
67
117
  $titleField,
118
+ $idType,
68
119
  );
69
120
  }
70
121
 
@@ -77,6 +128,10 @@ final class Descriptor
77
128
  $fields[$key] = Field::fromArray($key, (array) $field);
78
129
  }
79
130
  $base = self::make($name, $fields);
131
+ $actions = [];
132
+ foreach ((array) ($data['actions'] ?? []) as $key => $action) {
133
+ $actions[(string) $key] = Action::fromArray((string) $key, (array) $action);
134
+ }
80
135
 
81
136
  return new self(
82
137
  $name,
@@ -88,6 +143,12 @@ final class Descriptor
88
143
  $data['icon'] ?? null,
89
144
  $data['group'] ?? null,
90
145
  $data['titleField'] ?? null,
146
+ (string) ($data['id'] ?? 'uuid'),
147
+ (bool) ($data['adopted'] ?? false),
148
+ $actions,
149
+ isset($data['public']) ? PublicAccess::fromArray((array) $data['public']) : null,
150
+ $data['publish'] ?? null,
151
+ $data['blog'] ?? null,
91
152
  );
92
153
  }
93
154
 
@@ -107,13 +168,20 @@ final class Descriptor
107
168
  '$schema' => 'https://nevela.dev/schema/resource.v1.json',
108
169
  'name' => $this->name,
109
170
  'table' => $this->table,
171
+ // Left out when they are what every descriptor written before them means.
172
+ 'id' => $this->idType === 'uuid' ? null : $this->idType,
173
+ 'adopted' => $this->adopted ?: null,
110
174
  'slug' => $this->slug,
111
175
  'label' => $this->label,
112
176
  'pluralLabel' => $this->pluralLabel,
113
177
  'icon' => $this->icon,
114
178
  'group' => $this->group,
115
179
  'titleField' => $this->titleField,
180
+ 'publish' => $this->publish,
181
+ 'blog' => $this->blog,
116
182
  'fields' => array_map(fn (Field $f) => $f->toArray(), $this->fields),
183
+ 'actions' => $this->actions ? array_map(fn (Action $a) => $a->toArray(), $this->actions) : null,
184
+ 'public' => $this->public?->toArray(),
117
185
  ], fn ($v) => $v !== null);
118
186
  }
119
187
 
@@ -137,6 +205,17 @@ final class Descriptor
137
205
  return 'id';
138
206
  }
139
207
 
208
+ /**
209
+ * This resource with only some of its fields: what a list of it is sorted, filtered
210
+ * and searched by when the list is the public one.
211
+ *
212
+ * @param list<string> $fields
213
+ */
214
+ public function only(array $fields): self
215
+ {
216
+ return new self($this->name, array_intersect_key($this->fields, array_flip($fields)), $this->table, $this->slug, $this->label, $this->pluralLabel, $this->icon, $this->group, null, $this->idType, $this->adopted);
217
+ }
218
+
140
219
  public function variable(): string
141
220
  {
142
221
  return Naming::camel($this->name);
@@ -133,11 +133,25 @@ final class Fake
133
133
  'datetime' => $field->unique
134
134
  ? gmdate('Y-m-d H:i:s', self::EPOCH + $number)
135
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}" : ''),
136
+ 'text' => $field->isRich()
137
+ ? self::article($field->unique ? " {$number}" : '')
138
+ : self::sentence(mt_rand(8, 24)).($field->unique ? " {$number}" : ''),
137
139
  default => self::string($field, $number, $first, $last, $isPerson),
138
140
  };
139
141
  }
140
142
 
143
+ /** A few paragraphs with a heading and a list, so a seeded record reads like one somebody wrote. */
144
+ private static function article(string $suffix): string
145
+ {
146
+ $items = implode('', array_map(fn () => '<li>'.ucfirst(self::sentence(mt_rand(3, 6))).'</li>', range(1, 3)));
147
+
148
+ return '<p>'.self::sentence(mt_rand(14, 24)).'</p>'
149
+ .'<h2>'.rtrim(self::sentence(mt_rand(3, 5)), '.').$suffix.'</h2>'
150
+ .'<p>'.self::sentence(mt_rand(12, 20)).' <strong>'.rtrim(self::sentence(mt_rand(2, 4)), '.').'</strong>, '.lcfirst(self::sentence(mt_rand(6, 10))).'</p>'
151
+ ."<ul>{$items}</ul>"
152
+ .'<p>'.self::sentence(mt_rand(10, 18)).'</p>';
153
+ }
154
+
141
155
  private static function string(Field $field, int $number, string $first, string $last, bool $isPerson): string
142
156
  {
143
157
  $name = strtolower($field->name);
@@ -14,6 +14,7 @@ final class Field
14
14
 
15
15
  public const FORMATS = [
16
16
  'string' => ['email', 'url', 'tel', 'slug', 'color'],
17
+ 'text' => ['rich'],
17
18
  'float' => ['money', 'percent', 'rating'],
18
19
  'int' => ['percent', 'rating'],
19
20
  ];
@@ -23,7 +24,7 @@ final class Field
23
24
 
24
25
  /** Shorthand types accepted by --fields, mapped to kind + format. */
25
26
  private const ALIASES = [
26
- 'string' => ['string', null], 'text' => ['text', null],
27
+ 'string' => ['string', null], 'text' => ['text', null], 'richtext' => ['text', 'rich'],
27
28
  'email' => ['string', 'email'], 'url' => ['string', 'url'], 'tel' => ['string', 'tel'],
28
29
  'slug' => ['string', 'slug'], 'color' => ['string', 'color'],
29
30
  'int' => ['int', null], 'integer' => ['int', null],
@@ -40,6 +41,10 @@ final class Field
40
41
  * @param string|null $target belongsTo: the resource this points at, e.g. "Category"
41
42
  * @param list<string> $accept file: the categories it takes, e.g. ["image"]
42
43
  * @param string|null $profile file: the image profile uploads are optimised with (config nevela.uploads.profiles)
44
+ * @param int|null $maxLength string and text: the most characters it holds, where that isn't the usual 255 or 65,535
45
+ * @param bool $private file: kept where the web server doesn't reach, and handed only to someone who may view the record
46
+ * @param string|int|float|bool|null $default What the field is when a create leaves it out. Also the column's default.
47
+ * @param string|null $from slug: the field it is made from when a create leaves it empty, e.g. "title"
43
48
  */
44
49
  public function __construct(
45
50
  public readonly string $name,
@@ -52,6 +57,10 @@ final class Field
52
57
  public readonly ?string $target = null,
53
58
  public readonly array $accept = [],
54
59
  public readonly ?string $profile = null,
60
+ public readonly ?int $maxLength = null,
61
+ public readonly bool $private = false,
62
+ public readonly string|int|float|bool|null $default = null,
63
+ public readonly ?string $from = null,
55
64
  ) {
56
65
  if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $name)) {
57
66
  throw new InvalidArgumentException("Field \"{$name}\" must be camelCase (e.g. publishedAt).");
@@ -65,6 +74,9 @@ final class Field
65
74
  if ($format !== null && ! in_array($format, self::FORMATS[$kind] ?? [], true)) {
66
75
  throw new InvalidArgumentException("Format \"{$format}\" is not valid for {$kind} field \"{$name}\".");
67
76
  }
77
+ if ($maxLength !== null && (! in_array($kind, ['string', 'text'], true) || $format === 'color' || $maxLength < 1)) {
78
+ throw new InvalidArgumentException("\"{$name}\" can't have a length of {$maxLength}: a length is a whole number of 1 or more, on a string or text field.");
79
+ }
68
80
  if ($kind === 'enum' && $options === []) {
69
81
  throw new InvalidArgumentException("Enum field \"{$name}\" needs values, e.g. {$name}:enum(draft|published).");
70
82
  }
@@ -81,6 +93,33 @@ final class Field
81
93
  throw new InvalidArgumentException("A belongsTo field holds an id, so its name ends in \"Id\": {$name}Id.");
82
94
  }
83
95
  }
96
+ if ($default !== null) {
97
+ $fits = match ($kind) {
98
+ 'string', 'text' => is_string($default) && ! ($kind === 'text' && $format === 'rich'),
99
+ 'int' => is_int($default),
100
+ 'float' => is_int($default) || is_float($default),
101
+ 'boolean' => is_bool($default),
102
+ 'enum' => is_string($default) && in_array($default, $options, true),
103
+ default => false,
104
+ };
105
+ if (! $fits) {
106
+ throw new InvalidArgumentException("The default of \"{$name}\" has to be a value the field could hold".($kind === 'enum' ? ': one of '.implode(', ', $options).'.' : (in_array($kind, ['string', 'text', 'int', 'float', 'boolean'], true) ? '.' : ", and a {$kind} field can't have one.")));
107
+ }
108
+ if ($unique) {
109
+ throw new InvalidArgumentException("\"{$name}\" is unique, so only one record could ever have its default.");
110
+ }
111
+ }
112
+ if ($from !== null) {
113
+ if ($kind !== 'string' || $format !== 'slug') {
114
+ throw new InvalidArgumentException("\"{$name}\" can't be made from another field: that is for a slug ({$name}:slug({$from})).");
115
+ }
116
+ if (! preg_match('/^[a-z][A-Za-z0-9]*$/', $from) || $from === $name) {
117
+ throw new InvalidArgumentException("\"{$name}\" is made from a field of the same resource, named as it is there: {$name}:slug(title).");
118
+ }
119
+ }
120
+ if ($private && $kind !== 'file') {
121
+ throw new InvalidArgumentException("\"{$name}\" can't be private: that is for file fields. Who may see a record's other fields is its policy's to say.");
122
+ }
84
123
  if ($kind === 'file') {
85
124
  if ($accept === []) {
86
125
  throw new InvalidArgumentException("File field \"{$name}\" needs what it accepts, e.g. {$name}:image or {$name}:file(pdf|image).");
@@ -103,15 +142,41 @@ final class Field
103
142
  * Parse one `name:type` token. Suffix `?` = optional (nullable), `!` = unique; both may be combined.
104
143
  *
105
144
  * status:enum(draft|published)
145
+ * excerpt:string(500) a length other than the usual 255
146
+ * body:richtext formatted text, kept as HTML that has been cleaned
106
147
  * category:belongsTo(Category) stored as categoryId
107
148
  * image:image an optimised image; image:image(product) names a profile
108
149
  * manual:file(pdf|document) any other upload
150
+ * cv:file(pdf|private) one only those who may view the record can fetch
151
+ * status:enum(pending|paid)=pending what it is when a create leaves it out
152
+ * slug:slug(title)! made from the title when left empty
109
153
  *
110
154
  * Grit's and Flare's spellings are understood too: see normalise().
111
155
  */
112
156
  public static function parse(string $token): self
113
157
  {
114
158
  $written = trim($token);
159
+ // status:enum(pending|paid)=pending: what the field is when a create leaves it out.
160
+ $default = null;
161
+ if (($at = strpos($written, '=')) !== false) {
162
+ $default = trim(substr($written, $at + 1));
163
+ $written = rtrim(substr($written, 0, $at));
164
+ }
165
+ if ($default !== null) {
166
+ $field = self::parse($written);
167
+ $value = match ($field->kind) {
168
+ 'int' => filter_var($default, FILTER_VALIDATE_INT) === false ? $default : (int) $default,
169
+ 'float' => is_numeric($default) ? (float) $default : $default,
170
+ 'boolean' => match (strtolower($default)) {
171
+ 'true', '1', 'yes' => true,
172
+ 'false', '0', 'no' => false,
173
+ default => $default,
174
+ },
175
+ default => $default,
176
+ };
177
+
178
+ return self::fromArray($field->name, ['default' => $value] + $field->toArray());
179
+ }
115
180
  $token = self::normalise($written);
116
181
  if (! preg_match('/^([A-Za-z][A-Za-z0-9_]*):([A-Za-z_]+)(?:\(([^)]*)\))?([?!]{0,2})$/', $token, $m)) {
117
182
  throw new InvalidArgumentException("Invalid field \"{$written}\". Expected name:type, e.g. price:money, status:enum(draft|live)?, image:image or category:belongsTo(Category).");
@@ -135,14 +200,23 @@ final class Field
135
200
  return new self($name, 'file', null, $required, $unique, accept: ['image'], profile: $list[0] ?? null);
136
201
  }
137
202
  if ($type === 'file') {
138
- return new self($name, 'file', null, $required, $unique, accept: $list ?: ['any']);
203
+ // cv:file(pdf|private): "private" among what it takes is how it is kept, not a kind of file.
204
+ $accept = array_values(array_diff($list, ['private']));
205
+
206
+ return new self($name, 'file', null, $required, $unique, accept: $accept ?: ['any'], private: count($accept) !== count($list));
139
207
  }
140
208
  if (! isset(self::ALIASES[$type])) {
141
209
  throw new InvalidArgumentException("Unknown type \"{$type}\" on \"{$name}\". Use one of: ".implode(', ', array_keys(self::ALIASES)).', enum(a|b), belongsTo(Resource), image, file(pdf|…).');
142
210
  }
143
211
  [$kind, $format] = self::ALIASES[$type];
212
+ // slug:slug(title)!: made from the title when a create leaves it empty.
213
+ if ($format === 'slug' && $args !== '' && ! ctype_digit($args)) {
214
+ return new self($name, $kind, $format, $required, $unique, from: Naming::camel($args));
215
+ }
216
+ // excerpt:string(500): a length of its own.
217
+ $maxLength = in_array($kind, ['string', 'text'], true) && $format !== 'color' && ctype_digit($args) ? (int) $args : null;
144
218
 
145
- return new self($name, $kind, $format, $required, $unique);
219
+ return new self($name, $kind, $format, $required, $unique, maxLength: $maxLength);
146
220
  }
147
221
 
148
222
  /**
@@ -183,6 +257,10 @@ final class Field
183
257
  $data['target'] ?? null,
184
258
  array_values($data['accept'] ?? []),
185
259
  $data['profile'] ?? null,
260
+ isset($data['maxLength']) ? (int) $data['maxLength'] : null,
261
+ (bool) ($data['private'] ?? false),
262
+ $data['default'] ?? null,
263
+ $data['from'] ?? null,
186
264
  );
187
265
  }
188
266
 
@@ -196,6 +274,10 @@ final class Field
196
274
  'target' => $this->target,
197
275
  'accept' => $this->accept ?: null,
198
276
  'profile' => $this->profile,
277
+ 'maxLength' => $this->maxLength,
278
+ 'private' => $this->private ?: null,
279
+ 'default' => $this->default,
280
+ 'from' => $this->from,
199
281
  'required' => $this->required ? null : false,
200
282
  'unique' => $this->unique ?: null,
201
283
  'label' => $this->label,
@@ -219,6 +301,12 @@ final class Field
219
301
  return substr($this->name, 0, -2);
220
302
  }
221
303
 
304
+ /** Whether this is text with formatting: written in an editor, kept as HTML, cleaned on the way in and out. */
305
+ public function isRich(): bool
306
+ {
307
+ return $this->kind === 'text' && $this->format === 'rich';
308
+ }
309
+
222
310
  /** Whether this is a file field that takes images only, and so is optimised and shown as a picture. */
223
311
  public function isImage(): bool
224
312
  {
@@ -0,0 +1,185 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use DOMDocument;
6
+ use DOMElement;
7
+ use DOMNode;
8
+ use DOMText;
9
+
10
+ /**
11
+ * Rich text, made safe to keep and to show.
12
+ *
13
+ * What a rich text field receives is whatever was sent: the dashboard's editor, a script,
14
+ * a CSV file, a visitor's form. None of it is trusted. The HTML is parsed, and written
15
+ * out again from what was understood, keeping only the tags and attributes on a list
16
+ * (config: nevela.rich_text). A tag that isn't on it goes and its words stay; a tag whose
17
+ * contents are not words, such as a script, goes with them.
18
+ *
19
+ * The same is done when a record is read, so that HTML which reached the table some other
20
+ * way (a column adopted from an older app, a row written by hand) is never shown as it is.
21
+ */
22
+ final class Html
23
+ {
24
+ /** What the dashboard's editor can make. */
25
+ public const TAGS = ['p', 'br', 'strong', 'em', 's', 'a', 'h2', 'h3', 'ul', 'ol', 'li', 'blockquote'];
26
+
27
+ /** Attributes kept, by tag. A link keeps where it goes and nothing else. */
28
+ public const ATTRIBUTES = ['a' => ['href']];
29
+
30
+ /** Where a link may go. One with no scheme, such as /about, is the site's own and is kept. */
31
+ public const SCHEMES = ['http', 'https', 'mailto'];
32
+
33
+ /** Other spellings of the same thing, as browsers and word processors write them. */
34
+ private const SAME_AS = ['b' => 'strong', 'i' => 'em', 'strike' => 's', 'del' => 's'];
35
+
36
+ /** Removed with everything inside them: what they hold was never text. */
37
+ private const DROPPED = ['script', 'style', 'iframe', 'object', 'embed', 'noscript', 'template', 'svg', 'math', 'head', 'title', 'textarea', 'select', 'option', 'button', 'audio', 'video', 'canvas', 'applet', 'frame', 'frameset', 'link', 'meta', 'base'];
38
+
39
+ /** Tags that sit inside a line of text. Anything else stands on lines of its own. */
40
+ private const INLINE = ['a', 'strong', 'em', 's', 'b', 'i', 'u', 'strike', 'del', 'ins', 'span', 'code', 'sub', 'sup', 'small', 'mark', 'font', 'br', 'abbr', 'cite', 'q', 'label', 'time', 'img', 'kbd', 'var', 'samp', 'big', 'tt', 'wbr'];
41
+
42
+ private const EMPTY = ['br', 'hr', 'img'];
43
+
44
+ /**
45
+ * The HTML with only what the list allows, or an empty string when there is nothing
46
+ * in it to read: an editor that was opened and left sends `<p><br></p>`.
47
+ *
48
+ * @param array{tags?: list<string>, attributes?: array<string, list<string>>, schemes?: list<string>}|null $allow Null for config('nevela.rich_text')
49
+ */
50
+ public static function clean(?string $html, ?array $allow = null): string
51
+ {
52
+ if ($html === null || trim($html) === '') {
53
+ return '';
54
+ }
55
+ $allow ??= function_exists('config') && function_exists('app') && app()->bound('config') ? (array) config('nevela.rich_text', []) : [];
56
+ $tags = array_map('strtolower', (array) ($allow['tags'] ?? self::TAGS));
57
+ $attributes = (array) ($allow['attributes'] ?? self::ATTRIBUTES);
58
+ $schemes = array_map('strtolower', (array) ($allow['schemes'] ?? self::SCHEMES));
59
+
60
+ $document = new DOMDocument;
61
+ $before = libxml_use_internal_errors(true);
62
+ // Characters outside ASCII go in as numbered entities: the parser would otherwise
63
+ // have to guess the encoding, and guesses Latin-1.
64
+ $document->loadHTML('<html><body>'.mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8').'</body></html>');
65
+ libxml_clear_errors();
66
+ libxml_use_internal_errors($before);
67
+
68
+ $body = $document->getElementsByTagName('body')->item(0);
69
+ if ($body === null) {
70
+ return '';
71
+ }
72
+ $out = self::top($body, $tags, $attributes, $schemes);
73
+ $words = trim(html_entity_decode(strip_tags($out), ENT_QUOTES | ENT_HTML5, 'UTF-8'), " \t\n\r\0\x0B\u{A0}");
74
+
75
+ return $words === '' && ! str_contains($out, '<img') ? '' : $out;
76
+ }
77
+
78
+ /**
79
+ * The top of the document, where everything is a paragraph, a heading, a list or a
80
+ * quote. Text and inline tags found loose there are gathered into paragraphs, so that
81
+ * plain text from a CSV file or a script is stored as what the editor would have made.
82
+ * A wrapper that isn't kept, such as a div, is looked into the same way.
83
+ *
84
+ * @param list<string> $tags
85
+ * @param array<string, list<string>> $attributes
86
+ * @param list<string> $schemes
87
+ */
88
+ private static function top(DOMNode $parent, array $tags, array $attributes, array $schemes): string
89
+ {
90
+ $paragraph = fn (string $inline) => trim($inline) === '' ? '' : (in_array('p', $tags, true) ? '<p>'.trim($inline).'</p>' : $inline);
91
+ $out = '';
92
+ $loose = '';
93
+ foreach ($parent->childNodes as $node) {
94
+ $name = $node instanceof DOMElement ? strtolower($node->nodeName) : '';
95
+ $name = self::SAME_AS[$name] ?? $name;
96
+ if ($name === '' || in_array($name, self::INLINE, true)) {
97
+ $loose .= self::write($node, $tags, $attributes, $schemes);
98
+
99
+ continue;
100
+ }
101
+ $out .= $paragraph($loose);
102
+ $loose = '';
103
+ $out .= in_array($name, $tags, true) || in_array($name, self::DROPPED, true)
104
+ ? self::write($node, $tags, $attributes, $schemes)
105
+ : self::top($node, $tags, $attributes, $schemes);
106
+ }
107
+
108
+ return $out.$paragraph($loose);
109
+ }
110
+
111
+ /** The words alone, on one line: what a table cell or a search result shows. */
112
+ public static function text(?string $html, int $limit = 0): string
113
+ {
114
+ $text = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags((string) preg_replace('/<(br|\/p|\/h[1-6]|\/li|\/blockquote)\b[^>]*>/i', ' ', (string) $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
115
+
116
+ return $limit > 0 && mb_strlen($text) > $limit ? rtrim(mb_substr($text, 0, $limit - 1)).'…' : $text;
117
+ }
118
+
119
+
120
+ /**
121
+ * @param list<string> $tags
122
+ * @param array<string, list<string>> $attributes
123
+ * @param list<string> $schemes
124
+ */
125
+ private static function write(DOMNode $node, array $tags, array $attributes, array $schemes): string
126
+ {
127
+ if ($node instanceof DOMText) {
128
+ return htmlspecialchars($node->nodeValue ?? '', ENT_NOQUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
129
+ }
130
+ if (! $node instanceof DOMElement) {
131
+ return ''; // a comment, a processing instruction
132
+ }
133
+ $name = strtolower($node->nodeName);
134
+ $name = self::SAME_AS[$name] ?? $name;
135
+ if (in_array($name, self::DROPPED, true)) {
136
+ return '';
137
+ }
138
+
139
+ $inner = '';
140
+ foreach ($node->childNodes as $child) {
141
+ $inner .= self::write($child, $tags, $attributes, $schemes);
142
+ }
143
+ if (! in_array($name, $tags, true)) {
144
+ // Not a tag to keep, but its words are. Where it stood on its own line, a space keeps them apart.
145
+ return in_array($name, self::INLINE, true) && $name !== 'br' ? $inner : $inner.' ';
146
+ }
147
+
148
+ $kept = '';
149
+ foreach ((array) ($attributes[$name] ?? []) as $attribute) {
150
+ $value = $node->getAttribute($attribute);
151
+ if ($value === '' || (in_array($attribute, ['href', 'src'], true) && ! self::safe($value, $schemes))) {
152
+ continue;
153
+ }
154
+ $kept .= ' '.$attribute.'="'.htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8').'"';
155
+ }
156
+ // A link that goes nowhere is its words.
157
+ if ($name === 'a' && $kept === '') {
158
+ return $inner;
159
+ }
160
+ // A tag with nothing in it is nothing. Browsers leave these behind: Chrome puts a
161
+ // list inside the paragraph it was started in, which leaves an empty paragraph
162
+ // before it once the two are told apart. (A blank line is <p><br></p>, and is kept.)
163
+ if (! in_array($name, self::EMPTY, true) && trim($inner) === '') {
164
+ return '';
165
+ }
166
+
167
+ return in_array($name, self::EMPTY, true) ? "<{$name}{$kept}>" : "<{$name}{$kept}>{$inner}</{$name}>";
168
+ }
169
+
170
+ /**
171
+ * Whether an address is one a link may have. A browser ignores spaces and control
172
+ * characters inside a scheme, so `java\tscript:` is read here the way it would be there.
173
+ *
174
+ * @param list<string> $schemes
175
+ */
176
+ private static function safe(string $address, array $schemes): bool
177
+ {
178
+ $address = (string) preg_replace('/[\x00-\x20\x7F]+/', '', $address);
179
+ if (! preg_match('/^([a-z][a-z0-9+.\-]*):/i', $address, $m)) {
180
+ return true; // no scheme: /about, #top, //example.com
181
+ }
182
+
183
+ return in_array(strtolower($m[1]), $schemes, true);
184
+ }
185
+ }
@@ -0,0 +1,57 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Support;
4
+
5
+ use Illuminate\Database\Eloquent\Concerns\HasUlids;
6
+ use Illuminate\Database\Eloquent\Concerns\HasUuids;
7
+ use Illuminate\Database\Eloquent\Model;
8
+ use Illuminate\Support\Str;
9
+
10
+ /**
11
+ * Whether a string from an address could be a record's id at all.
12
+ *
13
+ * SQLite and MySQL answer "where id = 'nobody'" with no rows. Postgres checks the type
14
+ * first: an id column is a number or a UUID, 'nobody' is neither, and the query is an
15
+ * error, which reaches the visitor as a 500 where a 404 was meant. Inside a transaction
16
+ * it also aborts everything else in it. So an id is looked at before the database is
17
+ * asked, and one of the wrong shape is nobody's.
18
+ */
19
+ final class Key
20
+ {
21
+ /** A whole number that fits a bigint column. */
22
+ public static function isInt(mixed $id): bool
23
+ {
24
+ return is_scalar($id) && preg_match('/^[0-9]{1,18}$/', (string) $id) === 1;
25
+ }
26
+
27
+ public static function isUuid(mixed $id): bool
28
+ {
29
+ return is_string($id) && Str::isUuid($id);
30
+ }
31
+
32
+ /**
33
+ * Whether `$id` has the shape of this model's key: digits for a numbered key, and
34
+ * whatever a model with Laravel's UUID or ULID keys says is one of its own. A model
35
+ * with some other key of its own making is taken at its word.
36
+ *
37
+ * @param Model|class-string<Model> $model
38
+ */
39
+ public static function fits(Model|string $model, mixed $id): bool
40
+ {
41
+ $model = is_string($model) ? new $model : $model;
42
+ if (! is_scalar($id)) {
43
+ return false;
44
+ }
45
+ if (in_array($model->getKeyType(), ['int', 'integer'], true)) {
46
+ return self::isInt($id);
47
+ }
48
+
49
+ $traits = class_uses_recursive($model);
50
+
51
+ return match (true) {
52
+ isset($traits[HasUuids::class]) => Str::isUuid((string) $id),
53
+ isset($traits[HasUlids::class]) => Str::isUlid((string) $id),
54
+ default => true,
55
+ };
56
+ }
57
+ }
@@ -25,7 +25,17 @@ final class ListQuery
25
25
 
26
26
  public ?string $q = null;
27
27
 
28
- /** @var list<array{field: string, column: string, value: string|int|float|bool|null}> */
28
+ /** The three things a record with a publish date can be. */
29
+ public const PUBLISHED = ['published', 'draft', 'scheduled'];
30
+
31
+ /** The filter that isn't a field: whether a record's address has been proved ("confirm"). */
32
+ public const CONFIRMED = 'confirmed';
33
+
34
+ /**
35
+ * A filter on the field a resource is published by carries `state` in place of a value.
36
+ *
37
+ * @var list<array{field: string, column: string, value: string|int|float|bool|null, state?: string, confirmed?: bool}>
38
+ */
29
39
  public array $filters = [];
30
40
 
31
41
  /** @param array<string, mixed> $params */
@@ -66,6 +76,24 @@ final class ListQuery
66
76
  foreach ($filters as $key => $value) {
67
77
  $param = "filter[{$key}]";
68
78
  $field = $resource->fields[$key] ?? null;
79
+ // filter[publishedAt]=draft: not a date to match, but which of the three it is.
80
+ if ($field !== null && $resource->publish === $key) {
81
+ $state = is_string($value) ? strtolower($value) : '';
82
+ in_array($state, self::PUBLISHED, true)
83
+ ? $query->filters[] = ['field' => $key, 'column' => $field->column(), 'value' => null, 'state' => $state]
84
+ : $query->issue($param, 'Must be one of: '.implode(', ', self::PUBLISHED).'.');
85
+
86
+ continue;
87
+ }
88
+ // filter[confirmed]=true: whether the address was proved, for a resource whose form asks.
89
+ if ($field === null && $key === self::CONFIRMED && $resource->public?->confirm !== null) {
90
+ $proved = is_array($value) ? null : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
91
+ $proved === null
92
+ ? $query->issue($param, 'Must be true or false.')
93
+ : $query->filters[] = ['field' => $key, 'column' => 'confirmed_at', 'value' => null, 'confirmed' => $proved];
94
+
95
+ continue;
96
+ }
69
97
  if ($field === null || ! $field->filterable()) {
70
98
  $query->issue($param, "Can't filter by \"{$key}\".");
71
99