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.
- package/README.md +17 -6
- package/index.mjs +77 -16
- package/package.json +2 -1
- package/project.mjs +20 -4
- package/selfupdate.mjs +13 -3
- package/template/laravel/README.md +1 -1
- package/template/laravel/composer.json +61 -59
- package/template/laravel/config/nevela.php +70 -0
- package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
- package/template/laravel/src/Console/GenerateCommand.php +25 -2
- package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
- package/template/laravel/src/Console/SeedCommand.php +186 -124
- package/template/laravel/src/Console/StatusCommand.php +2 -2
- package/template/laravel/src/Console/UpdateCommand.php +76 -9
- package/template/laravel/src/Generator/ResourceGenerator.php +154 -21
- package/template/laravel/src/Http/ResourceRequest.php +4 -1
- package/template/laravel/src/Http/UploadController.php +147 -0
- package/template/laravel/src/Media/FileTypes.php +97 -0
- package/template/laravel/src/Media/ImageOptimizer.php +252 -0
- package/template/laravel/src/Media/Profile.php +94 -0
- package/template/laravel/src/Media/UploadRejected.php +8 -0
- package/template/laravel/src/Media/Uploads.php +325 -0
- package/template/laravel/src/Models/Upload.php +46 -0
- package/template/laravel/src/Nevela.php +20 -1
- package/template/laravel/src/NevelaServiceProvider.php +24 -0
- package/template/laravel/src/Rules/UploadKey.php +27 -0
- package/template/laravel/src/Support/Descriptor.php +3 -3
- package/template/laravel/src/Support/Fake.php +194 -171
- package/template/laravel/src/Support/Field.php +108 -12
- package/template/laravel/src/Support/TemplateSource.php +1 -1
- package/template/web/app/api/[...path]/route.ts +87 -0
- package/template/web/app/dashboard/actions.ts +26 -9
- package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
- package/template/web/components/dashboard/fields/file-field.tsx +25 -31
- package/template/web/lib/files.ts +21 -0
- package/template/web/package.json +1 -1
- package/template.mjs +2 -2
- 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
|
-
##
|
|
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
|
-
|
|
44
|
+
npm install -g create-nevela
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
+
nevela upgrade
|
|
51
60
|
```
|
|
52
61
|
|
|
53
|
-
|
|
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,
|
|
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('
|
|
45
|
-
|
|
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('
|
|
49
|
-
nevela
|
|
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
|
|
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 !== '
|
|
335
|
+
if (name !== 'upgrade') process.exit(forward(project, name, args));
|
|
332
336
|
|
|
333
|
-
//
|
|
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, ['
|
|
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('
|
|
347
|
+
console.log(`\n ${indigo(bold('Nevela'))} ${dim('upgrade')}\n`);
|
|
342
348
|
const releases = await latestRelease();
|
|
343
|
-
const status = await
|
|
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
|
|
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`)}
|
|
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.
|
|
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 `
|
|
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 = ['
|
|
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
|
-
/**
|
|
101
|
-
|
|
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
|
|
18
|
-
export async function
|
|
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
|
|
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:
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
];
|
package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php
ADDED
|
@@ -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
|
-
$
|
|
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
|
-
|
|
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
|
}
|