create-nevela 0.1.5 → 0.3.0

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 (38) hide show
  1. package/README.md +17 -6
  2. package/index.mjs +77 -16
  3. package/package.json +2 -1
  4. package/project.mjs +20 -4
  5. package/selfupdate.mjs +13 -3
  6. package/template/laravel/README.md +1 -1
  7. package/template/laravel/composer.json +61 -59
  8. package/template/laravel/config/nevela.php +70 -0
  9. package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
  10. package/template/laravel/src/Console/GenerateCommand.php +25 -2
  11. package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
  12. package/template/laravel/src/Console/SeedCommand.php +186 -124
  13. package/template/laravel/src/Console/StatusCommand.php +2 -2
  14. package/template/laravel/src/Console/UpdateCommand.php +76 -9
  15. package/template/laravel/src/Generator/ResourceGenerator.php +154 -21
  16. package/template/laravel/src/Http/ResourceRequest.php +4 -1
  17. package/template/laravel/src/Http/UploadController.php +147 -0
  18. package/template/laravel/src/Media/FileTypes.php +97 -0
  19. package/template/laravel/src/Media/ImageOptimizer.php +252 -0
  20. package/template/laravel/src/Media/Profile.php +94 -0
  21. package/template/laravel/src/Media/UploadRejected.php +8 -0
  22. package/template/laravel/src/Media/Uploads.php +325 -0
  23. package/template/laravel/src/Models/Upload.php +46 -0
  24. package/template/laravel/src/Nevela.php +20 -1
  25. package/template/laravel/src/NevelaServiceProvider.php +24 -0
  26. package/template/laravel/src/Rules/UploadKey.php +27 -0
  27. package/template/laravel/src/Support/Descriptor.php +3 -3
  28. package/template/laravel/src/Support/Fake.php +194 -171
  29. package/template/laravel/src/Support/Field.php +108 -12
  30. package/template/laravel/src/Support/TemplateSource.php +1 -1
  31. package/template/web/app/api/[...path]/route.ts +87 -0
  32. package/template/web/app/dashboard/actions.ts +26 -9
  33. package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
  34. package/template/web/components/dashboard/fields/file-field.tsx +25 -31
  35. package/template/web/lib/files.ts +21 -0
  36. package/template/web/package.json +1 -1
  37. package/template.mjs +2 -2
  38. package/tool.mjs +58 -0
package/README.md CHANGED
@@ -36,21 +36,32 @@ php nevela resource Product --fields="name:string, sku:string!, price:money"
36
36
 
37
37
  That writes the Laravel code and the dashboard pages, and creates the table. `php nevela` lists everything else.
38
38
 
39
- ## Updating an app later
39
+ ## The nevela command
40
+
41
+ Install this package once and you have a `nevela` command, in every shell:
40
42
 
41
43
  ```sh
42
- php nevela update
44
+ npm install -g create-nevela
43
45
  ```
44
46
 
45
- That updates the Laravel package, regenerates, and updates the dashboard files you have not changed. Files you changed are kept, and everything it replaces is backed up first.
47
+ ```sh
48
+ nevela new my-app # anywhere: create an app
49
+ nevela update # anywhere: update the nevela command itself
50
+ nevela dev # inside an app: run the API and the dashboard
51
+ nevela upgrade # inside an app: bring it to the latest Nevela
52
+ ```
46
53
 
47
- If `php` is not a command in your shell (Git Bash with Laravel Herd), or the app is an old one, run it through this package instead. It works in any shell and on an app of any age:
54
+ As in Grit, `update` is for the tool and `upgrade` is for an app.
55
+
56
+ ## Upgrading an app later
48
57
 
49
58
  ```sh
50
- npx create-nevela update
59
+ nevela upgrade
51
60
  ```
52
61
 
