create-nevela 0.1.3 → 0.1.4
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 +10 -1
- package/index.mjs +184 -68
- package/package.json +41 -37
- package/packagist.mjs +17 -0
- package/project.mjs +126 -0
- package/selfupdate.mjs +94 -0
- package/shell.mjs +21 -0
- package/template/laravel/src/Console/SetupCommand.php +55 -0
- package/template/laravel/src/Console/StatusCommand.php +25 -2
- package/template/laravel/src/Console/UpdateCommand.php +160 -92
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +2 -1
- package/template/laravel/src/Support/DashboardState.php +118 -0
- package/template/laravel/src/Support/DashboardUpdate.php +57 -20
- package/template/laravel/src/Support/TemplateSource.php +144 -0
- package/template/web/package.json +1 -1
- package/template.mjs +127 -94
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What an app remembers about its dashboard, in apps/web/.nevela.json.
|
|
7
|
+
*
|
|
8
|
+
* - `template`: the version of the dashboard the app is on.
|
|
9
|
+
* - `files`: a fingerprint of every template file at that version. Comparing the app's
|
|
10
|
+
* files against these is how an update tells "you changed this" from "you didn't",
|
|
11
|
+
* with nothing to download and nothing to guess.
|
|
12
|
+
* - `dependencies`: the template's package.json dependencies at that version, for the
|
|
13
|
+
* same question about each dependency.
|
|
14
|
+
* - `history`: what each update did.
|
|
15
|
+
*
|
|
16
|
+
* Commit the file. Backups and incoming files live beside it in .nevela/, which ignores
|
|
17
|
+
* itself.
|
|
18
|
+
*/
|
|
19
|
+
final class DashboardState
|
|
20
|
+
{
|
|
21
|
+
/** Files the installer personalises for every app (its name); never "changed by you". */
|
|
22
|
+
public const PERSONALISED = ['package.json', 'lib/site.ts'];
|
|
23
|
+
|
|
24
|
+
/** @param array<string, string>|null $files @param array<string, array<string, string>>|null $dependencies @param list<array<string, mixed>> $history */
|
|
25
|
+
public function __construct(
|
|
26
|
+
public ?string $template = null,
|
|
27
|
+
public ?array $files = null,
|
|
28
|
+
public ?array $dependencies = null,
|
|
29
|
+
public array $history = [],
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
public static function path(string $web): string
|
|
33
|
+
{
|
|
34
|
+
return rtrim($web, '/\\').'/.nevela.json';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
public static function read(string $web): self
|
|
38
|
+
{
|
|
39
|
+
$data = json_decode((string) @file_get_contents(self::path($web)), true);
|
|
40
|
+
if (! is_array($data)) {
|
|
41
|
+
return new self;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return new self(
|
|
45
|
+
isset($data['template']) ? (string) $data['template'] : null,
|
|
46
|
+
isset($data['files']) && is_array($data['files']) ? $data['files'] : null,
|
|
47
|
+
isset($data['dependencies']) && is_array($data['dependencies']) ? $data['dependencies'] : null,
|
|
48
|
+
isset($data['history']) && is_array($data['history']) ? array_values($data['history']) : [],
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
public function write(string $web): void
|
|
53
|
+
{
|
|
54
|
+
$files = $this->files ?? [];
|
|
55
|
+
ksort($files);
|
|
56
|
+
file_put_contents(self::path($web), json_encode(array_filter([
|
|
57
|
+
'template' => $this->template,
|
|
58
|
+
'files' => $files ?: null,
|
|
59
|
+
'dependencies' => $this->dependencies,
|
|
60
|
+
'history' => $this->history ?: null,
|
|
61
|
+
], fn ($value) => $value !== null), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Record a template as the one this app is now on.
|
|
66
|
+
*
|
|
67
|
+
* @param array<string, string> $templateFiles path => contents
|
|
68
|
+
*/
|
|
69
|
+
public function adopt(string $version, array $templateFiles): void
|
|
70
|
+
{
|
|
71
|
+
$this->template = $version;
|
|
72
|
+
$this->files = DashboardUpdate::hashes($templateFiles);
|
|
73
|
+
$package = json_decode($templateFiles['package.json'] ?? '{}', true) ?: [];
|
|
74
|
+
$this->dependencies = array_filter([
|
|
75
|
+
'dependencies' => $package['dependencies'] ?? null,
|
|
76
|
+
'devDependencies' => $package['devDependencies'] ?? null,
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The old template's package.json, as far as merging needs it, or null when it wasn't recorded. */
|
|
81
|
+
public function packageJson(): ?string
|
|
82
|
+
{
|
|
83
|
+
return $this->dependencies === null ? null : json_encode($this->dependencies);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The app's own fingerprints for a set of paths.
|
|
88
|
+
*
|
|
89
|
+
* @param iterable<string> $paths
|
|
90
|
+
* @return array<string, string|null>
|
|
91
|
+
*/
|
|
92
|
+
public static function fingerprints(string $web, iterable $paths): array
|
|
93
|
+
{
|
|
94
|
+
$web = rtrim($web, '/\\');
|
|
95
|
+
$yours = [];
|
|
96
|
+
foreach ($paths as $path) {
|
|
97
|
+
$yours[$path] = is_file("{$web}/{$path}") ? DashboardUpdate::hash((string) file_get_contents("{$web}/{$path}")) : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return $yours;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The dashboard files this app has made its own, leaving out the two the installer
|
|
105
|
+
* personalises for everyone.
|
|
106
|
+
*
|
|
107
|
+
* @return array{changed: list<string>, deleted: list<string>}|null null when no fingerprints were recorded
|
|
108
|
+
*/
|
|
109
|
+
public function yourChanges(string $web): ?array
|
|
110
|
+
{
|
|
111
|
+
if ($this->files === null) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
$base = array_diff_key($this->files, array_flip(self::PERSONALISED));
|
|
115
|
+
|
|
116
|
+
return DashboardUpdate::changes($base, self::fingerprints($web, array_keys($base)));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -6,7 +6,7 @@ namespace Nevela\Laravel\Support;
|
|
|
6
6
|
* Works out how to bring an app's dashboard up to a newer template without losing what
|
|
7
7
|
* the developer changed.
|
|
8
8
|
*
|
|
9
|
-
* Three versions of every file are compared: the template the app
|
|
9
|
+
* Three versions of every file are compared, by fingerprint: the template the app is on
|
|
10
10
|
* (base), the newer template (next) and what is in the app now (yours). Only a file the
|
|
11
11
|
* developer hasn't touched is replaced. Pure on purpose: no filesystem, no network.
|
|
12
12
|
*/
|
|
@@ -18,19 +18,37 @@ final class DashboardUpdate
|
|
|
18
18
|
public const REMOVED = 'removed'; // gone from the template; you still have it unchanged
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
21
|
+
* A file's fingerprint. Line endings are ignored: git on Windows may have rewritten
|
|
22
|
+
* them, and that is not a change anyone made.
|
|
23
|
+
*/
|
|
24
|
+
public static function hash(string $contents): string
|
|
25
|
+
{
|
|
26
|
+
return sha1(str_replace("\r\n", "\n", $contents));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param array<string, string> $contents path => contents
|
|
31
|
+
* @return array<string, string> path => fingerprint
|
|
32
|
+
*/
|
|
33
|
+
public static function hashes(array $contents): array
|
|
34
|
+
{
|
|
35
|
+
return array_map(self::hash(...), $contents);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param array<string, string> $base path => fingerprint, the template the app is on
|
|
40
|
+
* @param array<string, string> $next path => fingerprint, the template to move to
|
|
41
|
+
* @param array<string, string|null> $yours path => fingerprint of the app's file, null when it is missing
|
|
24
42
|
* @return array<string, string> path => one of the constants above. Files needing nothing are left out.
|
|
25
43
|
*/
|
|
26
44
|
public static function plan(array $base, array $next, array $yours): array
|
|
27
45
|
{
|
|
28
46
|
$plan = [];
|
|
29
|
-
foreach ($next as $path => $
|
|
47
|
+
foreach ($next as $path => $wanted) {
|
|
30
48
|
$mine = $yours[$path] ?? null;
|
|
31
49
|
$before = $base[$path] ?? null;
|
|
32
50
|
|
|
33
|
-
if ($mine
|
|
51
|
+
if ($mine === $wanted) {
|
|
34
52
|
continue; // already there
|
|
35
53
|
}
|
|
36
54
|
if ($mine === null) {
|
|
@@ -41,15 +59,14 @@ final class DashboardUpdate
|
|
|
41
59
|
|
|
42
60
|
continue;
|
|
43
61
|
}
|
|
44
|
-
if ($before
|
|
62
|
+
if ($before === $wanted) {
|
|
45
63
|
continue; // the template didn't change this file; your version stands
|
|
46
64
|
}
|
|
47
|
-
$plan[$path] = $
|
|
65
|
+
$plan[$path] = $mine === $before ? self::UPDATE : self::CONFLICT;
|
|
48
66
|
}
|
|
49
67
|
|
|
50
|
-
foreach ($base as $path => $
|
|
51
|
-
$
|
|
52
|
-
if (! isset($next[$path]) && $mine !== null && self::same($mine, $contents)) {
|
|
68
|
+
foreach ($base as $path => $before) {
|
|
69
|
+
if (! isset($next[$path]) && ($yours[$path] ?? null) === $before) {
|
|
53
70
|
$plan[$path] = self::REMOVED;
|
|
54
71
|
}
|
|
55
72
|
}
|
|
@@ -59,19 +76,45 @@ final class DashboardUpdate
|
|
|
59
76
|
return $plan;
|
|
60
77
|
}
|
|
61
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The files of the template that differ in the app: what the developer has made their own.
|
|
81
|
+
*
|
|
82
|
+
* @param array<string, string> $base path => fingerprint, the template the app is on
|
|
83
|
+
* @param array<string, string|null> $yours path => fingerprint of the app's file, null when it is missing
|
|
84
|
+
* @return array{changed: list<string>, deleted: list<string>}
|
|
85
|
+
*/
|
|
86
|
+
public static function changes(array $base, array $yours): array
|
|
87
|
+
{
|
|
88
|
+
$changed = [];
|
|
89
|
+
$deleted = [];
|
|
90
|
+
foreach ($base as $path => $before) {
|
|
91
|
+
$mine = $yours[$path] ?? null;
|
|
92
|
+
if ($mine === null) {
|
|
93
|
+
$deleted[] = $path;
|
|
94
|
+
} elseif ($mine !== $before) {
|
|
95
|
+
$changed[] = $path;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
sort($changed);
|
|
99
|
+
sort($deleted);
|
|
100
|
+
|
|
101
|
+
return ['changed' => $changed, 'deleted' => $deleted];
|
|
102
|
+
}
|
|
103
|
+
|
|
62
104
|
/**
|
|
63
105
|
* Move the app's package.json to the template's dependency versions, keeping its name
|
|
64
106
|
* and anything else the developer added.
|
|
65
107
|
*
|
|
66
108
|
* A dependency is moved when the app still has the version the old template had, or
|
|
67
109
|
* doesn't have it at all. One the developer pinned differently is left and reported.
|
|
110
|
+
* Without the old template (`$base` null) only missing dependencies are added.
|
|
68
111
|
*
|
|
69
112
|
* @return array{json: string, changed: array<string, string>, kept: array<string, string>}
|
|
70
113
|
*/
|
|
71
|
-
public static function mergePackageJson(string $yours, string $base, string $next): array
|
|
114
|
+
public static function mergePackageJson(string $yours, ?string $base, string $next): array
|
|
72
115
|
{
|
|
73
116
|
$app = json_decode($yours, true, 512, JSON_THROW_ON_ERROR);
|
|
74
|
-
$old = json_decode($base, true, 512, JSON_THROW_ON_ERROR);
|
|
117
|
+
$old = $base === null ? null : json_decode($base, true, 512, JSON_THROW_ON_ERROR);
|
|
75
118
|
$new = json_decode($next, true, 512, JSON_THROW_ON_ERROR);
|
|
76
119
|
$changed = [];
|
|
77
120
|
$kept = [];
|
|
@@ -83,7 +126,7 @@ final class DashboardUpdate
|
|
|
83
126
|
continue;
|
|
84
127
|
}
|
|
85
128
|
$before = $old[$section][$name] ?? null;
|
|
86
|
-
if ($mine === null || $mine === $before) {
|
|
129
|
+
if ($mine === null || ($old !== null && $mine === $before)) {
|
|
87
130
|
$app[$section][$name] = $version;
|
|
88
131
|
$changed[$name] = ($mine ?? 'not installed').' → '.$version;
|
|
89
132
|
} else {
|
|
@@ -101,10 +144,4 @@ final class DashboardUpdate
|
|
|
101
144
|
|
|
102
145
|
return ['json' => $json."\n", 'changed' => $changed, 'kept' => $kept];
|
|
103
146
|
}
|
|
104
|
-
|
|
105
|
-
/** Equal apart from line endings: git on Windows may have rewritten them. */
|
|
106
|
-
public static function same(string $a, string $b): bool
|
|
107
|
-
{
|
|
108
|
-
return $a === $b || str_replace("\r\n", "\n", $a) === str_replace("\r\n", "\n", $b);
|
|
109
|
-
}
|
|
110
147
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Support\Facades\Http;
|
|
6
|
+
use PharData;
|
|
7
|
+
use RecursiveDirectoryIterator;
|
|
8
|
+
use RecursiveIteratorIterator;
|
|
9
|
+
use RuntimeException;
|
|
10
|
+
use Throwable;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The dashboard as it shipped in a given version of Nevela.
|
|
14
|
+
*
|
|
15
|
+
* Tried in order, so an update never depends on one place being up to date:
|
|
16
|
+
*
|
|
17
|
+
* 1. NEVELA_TEMPLATE_DIR, a folder of create-nevela-<version>.tgz files. For working on
|
|
18
|
+
* Nevela itself, and for updating without a connection.
|
|
19
|
+
* 2. The create-nevela package on npm, which carries the dashboard.
|
|
20
|
+
* 3. The repository's own tag on GitHub. Tags are pushed before anything is published,
|
|
21
|
+
* so this works in the gap between a release and its npm package.
|
|
22
|
+
*/
|
|
23
|
+
final class TemplateSource
|
|
24
|
+
{
|
|
25
|
+
private const NPM = 'https://registry.npmjs.org/create-nevela/-/create-nevela-%s.tgz';
|
|
26
|
+
|
|
27
|
+
private const GITHUB = 'https://codeload.github.com/MarkColeMukisa/nevela/tar.gz/refs/tags/v%s';
|
|
28
|
+
|
|
29
|
+
/** Never part of an app's dashboard: build output, installs and local settings. */
|
|
30
|
+
private const NEVER = ['node_modules', '.next', '.env.local', 'tsconfig.tsbuildinfo', 'next-env.d.ts'];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @return array{files: array<string, string>, from: string} path => contents, and where it came from
|
|
34
|
+
*/
|
|
35
|
+
public static function fetch(string $version): array
|
|
36
|
+
{
|
|
37
|
+
$dir = rtrim(sys_get_temp_dir(), '/\\').DIRECTORY_SEPARATOR.'nevela-template-'.$version.'-'.bin2hex(random_bytes(4));
|
|
38
|
+
mkdir($dir, 0775, true);
|
|
39
|
+
$archive = $dir.DIRECTORY_SEPARATOR.'template.tgz';
|
|
40
|
+
$tried = [];
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
$local = rtrim((string) env('NEVELA_TEMPLATE_DIR', ''), '/\\');
|
|
44
|
+
if ($local !== '' && is_file("{$local}/create-nevela-{$version}.tgz")) {
|
|
45
|
+
copy("{$local}/create-nevela-{$version}.tgz", $archive);
|
|
46
|
+
|
|
47
|
+
return ['files' => self::read($archive, $dir, 'package/template/web', false), 'from' => 'NEVELA_TEMPLATE_DIR'];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
foreach (['npm' => [self::NPM, 'package/template/web', false], 'GitHub' => [self::GITHUB, "nevela-{$version}/apps/web", true]] as $name => [$url, $root, $filter]) {
|
|
51
|
+
try {
|
|
52
|
+
$response = Http::timeout(60)->get(sprintf($url, $version));
|
|
53
|
+
if (! $response->successful()) {
|
|
54
|
+
$tried[] = "{$name}: HTTP {$response->status()}";
|
|
55
|
+
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
file_put_contents($archive, $response->body());
|
|
59
|
+
|
|
60
|
+
return ['files' => self::read($archive, $dir.DIRECTORY_SEPARATOR.strtolower($name), $root, $filter), 'from' => $name];
|
|
61
|
+
} catch (Throwable $e) {
|
|
62
|
+
$tried[] = "{$name}: ".$e->getMessage();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
throw new RuntimeException("version {$version} was not found (".implode('; ', $tried).')');
|
|
67
|
+
} finally {
|
|
68
|
+
self::remove($dir);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Unpack an archive and read the dashboard out of it.
|
|
74
|
+
*
|
|
75
|
+
* @param bool $filter Whether this is the repository's own apps/web, whose example resources aren't part of the template
|
|
76
|
+
* @return array<string, string>
|
|
77
|
+
*/
|
|
78
|
+
private static function read(string $archive, string $into, string $root, bool $filter): array
|
|
79
|
+
{
|
|
80
|
+
if (! is_dir($into)) {
|
|
81
|
+
mkdir($into, 0775, true);
|
|
82
|
+
}
|
|
83
|
+
(new PharData($archive))->extractTo($into, null, true);
|
|
84
|
+
$base = $into.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $root);
|
|
85
|
+
if (! is_dir($base)) {
|
|
86
|
+
throw new RuntimeException('the archive has no dashboard in it');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
$files = [];
|
|
90
|
+
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, RecursiveDirectoryIterator::SKIP_DOTS)) as $file) {
|
|
91
|
+
$path = str_replace('\\', '/', substr($file->getPathname(), strlen($base) + 1));
|
|
92
|
+
if (array_intersect(explode('/', $path), self::NEVER) !== []) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// npm can't carry .gitignore files, so its template holds them as _gitignore.
|
|
96
|
+
$path = preg_replace('#(^|/)_gitignore$#', '$1.gitignore', $path);
|
|
97
|
+
$files[$path] = (string) file_get_contents($file->getPathname());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return $filter ? self::withoutExamples($files) : $files;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The repository's dashboard has example resources in it (a Product, say). They are
|
|
105
|
+
* generated from Laravel, not part of what a new app is given.
|
|
106
|
+
*
|
|
107
|
+
* @param array<string, string> $files
|
|
108
|
+
* @return array<string, string>
|
|
109
|
+
*/
|
|
110
|
+
public static function withoutExamples(array $files): array
|
|
111
|
+
{
|
|
112
|
+
$slugs = [];
|
|
113
|
+
foreach ($files as $path => $contents) {
|
|
114
|
+
if (preg_match('#^resources/[^/]+\.resource\.ts$#', $path) && preg_match('/slug:\s*"([^"]+)"/', $contents, $m)) {
|
|
115
|
+
$slugs[] = $m[1];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return array_filter($files, function (string $path) use ($slugs) {
|
|
120
|
+
if (preg_match('#^resources/[^/]+\.resource\.ts$#', $path) || $path === 'resources/index.ts') {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
foreach ($slugs as $slug) {
|
|
124
|
+
if (str_starts_with($path, "app/dashboard/{$slug}/")) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return true;
|
|
130
|
+
}, ARRAY_FILTER_USE_KEY);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private static function remove(string $dir): void
|
|
134
|
+
{
|
|
135
|
+
if (! is_dir($dir)) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
$items = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
|
|
139
|
+
foreach ($items as $item) {
|
|
140
|
+
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
|
141
|
+
}
|
|
142
|
+
@rmdir($dir);
|
|
143
|
+
}
|
|
144
|
+
}
|
package/template.mjs
CHANGED
|
@@ -1,94 +1,127 @@
|
|
|
1
|
-
// Where the new app's files come from.
|
|
2
|
-
//
|
|
3
|
-
// Published to npm, this package carries a `template/` folder built by build-template.mjs.
|
|
4
|
-
// Run from the repository, it reads apps/web and packages/laravel directly, so there is
|
|
5
|
-
// one source for the dashboard and nothing to keep in sync by hand.
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
fs.writeFileSync(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
fs.
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
function
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
1
|
+
// Where the new app's files come from.
|
|
2
|
+
//
|
|
3
|
+
// Published to npm, this package carries a `template/` folder built by build-template.mjs.
|
|
4
|
+
// Run from the repository, it reads apps/web and packages/laravel directly, so there is
|
|
5
|
+
// one source for the dashboard and nothing to keep in sync by hand.
|
|
6
|
+
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const bundled = path.join(here, 'template');
|
|
14
|
+
// In a checkout the dashboard two folders up is the source, even when a template/ folder
|
|
15
|
+
// is lying around from an earlier `npm pack`: that copy may be stale.
|
|
16
|
+
const fromRepo = fs.existsSync(path.resolve(here, '..', '..', 'apps', 'web', 'package.json')) || !fs.existsSync(bundled);
|
|
17
|
+
|
|
18
|
+
/** True when this is a checkout of the repository rather than the package from npm. */
|
|
19
|
+
export const fromRepository = fromRepo;
|
|
20
|
+
|
|
21
|
+
export const sources = {
|
|
22
|
+
web: fromRepo ? path.resolve(here, '..', '..', 'apps', 'web') : path.join(bundled, 'web'),
|
|
23
|
+
laravel: fromRepo ? path.resolve(here, '..', 'laravel') : path.join(bundled, 'laravel'),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Never part of a new app: build output, installs and local settings. */
|
|
27
|
+
const NEVER = new Set(['node_modules', '.next', '.env.local', 'tsconfig.tsbuildinfo', 'next-env.d.ts', 'vendor', '.phpunit.result.cache', '.phpunit.cache', 'composer.lock']);
|
|
28
|
+
|
|
29
|
+
function copyTree(from, to, skip = () => false, relative = '') {
|
|
30
|
+
fs.mkdirSync(to, { recursive: true });
|
|
31
|
+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
|
|
32
|
+
const inside = relative ? `${relative}/${entry.name}` : entry.name;
|
|
33
|
+
if (NEVER.has(entry.name) || skip(inside, entry)) continue;
|
|
34
|
+
const source = path.join(from, entry.name);
|
|
35
|
+
// npm leaves .gitignore files out of a published package, so the bundled template
|
|
36
|
+
// carries them as _gitignore (see buildTemplate) and they get their name back here.
|
|
37
|
+
const target = path.join(to, entry.name === '_gitignore' ? '.gitignore' : entry.name);
|
|
38
|
+
if (entry.isDirectory()) copyTree(source, target, skip, inside);
|
|
39
|
+
else if (entry.isFile()) fs.copyFileSync(source, target);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What belongs to the framework repository's own example resources, not to a new app:
|
|
45
|
+
* their descriptors, the registry that lists them, and their dashboard pages.
|
|
46
|
+
*/
|
|
47
|
+
export function exampleResourceFilter(webRoot) {
|
|
48
|
+
const resources = path.join(webRoot, 'resources');
|
|
49
|
+
const slugs = new Set();
|
|
50
|
+
if (fs.existsSync(resources)) {
|
|
51
|
+
for (const file of fs.readdirSync(resources)) {
|
|
52
|
+
if (!file.endsWith('.resource.ts')) continue;
|
|
53
|
+
const slug = /slug:\s*"([^"]+)"/.exec(fs.readFileSync(path.join(resources, file), 'utf8'));
|
|
54
|
+
if (slug) slugs.add(slug[1]);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return (inside) =>
|
|
58
|
+
/^resources\/[^/]+\.resource\.ts$/.test(inside)
|
|
59
|
+
|| inside === 'resources/index.ts'
|
|
60
|
+
|| [...slugs].some((slug) => inside === `app/dashboard/${slug}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The dashboard, without the repository's example resources, named for the new app. */
|
|
64
|
+
export function copyWebTemplate(to, { name, title }) {
|
|
65
|
+
copyTree(sources.web, to, exampleResourceFilter(sources.web));
|
|
66
|
+
|
|
67
|
+
const packageFile = path.join(to, 'package.json');
|
|
68
|
+
const manifest = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
|
|
69
|
+
manifest.name = `${name}-web`;
|
|
70
|
+
fs.writeFileSync(packageFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
71
|
+
|
|
72
|
+
const siteFile = path.join(to, 'lib', 'site.ts');
|
|
73
|
+
const site = fs.readFileSync(siteFile, 'utf8')
|
|
74
|
+
.replace('name: "Nevela"', `name: ${JSON.stringify(title)}`)
|
|
75
|
+
.replace('description: "Nevela keeps', `description: "${title.replace(/"/g, '')} keeps`);
|
|
76
|
+
fs.writeFileSync(siteFile, site);
|
|
77
|
+
|
|
78
|
+
// The generator fills this in; it has to exist for the first build.
|
|
79
|
+
fs.mkdirSync(path.join(to, 'resources'), { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A fingerprint of every template file, and the template's dependencies: what the app
|
|
84
|
+
* records in .nevela.json so that `nevela update` can tell, later and exactly, which
|
|
85
|
+
* files the developer changed. The same fingerprint the Laravel package computes: SHA-1
|
|
86
|
+
* with line endings ignored.
|
|
87
|
+
*/
|
|
88
|
+
export function templateRecord(version) {
|
|
89
|
+
const skip = exampleResourceFilter(sources.web);
|
|
90
|
+
const files = {};
|
|
91
|
+
const walk = (dir, relative = '') => {
|
|
92
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
93
|
+
const inside = relative ? `${relative}/${entry.name}` : entry.name;
|
|
94
|
+
if (NEVER.has(entry.name) || skip(inside, entry)) continue;
|
|
95
|
+
if (entry.isDirectory()) walk(path.join(dir, entry.name), inside);
|
|
96
|
+
else if (entry.isFile()) {
|
|
97
|
+
const bytes = fs.readFileSync(path.join(dir, entry.name)).toString('latin1').replace(/\r\n/g, '\n');
|
|
98
|
+
files[inside.replace(/(^|\/)_gitignore$/, '$1.gitignore')] = crypto.createHash('sha1').update(bytes, 'latin1').digest('hex');
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
walk(sources.web);
|
|
103
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(sources.web, 'package.json'), 'utf8'));
|
|
104
|
+
const dependencies = {};
|
|
105
|
+
for (const section of ['dependencies', 'devDependencies']) if (manifest[section]) dependencies[section] = manifest[section];
|
|
106
|
+
return { template: version, files: Object.fromEntries(Object.entries(files).sort(([a], [b]) => a.localeCompare(b))), dependencies };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The Laravel package, for installing by path until it is on Packagist. */
|
|
110
|
+
export function copyLaravelPackage(to) {
|
|
111
|
+
copyTree(sources.laravel, to, (inside) => inside === 'tests' || inside === 'phpunit.xml');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Used by build-template.mjs: the same filtered copies, into this package's template/. */
|
|
115
|
+
export function buildTemplate(to) {
|
|
116
|
+
copyTree(sources.web, path.join(to, 'web'), exampleResourceFilter(sources.web));
|
|
117
|
+
copyLaravelPackage(path.join(to, 'laravel'));
|
|
118
|
+
hideGitignores(to);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function hideGitignores(dir) {
|
|
122
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
123
|
+
const full = path.join(dir, entry.name);
|
|
124
|
+
if (entry.isDirectory()) hideGitignores(full);
|
|
125
|
+
else if (entry.name === '.gitignore') fs.renameSync(full, path.join(dir, '_gitignore'));
|
|
126
|
+
}
|
|
127
|
+
}
|