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
package/index.mjs CHANGED
@@ -75,13 +75,16 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
75
75
  nevela dev Run the API and the dashboard
76
76
  nevela status Check versions, migrations, users and the dashboard
77
77
  nevela resource <Name> --fields="…" [--seed]
78
- nevela seed | user | generate | migrate | trash | artisan <command>
78
+ nevela adopt <table> Make a resource of a table you already have
79
+ nevela blog Give the site a blog, if it was created without one
80
+ nevela seed | user | generate | migrate | trash | disposable | artisan <command>
79
81
 
80
82
  ${bold('Options')}
81
83
  --pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
82
84
  --no-install Don't install the dashboard's dependencies.
83
85
  --no-user Don't create the starter admin account, or the sample users.
84
86
  --no-sample-users Create the admin only, without the ten sample users.
87
+ --no-blog Leave out the blog: the Post resource, three sample posts and /blog.
85
88
  --fast Leave out PHPUnit, Pint and Laravel's other development packages.
86
89
  About a third quicker. Add them later: cd apps/api && composer install
87
90
  --no-git Don't run git init.
@@ -193,7 +196,7 @@ async function step(label, work) {
193
196
  }
194
197
 
195
198
  function parseArgs(argv) {
196
- const options = { name: undefined, pm: undefined, install: true, user: true, samples: true, git: true, yes: false, bundledPackage: false, devTools: true };
199
+ const options = { name: undefined, pm: undefined, install: true, user: true, samples: true, blog: true, git: true, yes: false, bundledPackage: false, devTools: true };
197
200
  for (let i = 0; i < argv.length; i++) {
198
201
  const arg = argv[i];
199
202
  if (arg === '-h' || arg === '--help') {
@@ -207,6 +210,7 @@ function parseArgs(argv) {
207
210
  else if (arg === '--no-install') options.install = false;
208
211
  else if (arg === '--no-user') options.user = false;
209
212
  else if (arg === '--no-sample-users') options.samples = false;
213
+ else if (arg === '--no-blog') options.blog = false;
210
214
  else if (arg === '--no-git') options.git = false;
211
215
  else if (arg === '--no-dev-tools' || arg === '--fast') options.devTools = false;
212
216
  else if (arg === '--bundled-package') options.bundledPackage = true;
@@ -285,7 +289,7 @@ function write(file, contents) {
285
289
  fs.writeFileSync(file, contents);
286
290
  }
287
291
 
288
- function writeRootFiles(root, { name, title, pm, admin, samples }) {
292
+ function writeRootFiles(root, { name, title, pm, admin, samples, blog }) {
289
293
  write(path.join(root, 'package.json'), `${JSON.stringify({
290
294
  name,
291
295
  version: '0.1.0',
@@ -337,7 +341,11 @@ php nevela upgrade # bring this app to the latest Nevela
337
341
  php nevela # everything it can do
338
342
  \`\`\`
339
343
 
340
- ## Add a resource
344
+ ${blog ? `## The blog
345
+
346
+ The site has a blog at http://localhost:3000/blog. Posts are written under **Posts** in the dashboard, and a post is on the site once it is published. Its pages are in \`apps/web/app/blog\` and its settings in \`apps/web/lib/blog.ts\`: both are yours to change.
347
+
348
+ ` : ''}## Add a resource
341
349
 
342
350
  \`\`\`sh
343
351
  php nevela resource Product --fields="name:string, sku:string!, price:money, notes:text?"
@@ -681,7 +689,7 @@ async function main() {
681
689
  // `nevela upgrade` compares against these to tell the files you changed from the ones
682
690
  // you didn't, so an update never overwrites your work.
683
691
  write(path.join(web, '.nevela.json'), `${JSON.stringify(templateRecord(VERSION), null, 4)}\n`);
684
- writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null, samples: options.user && options.samples });
692
+ writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null, samples: options.user && options.samples, blog: options.blog });
685
693
  const packages = options.install ? start(pm, ['install'], { cwd: web }) : null;
686
694
 
687
695
  const tokens = await step('Creating the Laravel app', async (progress) => {
@@ -738,15 +746,20 @@ async function main() {
738
746
 
739
747
  let admin = false;
740
748
  let samples = false;
749
+ let blog = false;
750
+ let blogMissing = false;
741
751
  await step('Setting up the database', () => {
742
752
  // One artisan process for the app key, Sanctum's migration, the generated files, the
743
753
  // migrations, the starter account and the sample users: each separate call would
744
754
  // start Laravel again.
745
755
  const account = options.user && tokens ? [`--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`, ...(options.samples ? [] : ['--no-sample-users'])] : [];
746
- const setup = run('php', ['artisan', 'nevela:setup', ...account], { cwd: api, allowFailure: true });
756
+ const setup = run('php', ['artisan', 'nevela:setup', ...account, ...(options.blog ? [] : ['--no-blog'])], { cwd: api, allowFailure: true });
747
757
  if (setup.ok) {
748
758
  admin = account.length > 0;
749
759
  samples = admin && options.samples;
760
+ blog = options.blog && fs.existsSync(path.join(web, 'app', 'blog', 'page.tsx'));
761
+ // Asked for, and not there: the setup carried on without it, and the person is told.
762
+ blogMissing = options.blog && !blog;
750
763
  return;
751
764
  }
752
765
  // 2: the app is set up and only the starter account could not be created.
@@ -797,7 +810,12 @@ async function main() {
797
810
  console.log(` ${dim(`Ten sample users are there too, with the same password: 2 editors and 8 users. See them under Users, and what each may do under Roles.`)}`);
798
811
  }
799
812
  }
800
- console.log(`\n Add your first resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
813
+ if (blog) {
814
+ console.log(`\n The site has a blog at ${indigo('http://localhost:3000/blog')}, with three posts to start. Write more under Posts in the dashboard.`);
815
+ } else if (blogMissing) {
816
+ console.log(`\n The blog couldn't be set up, so this app has none yet. Everything else is in place. Add it later: ${dim(`${nevela} blog`)}`);
817
+ }
818
+ console.log(`\n Add ${blog ? 'a' : 'your first'} resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
801
819
  console.log(` Check the app: ${dim(`${nevela} status`)} Upgrade it later: ${dim(`${nevela} upgrade`)} Everything: ${dim(nevela)}`);
802
820
  if (nevela !== 'php nevela' && nevela !== 'nevela') {
803
821
  console.log(`\n ${dim(`In this shell "php" isn't a command (your PHP is php.bat), so use ${nevela}. In PowerShell, php nevela works too.`)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.7.1",
3
+ "version": "0.9.1",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
package/project.mjs CHANGED
@@ -11,7 +11,7 @@ import { isNewer, satisfiesCaret } from './packagist.mjs';
11
11
  import { spawnCommand } from './shell.mjs';
12
12
 
13
13
  /** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
14
- const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user', 'trash'];
14
+ const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'adopt', 'blog', 'generate', 'seed', 'user', 'trash', 'digest', 'disposable'];
15
15
  /** These go to the artisan command of the same name. */
16
16
  const ARTISAN = ['migrate', 'tinker', 'test', 'serve'];
17
17
 
@@ -131,6 +131,60 @@ return [
131
131
  'days' => 30,
132
132
  ],
133
133
 
134
+ /*
135
+ | Forms anyone can send: a resource's "public" create, with "protect" or "confirm".
136
+ |
137
+ | turnstile Cloudflare's robot check. The secret key is this app's alone; the site key
138
+ | goes in the web app (NEXT_PUBLIC_TURNSTILE_SITE_KEY). With no secret the
139
+ | check is skipped in development, and in production a form that asks for
140
+ | it answers 503: it is never quietly left open. A check counts only if it
141
+ | was solved on one of `hostnames` (a name stands for its subdomains), and
142
+ | never on localhost in production.
143
+ | form_lifetime How long a form can sit open before it must be loaded again, in seconds.
144
+ | mail The most one address is emailed because someone typed it into a form
145
+ | (PublicMail::toVisitor), across every form. Past it the email is dropped
146
+ | and the record is still saved. This is what stops a form being used to
147
+ | mail strangers.
148
+ | notify Who is told a form was sent (PublicMail::toOwner), and how often: past
149
+ | max_per_hour the rest wait for one email at the top of the hour.
150
+ | confirm How long an address has to follow its link ("confirm"), after which the
151
+ | record is removed, and the page of the web app the link opens.
152
+ */
153
+ 'public' => [
154
+ 'turnstile' => [
155
+ 'secret' => env('TURNSTILE_SECRET_KEY'),
156
+ /* The sites a check may be solved on, with commas and no spaces between: example.com,shop.example.com. The web app's own (NEVELA_WEB_URL), unless set. */
157
+ 'hostnames' => env('TURNSTILE_HOSTNAMES'),
158
+ 'verify_url' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
159
+ ],
160
+ 'form_lifetime' => 2 * 60 * 60,
161
+ 'mail' => [
162
+ 'visitor_per_hour' => 1,
163
+ 'visitor_per_day' => 3,
164
+ ],
165
+ 'notify' => [
166
+ 'to' => env('NEVELA_NOTIFY_TO'),
167
+ 'max_per_hour' => 10,
168
+ ],
169
+ 'confirm' => [
170
+ 'hours' => 48,
171
+ 'path' => '/confirm',
172
+ ],
173
+ ],
174
+
175
+ /*
176
+ | Rich text fields (body:richtext). Whatever is sent is parsed and written out again
177
+ | with only these tags and attributes: nothing the editor, a script or a visitor sends
178
+ | is trusted. A tag that isn't listed goes and its words stay. The list matches the
179
+ | dashboard editor's toolbar; add to it only what you would let a stranger put on a page.
180
+ | A link may use these schemes, or none (/about).
181
+ */
182
+ 'rich_text' => [
183
+ 'tags' => ['p', 'br', 'strong', 'em', 's', 'a', 'h2', 'h3', 'ul', 'ol', 'li', 'blockquote'],
184
+ 'attributes' => ['a' => ['href']],
185
+ 'schemes' => ['http', 'https', 'mailto'],
186
+ ],
187
+
134
188
  /*
135
189
  | The most rows POST /{slug}/_bulk creates in one request (the dashboard's "Add several"
136
190
  | grid). They are saved in one transaction, so this is also how long one can run.
@@ -142,14 +196,23 @@ return [
142
196
  |
143
197
  | Files go on a disk from config/filesystems.php and are handed out by Nevela's own
144
198
  | route, GET {prefix}/_nevela/files/{key}, so nothing needs linking or publishing. Keys
145
- | are unguessable, but anyone who has one can fetch the file: don't use these fields
146
- | for documents that must stay private. If a CDN or your web server serves the disk,
147
- | put its address in NEVELA_UPLOADS_URL and the API's links point there.
199
+ | are unguessable, but anyone who has one can fetch the file. If a CDN or your web
200
+ | server serves the disk, put its address in NEVELA_UPLOADS_URL and the API's links
201
+ | point there.
202
+ |
203
+ | A document that must stay private goes in a private file field ("private": true in
204
+ | the descriptor, or cv:file(pdf|private)). It is kept on private_disk, which must not
205
+ | be one the web server serves, and is handed out by
206
+ | GET {prefix}/_nevela/private/{slug}/{id}/{field} to someone signed in who may view
207
+ | that record. Its key opens nothing.
148
208
  */
149
209
  'uploads' => [
150
210
  'disk' => env('NEVELA_UPLOADS_DISK', 'public'),
151
211
  'url' => env('NEVELA_UPLOADS_URL'),
152
212
 
213
+ /* Where private files are kept. Never served: not "public", and not a bucket anyone can read. */
214
+ 'private_disk' => env('NEVELA_PRIVATE_DISK', 'local'),
215
+
153
216
  /* Where untouched originals are kept, so an image can be optimised again later. Never served. */
154
217
  'originals_disk' => env('NEVELA_ORIGINALS_DISK', 'local'),
155
218
 
@@ -0,0 +1,60 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Database\Schema\Blueprint;
5
+ use Illuminate\Support\Facades\Schema;
6
+
7
+ // Public forms: what was refused, and the mail a form causes.
8
+ //
9
+ // A form anyone can send is sent by bots too. What is turned away is counted, so that
10
+ // protection which works can be told from a form nobody uses. And because every send can
11
+ // cause mail, to the address that was typed and to the owner, each of those is counted
12
+ // too: that is what keeps the form from being a way to mail strangers, and the owner's
13
+ // inbox from being buried.
14
+ return new class extends Migration
15
+ {
16
+ public function up(): void
17
+ {
18
+ if (! Schema::hasTable('nevela_form_blocks')) {
19
+ Schema::create('nevela_form_blocks', function (Blueprint $table) {
20
+ $table->uuid('id')->primary();
21
+ $table->string('resource');
22
+ // honeypot, forged, too_fast, expired, robot, disposable, undeliverable, throttled, unconfigured, unreachable
23
+ $table->string('reason', 32);
24
+ $table->date('day');
25
+ $table->unsignedInteger('count')->default(0);
26
+ $table->unique(['resource', 'reason', 'day']);
27
+ });
28
+ }
29
+
30
+ if (! Schema::hasTable('nevela_public_mail')) {
31
+ Schema::create('nevela_public_mail', function (Blueprint $table) {
32
+ $table->uuid('id')->primary();
33
+ // "visitor": to an address a form was given. "owner": a notice that a form was sent.
34
+ $table->string('kind', 16);
35
+ // A visitor's address is kept as a fingerprint: enough to count by, not to read back.
36
+ $table->string('recipient', 64);
37
+ $table->timestamp('sent_at')->index();
38
+ $table->index(['kind', 'recipient', 'sent_at']);
39
+ });
40
+ }
41
+
42
+ if (! Schema::hasTable('nevela_owner_digest')) {
43
+ // Notices to the owner past the hour's limit, held for one email that names them all.
44
+ Schema::create('nevela_owner_digest', function (Blueprint $table) {
45
+ $table->uuid('id')->primary();
46
+ $table->string('subject');
47
+ $table->string('line', 500)->nullable();
48
+ $table->string('url', 2048)->nullable();
49
+ $table->timestamp('held_at')->index();
50
+ });
51
+ }
52
+ }
53
+
54
+ public function down(): void
55
+ {
56
+ Schema::dropIfExists('nevela_owner_digest');
57
+ Schema::dropIfExists('nevela_public_mail');
58
+ Schema::dropIfExists('nevela_form_blocks');
59
+ }
60
+ };