53
- Installed globally (`npm install -g create-nevela`), it gives you a `nevela` command: `nevela update`, `nevela dev`, `nevela status`, `nevela resource …`. See https://nevela-docs.vercel.app/guides/updating/.
62
+ That updates the Laravel package, regenerates, and upgrades the dashboard files you have not changed. Files you changed are kept, and everything it replaces is backed up first.
63
+
64
+ Without the command installed, `npx create-nevela upgrade` does the same, in any shell and on an app of any age. So does the app's own launcher, `php nevela upgrade`. See https://nevela-docs.vercel.app/guides/updating/.
54
65
 
55
66
  ## Options
56
67
 
package/index.mjs CHANGED
@@ -12,10 +12,11 @@ import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import readline from 'node:readline/promises';
14
14
  import { fileURLToPath } from 'node:url';
15
- import { latestRelease, onPackagist } from './packagist.mjs';
16
- import { COMMANDS, findProject, forward, update } from './project.mjs';
15
+ import { isNewer, latestRelease, onPackagist } from './packagist.mjs';
16
+ import { COMMANDS, findProject, forward, upgrade } from './project.mjs';
17
17
  import { shellLine, spawnCommand, windows } from './shell.mjs';
18
- import { newerVersion, runVersion } from './selfupdate.mjs';
18
+ import { latestVersion, newerVersion, runVersion } from './selfupdate.mjs';
19
+ import { globalInstall, INSTALL_COMMAND } from './tool.mjs';
19
20
  import { copyLaravelPackage, copyWebTemplate, fromRepository, templateRecord } from './template.mjs';
20
21
 
21
22
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -41,12 +42,15 @@ const HELP = `
41
42
 
42
43
  Create a new Nevela app: a Laravel API and a Next.js dashboard.
43
44
 
44
- ${bold('Create an app')}
45
- pnpm create nevela <name> [options]
46
- npm create nevela@latest <name> -- [options]
45
+ ${bold('Get the nevela command')} (once; everything below is then this short)
46
+ npm install -g create-nevela
47
47
 
48
- ${bold('Inside an app')} (also as: nevela <command>, once installed with npm install -g create-nevela)
49
- nevela update Update Nevela and the dashboard to the latest version
48
+ ${bold('Anywhere')}
49
+ nevela new <name> [options] Create an app. Without the command: pnpm create nevela <name>
50
+ nevela update Update the nevela command itself to the latest version
51
+
52
+ ${bold('Inside an app')}
53
+ nevela upgrade Bring this app to the latest Nevela: the package and the dashboard
50
54
  nevela dev Run the API and the dashboard
51
55
  nevela status Check versions, migrations, users and the dashboard
52
56
  nevela resource <Name> --fields="…" [--seed]
@@ -301,7 +305,7 @@ Everything else runs from this folder too, with \`php nevela\`:
301
305
  \`\`\`sh
302
306
  php nevela user # add someone who can sign in
303
307
  php nevela status # versions, migrations, users, and what the dashboard is talking to
304
- php nevela update # update Nevela and the dashboard
308
+ php nevela upgrade # bring this app to the latest Nevela
305
309
  php nevela # everything it can do
306
310
  \`\`\`
307
311
 
@@ -328,25 +332,82 @@ async function inProject(name, args) {
328
332
  fail(`"${name}" runs inside a Nevela app, and this folder isn't in one.`, `To create an app: pnpm create nevela my-app`);
329
333
  }
330
334
  if (name === 'version') console.log(`create-nevela ${VERSION}`);
331
- if (name !== 'update') process.exit(forward(project, name, args));
335
+ if (name !== 'upgrade') process.exit(forward(project, name, args));
332
336
 
333
- // Updating is the one command that must not run on stale code itself.
337
+ // Upgrading is the one command that must not run on stale code itself: it is the newest
338
+ // installer that knows what an older app needs repaired. So even a nevela command that
339
+ // hasn't been updated in a while upgrades an app correctly.
334
340
  if (!process.env.NEVELA_NO_SELF_UPDATE && !fromRepository) {
335
341
  const newer = await newerVersion(VERSION);
336
342
  if (newer) {
337
- const status = await runVersion(newer, ['update', ...args]);
343
+ const status = await runVersion(newer, ['upgrade', ...args]);
338
344
  if (status !== null) process.exit(status);
339
345
  }
340
346
  }
341
- console.log(`\n ${indigo(bold('Nevela'))} ${dim('update')}\n`);
347
+ console.log(`\n ${indigo(bold('Nevela'))} ${dim('upgrade')}\n`);
342
348
  const releases = await latestRelease();
343
- const status = await update(project, args, { latest: releases, say: (line) => console.log(` ${green('✔')} ${line}`) });
349
+ const status = await upgrade(project, args, { latest: releases, say: (line) => console.log(` ${green('✔')} ${line}`) });
344
350
  process.exit(status);
345
351
  }
346
352
 
353
+ /**
354
+ * `nevela update`, from anywhere: update the nevela command itself.
355
+ *
356
+ * As in Grit, updating the tool and upgrading an app are two commands. This one doesn't
357
+ * touch any app; it ends by saying how to upgrade the one you are in, if you are in one.
358
+ *
359
+ * It returns instead of calling process.exit(): on Windows, exiting in the same moment a
360
+ * fetch() has just finished trips an assertion inside Node and prints a crash.
361
+ */
362
+ async function updateTool(args) {
363
+ const project = findProject(process.cwd());
364
+ const install = fromRepository ? null : globalInstall(here);
365
+
366
+ if (!install) {
367
+ // Run through npx or `pnpm dlx`, which fetch the latest every time, started by an
368
+ // older nevela command that fetched this one to do its work, or a copy in a project's
369
+ // own node_modules. There is no installed command to update. Inside an app, "update" used to mean "upgrade this app", and
370
+ // instructions saying so are still around, so that is what it does.
371
+ if (project) {
372
+ console.log(`\n ${dim(`"update" now updates the nevela command, and an app is brought up to date with "upgrade". Upgrading this app.`)}`);
373
+ await inProject('upgrade', args);
374
+ }
375
+ console.log(`\n ${indigo(bold('Nevela'))} ${dim(`v${VERSION}`)}\n`);
376
+ console.log(` The nevela command isn't installed on this computer, so there is nothing to update.`);
377
+ console.log(` To install it: ${bold(INSTALL_COMMAND)}`);
378
+ console.log(` Then, anywhere: ${bold('nevela new my-app')} and inside an app: ${bold('nevela upgrade')}, ${bold('nevela dev')}\n`);
379
+ return;
380
+ }
381
+
382
+ console.log(`\n ${indigo(bold('Nevela'))} ${dim('update')}\n`);
383
+ const latest = await latestVersion();
384
+ const newer = latest !== null && isNewer(latest, VERSION) ? latest : null;
385
+ if (latest === null) {
386
+ // Not the same as being up to date, so it isn't reported as that.
387
+ console.log(` ${red('!')} Couldn't reach npm to see whether there is a newer nevela than ${bold(VERSION)}. Nothing was changed.`);
388
+ console.log(` Try again when you are online, or run: ${install.command.join(' ')}`);
389
+ process.exitCode = 1;
390
+ } else if (!newer) {
391
+ console.log(` ${green('✔')} nevela ${bold(VERSION)} is the latest.`);
392
+ } else {
393
+ console.log(` Updating the nevela command ${bold(VERSION)} → ${bold(newer)} ${dim(`(${install.command.join(' ')})`)}\n`);
394
+ const [command, ...rest] = install.command;
395
+ const result = spawnCommand(command, rest, { stdio: 'inherit' });
396
+ if (result.error || result.status !== 0) {
397
+ fail(`Couldn't update the nevela command.`, `Run it yourself to see why: ${install.command.join(' ')}`);
398
+ }
399
+ console.log(`\n ${green('✔')} nevela is now ${bold(newer)}.`);
400
+ }
401
+ if (project) {
402
+ console.log(` ${dim('This app is not changed by that. To bring it to the latest Nevela:')} ${bold('nevela upgrade')}`);
403
+ }
404
+ console.log('');
405
+ }
406
+
347
407
  async function main() {
348
408
  const argv = process.argv.slice(2);
349
409
  if (argv[0] === 'new') argv.shift();
410
+ else if (argv[0] === 'update') return updateTool(argv.slice(1));
350
411
  else if (COMMANDS.includes(argv[0])) await inProject(argv[0], argv.slice(1));
351
412
  const options = parseArgs(argv);
352
413
 
@@ -399,7 +460,7 @@ async function main() {
399
460
  write(path.join(web, 'pnpm-workspace.yaml'), 'allowBuilds:\n sharp: true\n');
400
461
  }
401
462
  // Which dashboard this app started from, with a fingerprint of every file in it.
402
- // `nevela update` compares against these to tell the files you changed from the ones
463
+ // `nevela upgrade` compares against these to tell the files you changed from the ones
403
464
  // you didn't, so an update never overwrites your work.
404
465
  write(path.join(web, '.nevela.json'), `${JSON.stringify(templateRecord(VERSION), null, 4)}\n`);
405
466
  writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null });
@@ -510,7 +571,7 @@ async function main() {
510
571
  console.log(` ${dim(`That account is in this app's local database only. Add your own: ${nevela} user`)}`);
511
572
  }
512
573
  console.log(`\n Add your first resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
513
- console.log(` Check the app: ${dim(`${nevela} status`)} Update it later: ${dim(`${nevela} update`)} Everything: ${dim(nevela)}`);
574
+ console.log(` Check the app: ${dim(`${nevela} status`)} Upgrade it later: ${dim(`${nevela} upgrade`)} Everything: ${dim(nevela)}`);
514
575
  if (nevela !== 'php nevela') {
515
576
  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.`)}`);
516
577
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
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": {
@@ -14,6 +14,7 @@
14
14
  "selfupdate.mjs",
15
15
  "shell.mjs",
16
16
  "project.mjs",
17
+ "tool.mjs",
17
18
  "template",
18
19
  "LICENSE"
19
20
  ],
package/project.mjs CHANGED
@@ -2,15 +2,16 @@
2
2
  //
3
3
  // The same commands as the app's own `php nevela`, but started from Node. That matters in
4
4
  // shells where typing `php` doesn't work (Git Bash with Laravel Herd, where PHP is
5
- // php.bat), and for `update`, which has to work on an app of any age, including one from
5
+ // php.bat), and for `upgrade`, which has to work on an app of any age, including one from
6
6
  // before the app had a launcher at all.
7
7
 
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
+ import { isNewer, satisfiesCaret } from './packagist.mjs';
10
11
  import { spawnCommand } from './shell.mjs';
11
12
 
12
13
  /** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
13
- const NEVELA = ['update', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user'];
14
+ const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'generate', 'seed', 'user'];
14
15
  /** These go to the artisan command of the same name. */
15
16
  const ARTISAN = ['migrate', 'tinker', 'test', 'serve'];
16
17
 
@@ -64,6 +65,16 @@ export function repairManifest(manifest, latest) {
64
65
  if (typeof current === 'string' && /^\d+\.\d+(\.\d+)?$/.test(current)) {
65
66
  manifest.require['nevela/laravel'] = caret ?? `^${current.split('.').slice(0, 2).join('.')}`;
66
67
  repairs.push(`"nevela/laravel": "${current}" pinned one exact version; it is now "${manifest.require['nevela/laravel']}"`);
68
+ return repairs;
69
+ }
70
+
71
+ // Below 1.0 a caret holds the minor version: "^0.1" never reaches 0.2.0, so an update
72
+ // would finish having changed nothing. Updating is asking for the newest, so the range
73
+ // is moved to the one that has it.
74
+ const range = typeof current === 'string' ? /^\^(\d+)\.(\d+)(?:\.\d+)?$/.exec(current) : null;
75
+ if (range && caret && caret !== current && !satisfiesCaret(latest, [Number(range[1]), Number(range[2])]) && isNewer(latest, `${range[1]}.${range[2]}.0`)) {
76
+ manifest.require['nevela/laravel'] = caret;
77
+ repairs.push(`"nevela/laravel": "${current}" doesn't reach ${latest}; it is now "${caret}"`);
67
78
  }
68
79
  return repairs;
69
80
  }
@@ -97,8 +108,13 @@ function inApp(project, command, args) {
97
108
  return result.status ?? 1;
98
109
  }
99
110
 
100
- /** `nevela update`: bring an app of any age to the latest Nevela. */
101
- export async function update(project, args, { latest, say }) {
111
+ /**
112
+ * `nevela upgrade`: bring an app of any age to the latest Nevela.
113
+ *
114
+ * The artisan command is called by its older name, nevela:update, which every version of
115
+ * the package answers to (from 0.3.0 it is an alias of nevela:upgrade).
116
+ */
117
+ export async function upgrade(project, args, { latest, say }) {
102
118
  const check = args.includes('--check');
103
119
  const manifestFile = path.join(project.api, 'composer.json');
104
120
  const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
package/selfupdate.mjs CHANGED
@@ -14,17 +14,27 @@ import { isNewer } from './packagist.mjs';
14
14
 
15
15
  const REGISTRY = 'https://registry.npmjs.org/create-nevela';
16
16
 
17
- /** The newest create-nevela on npm when it is newer than `current`, else null. Never throws. */
18
- export async function newerVersion(current) {
17
+ /** The newest create-nevela on npm, or null when npm couldn't be asked. Never throws. */
18
+ export async function latestVersion() {
19
19
  try {
20
20
  const response = await fetch(`${REGISTRY}/latest`, { signal: AbortSignal.timeout(4000) });
21
21
  const latest = (await response.json()).version;
22
- return isNewer(latest, current) ? latest : null;
22
+ return typeof latest === 'string' && /^\d+\.\d+\.\d+$/.test(latest) ? latest : null;
23
23
  } catch {
24
24
  return null;
25
25
  }
26
26
  }
27
27
 
28
+ /**
29
+ * The newest create-nevela on npm when it is newer than `current`, else null. Never throws.
30
+ * Null also when npm couldn't be asked: callers that only want to run the newest carry on
31
+ * with what they have.
32
+ */
33
+ export async function newerVersion(current) {
34
+ const latest = await latestVersion();
35
+ return latest !== null && isNewer(latest, current) ? latest : null;
36
+ }
37
+
28
38
  /**
29
39
  * Unpack a .tar (already gunzipped) into `dir`. Enough of the format for an npm tarball:
30
40
  * regular files and directories, with the long-name extensions npm uses.
@@ -43,7 +43,7 @@ Needs PHP 8.2 or newer and Laravel 11, 12 or 13.
43
43
  | `nevela:generate` | Regenerate after changing a descriptor. Your own code is kept. |
44
44
  | `nevela:seed` | Fill a resource with plausible records, and print how long it took. |
45
45
  | `nevela:user` | Create someone who can sign in. |
46
- | `nevela:update` | Update the package, the generated code and the dashboard. |
46
+ | `nevela:upgrade` | Bring the app to the latest Nevela: the package, the generated code and the dashboard. `nevela:update` is its older name and still works. |
47
47
 
48
48
  Full documentation: https://nevela-docs.vercel.app
49
49
 
@@ -1,59 +1,61 @@
1
- {
2
- "name": "nevela/laravel",
3
- "description": "Nevela for Laravel: describe a resource once and generate its model, migration, validation, policy and REST API, plus the Next.js dashboard screens for it.",
4
- "type": "library",
5
- "license": "MIT",
6
- "keywords": [
7
- "laravel",
8
- "nevela",
9
- "generator",
10
- "crud",
11
- "rest-api",
12
- "nextjs",
13
- "dashboard",
14
- "scaffold"
15
- ],
16
- "homepage": "https://nevela-docs.vercel.app",
17
- "authors": [
18
- {
19
- "name": "Mark Cole MUKISA",
20
- "homepage": "https://github.com/MarkColeMukisa"
21
- }
22
- ],
23
- "support": {
24
- "issues": "https://github.com/MarkColeMukisa/nevela/issues",
25
- "source": "https://github.com/MarkColeMukisa/nevela",
26
- "docs": "https://nevela-docs.vercel.app"
27
- },
28
- "require": {
29
- "php": "^8.2",
30
- "illuminate/support": "^11.0|^12.0|^13.0",
31
- "illuminate/database": "^11.17|^12.0|^13.0",
32
- "illuminate/http": "^11.0|^12.0|^13.0",
33
- "illuminate/console": "^11.0|^12.0|^13.0"
34
- },
35
- "require-dev": {
36
- "phpunit/phpunit": "^11.0"
37
- },
38
- "suggest": {
39
- "laravel/sanctum": "Required for the token sign-in endpoints and the default auth:sanctum middleware (php artisan install:api)."
40
- },
41
- "autoload": {
42
- "psr-4": {
43
- "Nevela\\Laravel\\": "src/"
44
- }
45
- },
46
- "autoload-dev": {
47
- "psr-4": {
48
- "Nevela\\Laravel\\Tests\\": "tests/"
49
- }
50
- },
51
- "extra": {
52
- "laravel": {
53
- "providers": [
54
- "Nevela\\Laravel\\NevelaServiceProvider"
55
- ]
56
- }
57
- },
58
- "minimum-stability": "stable"
59
- }
1
+ {
2
+ "name": "nevela/laravel",
3
+ "description": "Nevela for Laravel: describe a resource once and generate its model, migration, validation, policy and REST API, plus the Next.js dashboard screens for it.",
4
+ "type": "library",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "laravel",
8
+ "nevela",
9
+ "generator",
10
+ "crud",
11
+ "rest-api",
12
+ "nextjs",
13
+ "dashboard",
14
+ "scaffold"
15
+ ],
16
+ "homepage": "https://nevela-docs.vercel.app",
17
+ "authors": [
18
+ {
19
+ "name": "Mark Cole MUKISA",
20
+ "homepage": "https://github.com/MarkColeMukisa"
21
+ }
22
+ ],
23
+ "support": {
24
+ "issues": "https://github.com/MarkColeMukisa/nevela/issues",
25
+ "source": "https://github.com/MarkColeMukisa/nevela",
26
+ "docs": "https://nevela-docs.vercel.app"
27
+ },
28
+ "require": {
29
+ "php": "^8.2",
30
+ "illuminate/support": "^11.0|^12.0|^13.0",
31
+ "illuminate/database": "^11.17|^12.0|^13.0",
32
+ "illuminate/http": "^11.0|^12.0|^13.0",
33
+ "illuminate/console": "^11.0|^12.0|^13.0"
34
+ },
35
+ "require-dev": {
36
+ "phpunit/phpunit": "^11.0"
37
+ },
38
+ "suggest": {
39
+ "laravel/sanctum": "Required for the token sign-in endpoints and the default auth:sanctum middleware (php artisan install:api).",
40
+ "ext-gd": "Optimises uploaded images: resizing, WebP and renditions. Without it images are stored as uploaded.",
41
+ "ext-exif": "Turns phone photos the right way up before their metadata is removed."
42
+ },
43
+ "autoload": {
44
+ "psr-4": {
45
+ "Nevela\\Laravel\\": "src/"
46
+ }
47
+ },
48
+ "autoload-dev": {
49
+ "psr-4": {
50
+ "Nevela\\Laravel\\Tests\\": "tests/"
51
+ }
52
+ },
53
+ "extra": {
54
+ "laravel": {
55
+ "providers": [
56
+ "Nevela\\Laravel\\NevelaServiceProvider"
57
+ ]
58
+ }
59
+ },
60
+ "minimum-stability": "stable"
61
+ }
@@ -33,4 +33,74 @@ return [
33
33
  /* List defaults, matching Flare's client. */
34
34
  'per_page' => 25,
35
35
  'max_per_page' => 100,
36
+
37
+ /*
38
+ | File and image fields (image:image, manual:file(pdf)).
39
+ |
40
+ | Files go on a disk from config/filesystems.php and are handed out by Nevela's own
41
+ | route, GET {prefix}/_nevela/files/{key}, so nothing needs linking or publishing. Keys
42
+ | are unguessable, but anyone who has one can fetch the file: don't use these fields
43
+ | for documents that must stay private. If a CDN or your web server serves the disk,
44
+ | put its address in NEVELA_UPLOADS_URL and the API's links point there.
45
+ */
46
+ 'uploads' => [
47
+ 'disk' => env('NEVELA_UPLOADS_DISK', 'public'),
48
+ 'url' => env('NEVELA_UPLOADS_URL'),
49
+
50
+ /* Where untouched originals are kept, so an image can be optimised again later. Never served. */
51
+ 'originals_disk' => env('NEVELA_ORIGINALS_DISK', 'local'),
52
+
53
+ /* The largest file a field takes. */
54
+ 'max_bytes' => 10 * 1024 * 1024,
55
+
56
+ /* Memory allowed while an image is being optimised: a phone photo needs more than PHP's usual 128M. */
57
+ 'memory' => '512M',
58
+
59
+ /*
60
+ | How images are optimised. Every image field uses "default" unless it names another:
61
+ | image:image(product). A profile only says what differs from the default.
62
+ |
63
+ | max [width, height] the stored image fits inside. Add 'crop' to fill it exactly.
64
+ | quality 1 to 100.
65
+ | format auto, webp, jpeg, png or avif. Auto keeps transparency and otherwise
66
+ | picks the smallest.
67
+ | renditions extra sizes made alongside, by name. Ask for one by putting its name
68
+ | before the extension: kettle.webp → kettle.thumb.webp
69
+ | keep_original keep the untouched upload on the originals disk.
70
+ | on_error store_original (keep the upload as it came) or reject.
71
+ | max_pixels refuse anything that would decode to more than this.
72
+ */
73
+ 'profiles' => [
74
+ 'default' => [
75
+ 'max' => [1600, 1600],
76
+ 'quality' => 82,
77
+ 'format' => 'auto',
78
+ 'renditions' => ['thumb' => [400, 400, 'crop']],
79
+ 'keep_original' => true,
80
+ 'on_error' => 'store_original',
81
+ 'max_pixels' => 50_000_000,
82
+ ],
83
+
84
+ // A product photograph: smaller than the default, because a catalogue page shows a dozen at once.
85
+ 'product' => [
86
+ 'max' => [1000, 1000],
87
+ 'quality' => 80,
88
+ 'renditions' => ['thumb' => [300, 300, 'crop'], 'card' => [600, 600]],
89
+ ],
90
+
91
+ // Always square and always small, so it crops: a portrait in a round frame shouldn't letterbox.
92
+ 'avatar' => [
93
+ 'max' => [400, 400, 'crop'],
94
+ 'quality' => 85,
95
+ 'renditions' => ['thumb' => [80, 80, 'crop']],
96
+ 'keep_original' => false,
97
+ ],
98
+
99
+ // A hero or cover image, where width matters more than total pixels.
100
+ 'cover' => [
101
+ 'max' => [2400, 1200],
102
+ 'renditions' => ['thumb' => [600, 300, 'crop']],
103
+ ],
104
+ ],
105
+ ],
36
106
  ];
@@ -0,0 +1,38 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Database\Schema\Blueprint;
5
+ use Illuminate\Support\Facades\Schema;
6
+
7
+ // What Nevela knows about each uploaded file: where it is, how big it is, and for an
8
+ // image, its dimensions and the smaller renditions made from it.
9
+ return new class extends Migration
10
+ {
11
+ public function up(): void
12
+ {
13
+ Schema::create('nevela_uploads', function (Blueprint $table) {
14
+ $table->uuid('id')->primary();
15
+ $table->string('key', 512)->unique();
16
+ $table->string('disk');
17
+ $table->string('resource')->index();
18
+ $table->string('field');
19
+ $table->string('name');
20
+ $table->string('mime');
21
+ $table->unsignedBigInteger('size');
22
+ $table->unsignedInteger('width')->nullable();
23
+ $table->unsignedInteger('height')->nullable();
24
+ $table->json('renditions')->nullable();
25
+ $table->string('profile')->nullable();
26
+ $table->boolean('optimised')->default(false);
27
+ $table->string('original_key', 512)->nullable();
28
+ $table->unsignedBigInteger('original_size')->nullable();
29
+ $table->string('uploaded_by')->nullable();
30
+ $table->timestamps();
31
+ });
32
+ }
33
+
34
+ public function down(): void
35
+ {
36
+ Schema::dropIfExists('nevela_uploads');
37
+ }
38
+ };
@@ -61,9 +61,32 @@ final class GenerateCommand extends Command
61
61
  public static function generate(Command $command, array $targets, array $all, bool $force): int
62
62
  {
63
63
  $generator = new ResourceGenerator;
64
- $files = [];
64
+ $byName = [];
65
+ foreach ($all as $descriptor) {
66
+ $byName[$descriptor->name] = $descriptor;
67
+ }
68
+
69
+ // A relation is written on both ends, so the resource a new one points at is
70
+ // regenerated with it: Category gains its products when Product arrives.
71
+ $queue = [];
65
72
  foreach ($targets as $descriptor) {
66
- array_push($files, ...$generator->forResource($descriptor));
73
+ $queue[$descriptor->name] = $descriptor;
74
+ foreach ($descriptor->fields as $field) {
75
+ if ($field->kind !== 'belongsTo') {
76
+ continue;
77
+ }
78
+ if (! isset($byName[$field->target])) {
79
+ $command->components->error("{$descriptor->name}.{$field->name} belongs to {$field->target}, which doesn't exist yet. Create it first: php artisan nevela:resource {$field->target} --fields=\"name:string\"");
80
+
81
+ return self::FAILURE;
82
+ }
83
+ $queue[$field->target] ??= $byName[$field->target];
84
+ }
85
+ }
86
+
87
+ $files = [];
88
+ foreach ($queue as $descriptor) {
89
+ array_push($files, ...$generator->forResource($descriptor, null, $all));
67
90
  }
68
91
  $files[] = $generator->routes($all);
69
92
  $files[] = $generator->registry($all);
@@ -43,6 +43,14 @@ final class MakeResourceCommand extends Command
43
43
 
44
44
  return self::FAILURE;
45
45
  }
46
+ // Checked before anything is written: a relation needs the resource it points at.
47
+ foreach ($descriptor->fields as $field) {
48
+ if ($field->kind === 'belongsTo' && $field->target !== $descriptor->name && ! is_file($dir.DIRECTORY_SEPARATOR.Naming::kebab((string) $field->target).'.json')) {
49
+ $this->components->error("{$field->name} belongs to {$field->target}, which doesn't exist yet. Create it first: php artisan nevela:resource {$field->target} --fields=\"name:string\"");
50
+
51
+ return self::FAILURE;
52
+ }
53
+ }
46
54
  if (! is_dir($dir)) {
47
55
  mkdir($dir, 0775, true);
48
56
  }