create-nevela 0.8.0 → 0.9.2
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/index.mjs +24 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +41 -0
- package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
- package/template/laravel/resources/disposable-domains.txt +9880 -0
- package/template/laravel/src/Access/ClosedAccounts.php +2 -1
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Console/BlogCommand.php +254 -0
- package/template/laravel/src/Console/DevCommand.php +9 -0
- package/template/laravel/src/Console/DigestCommand.php +28 -0
- package/template/laravel/src/Console/DisposableCommand.php +49 -0
- package/template/laravel/src/Console/GenerateCommand.php +25 -0
- package/template/laravel/src/Console/SetupCommand.php +15 -2
- package/template/laravel/src/Console/StatusCommand.php +14 -0
- package/template/laravel/src/Console/TrashCommand.php +9 -0
- package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
- package/template/laravel/src/Forms/Confirmation.php +88 -0
- package/template/laravel/src/Forms/Disposable.php +74 -0
- package/template/laravel/src/Forms/FormBlocks.php +76 -0
- package/template/laravel/src/Forms/FormGuard.php +359 -0
- package/template/laravel/src/Forms/OwnerDigest.php +63 -0
- package/template/laravel/src/Forms/PublicMail.php +217 -0
- package/template/laravel/src/Forms/Refusal.php +50 -0
- package/template/laravel/src/Generator/BlogPages.php +409 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +133 -7
- package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
- package/template/laravel/src/Http/Access/RolesController.php +2 -1
- package/template/laravel/src/Http/Access/UsersController.php +2 -1
- package/template/laravel/src/Http/ActionController.php +2 -1
- package/template/laravel/src/Http/Auth/AccountController.php +6 -3
- package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
- package/template/laravel/src/Http/FormsController.php +45 -0
- package/template/laravel/src/Http/PublicController.php +89 -4
- package/template/laravel/src/Http/ResourceRequest.php +43 -3
- package/template/laravel/src/Http/TrashController.php +2 -1
- package/template/laravel/src/Http/UploadController.php +2 -2
- package/template/laravel/src/Nevela.php +30 -1
- package/template/laravel/src/NevelaServiceProvider.php +11 -2
- package/template/laravel/src/Support/Descriptor.php +24 -0
- package/template/laravel/src/Support/Field.php +17 -0
- package/template/laravel/src/Support/Key.php +57 -0
- package/template/laravel/src/Support/ListQuery.php +29 -1
- package/template/laravel/src/Support/PublicAccess.php +75 -0
- package/template/laravel/src/Support/Slug.php +43 -0
- package/template/laravel/src/Trash/Trash.php +3 -1
- package/template/web/.env.example +4 -0
- package/template/web/app/api/[...path]/route.ts +6 -3
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +15 -1
- package/template/web/components/dashboard/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +29 -5
- package/template/web/components/dashboard/record-detail.tsx +2 -1
- package/template/web/components/dashboard/resource-form.tsx +19 -2
- package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
- package/template/web/components/dashboard/resource-table.tsx +32 -3
- package/template/web/components/dashboard/row-actions.tsx +33 -3
- package/template/web/components/marketing/site-header.tsx +9 -2
- package/template/web/components/public-form-guard.tsx +125 -0
- package/template/web/hooks/use-public-form.ts +263 -0
- package/template/web/lib/extras.ts +39 -0
- package/template/web/package.json +1 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Forms;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Contracts\Mail\Mailable;
|
|
6
|
+
use Illuminate\Support\Carbon;
|
|
7
|
+
use Illuminate\Support\Facades\Cache;
|
|
8
|
+
use Illuminate\Support\Facades\DB;
|
|
9
|
+
use Illuminate\Support\Facades\Mail;
|
|
10
|
+
use Illuminate\Support\Facades\Notification;
|
|
11
|
+
use Illuminate\Support\Str;
|
|
12
|
+
use Throwable;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The mail a public form causes, with the limits that make it safe to send.
|
|
16
|
+
*
|
|
17
|
+
* PublicMail::toVisitor($message->email, new MessageReceived($message));
|
|
18
|
+
* PublicMail::toOwner(new NewMessage($message), line: $message->subject, url: $link);
|
|
19
|
+
*
|
|
20
|
+
* A form that writes back to whatever address was typed into it will write to anyone:
|
|
21
|
+
* type a stranger's address a thousand times and the site has sent them a thousand
|
|
22
|
+
* emails. So an address is written to at most once an hour and three times a day,
|
|
23
|
+
* counted across every form, and past that the email is dropped. The record is saved
|
|
24
|
+
* either way.
|
|
25
|
+
*
|
|
26
|
+
* The owner is the other inbox. A burst of submissions would bury the real ones, so only
|
|
27
|
+
* so many notices go out in an hour. The rest are held, and one email at the top of the
|
|
28
|
+
* hour names them. Nothing is lost: every record is in the dashboard.
|
|
29
|
+
*
|
|
30
|
+
* Both are queued, so a slow mail server doesn't keep a visitor waiting.
|
|
31
|
+
*
|
|
32
|
+
* Called while a record is being saved (a model's `created`, inside a transaction), the
|
|
33
|
+
* counting and the sending wait until the save is committed. A record that is rolled
|
|
34
|
+
* back sends nothing, and nothing here can spoil the transaction it was called in: on
|
|
35
|
+
* Postgres one failed statement, such as a lock someone else holds, would abort it all.
|
|
36
|
+
*/
|
|
37
|
+
final class PublicMail
|
|
38
|
+
{
|
|
39
|
+
private const SENT = 'nevela_public_mail';
|
|
40
|
+
|
|
41
|
+
private const HELD = 'nevela_owner_digest';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Write to an address a form was given. False when it has been written to too often
|
|
45
|
+
* and this one was dropped. (Inside a transaction that isn't known yet: the answer is
|
|
46
|
+
* true, and the limit is applied when the transaction commits.)
|
|
47
|
+
*/
|
|
48
|
+
public static function toVisitor(string $address, Mailable $mail): bool
|
|
49
|
+
{
|
|
50
|
+
$address = strtolower(trim($address));
|
|
51
|
+
if (! filter_var($address, FILTER_VALIDATE_EMAIL)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
$who = self::fingerprint($address);
|
|
55
|
+
|
|
56
|
+
return self::whenSaved(fn () => self::counted("visitor:{$who}", function () use ($address, $mail, $who) {
|
|
57
|
+
$hour = self::sentSince('visitor', $who, now()->subHour());
|
|
58
|
+
$day = self::sentSince('visitor', $who, now()->subDay());
|
|
59
|
+
if ($hour >= (int) config('nevela.public.mail.visitor_per_hour', 1) || $day >= (int) config('nevela.public.mail.visitor_per_day', 3)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
self::record('visitor', $who);
|
|
63
|
+
Mail::to($address)->queue($mail);
|
|
64
|
+
|
|
65
|
+
return true;
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Tell the owner a form was sent. False when the hour's notices have gone, and this
|
|
71
|
+
* one waits for the digest.
|
|
72
|
+
*
|
|
73
|
+
* @param string|null $line What the digest says of it: a subject, or its first line
|
|
74
|
+
* @param string|null $url Where in the dashboard it is
|
|
75
|
+
*/
|
|
76
|
+
public static function toOwner(Mailable $mail, ?string $line = null, ?string $url = null): bool
|
|
77
|
+
{
|
|
78
|
+
$to = self::owner();
|
|
79
|
+
if ($to === null) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return self::whenSaved(fn () => self::counted('owner', function () use ($mail, $line, $url, $to) {
|
|
84
|
+
if (self::sentSince('owner', 'owner', now()->subHour()) >= (int) config('nevela.public.notify.max_per_hour', 10)) {
|
|
85
|
+
DB::table(self::HELD)->insert([
|
|
86
|
+
'id' => (string) Str::uuid(),
|
|
87
|
+
'subject' => Str::limit(self::subjectOf($mail), 250, ''),
|
|
88
|
+
'line' => $line === null ? null : Str::limit(trim(preg_replace('/\s+/', ' ', $line) ?? ''), 490),
|
|
89
|
+
'url' => $url === null ? null : Str::limit($url, 2040, ''),
|
|
90
|
+
'held_at' => now(),
|
|
91
|
+
]);
|
|
92
|
+
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
self::record('owner', 'owner');
|
|
96
|
+
Mail::to($to)->queue($mail);
|
|
97
|
+
|
|
98
|
+
return true;
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Send what was held, as one email, and forget it. Run at the top of each hour by the
|
|
104
|
+
* scheduler (`nevela:digest`). Returns how many notices it named.
|
|
105
|
+
*/
|
|
106
|
+
public static function digest(): int
|
|
107
|
+
{
|
|
108
|
+
$to = self::owner();
|
|
109
|
+
$held = DB::table(self::HELD)->orderBy('held_at')->get();
|
|
110
|
+
if ($held->isEmpty()) {
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
if ($to !== null) {
|
|
114
|
+
Notification::route('mail', $to)->notify(new OwnerDigest(
|
|
115
|
+
$held->map(fn ($row) => ['subject' => (string) $row->subject, 'line' => $row->line, 'url' => $row->url])->all(),
|
|
116
|
+
Carbon::parse($held->first()->held_at)->format('H:i'),
|
|
117
|
+
));
|
|
118
|
+
}
|
|
119
|
+
DB::table(self::HELD)->whereIn('id', $held->pluck('id')->all())->delete();
|
|
120
|
+
|
|
121
|
+
return $held->count();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Forget who was written to more than a few days ago, and old counts of what was refused. */
|
|
125
|
+
public static function prune(): void
|
|
126
|
+
{
|
|
127
|
+
try {
|
|
128
|
+
DB::table(self::SENT)->where('sent_at', '<', now()->subDays(2))->delete();
|
|
129
|
+
DB::table('nevela_form_blocks')->where('day', '<', now()->subDays(90)->toDateString())->delete();
|
|
130
|
+
} catch (Throwable) {
|
|
131
|
+
// Not migrated yet.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Who a form's notices go to: nevela.public.notify.to, or the address the app sends from. */
|
|
136
|
+
public static function owner(): ?string
|
|
137
|
+
{
|
|
138
|
+
$to = config('nevela.public.notify.to') ?: config('mail.from.address');
|
|
139
|
+
|
|
140
|
+
return is_string($to) && filter_var($to, FILTER_VALIDATE_EMAIL) ? $to : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** An address as it is counted by: enough to tell it from another, not to read it back. */
|
|
144
|
+
public static function fingerprint(string $address): string
|
|
145
|
+
{
|
|
146
|
+
return hash_hmac('sha256', strtolower(trim($address)), 'nevela-mail|'.config('app.key'));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private static function sentSince(string $kind, string $recipient, Carbon $since): int
|
|
150
|
+
{
|
|
151
|
+
return DB::table(self::SENT)->where('kind', $kind)->where('recipient', $recipient)->where('sent_at', '>', $since)->count();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private static function record(string $kind, string $recipient): void
|
|
155
|
+
{
|
|
156
|
+
DB::table(self::SENT)->insert(['id' => (string) Str::uuid(), 'kind' => $kind, 'recipient' => $recipient, 'sent_at' => now()]);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What an email is headed, for the digest to name it by: its subject, or failing that what it is called. */
|
|
160
|
+
private static function subjectOf(Mailable $mail): string
|
|
161
|
+
{
|
|
162
|
+
try {
|
|
163
|
+
// Asked of a copy: a mailable that sets its subject in build() must not be built twice.
|
|
164
|
+
$probe = clone $mail;
|
|
165
|
+
if (method_exists($probe, 'envelope')) {
|
|
166
|
+
$subject = $probe->envelope()->subject;
|
|
167
|
+
} else {
|
|
168
|
+
if (method_exists($probe, 'build')) {
|
|
169
|
+
app()->call([$probe, 'build']);
|
|
170
|
+
}
|
|
171
|
+
$subject = $probe->subject ?? null;
|
|
172
|
+
}
|
|
173
|
+
} catch (Throwable) {
|
|
174
|
+
$subject = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return is_string($subject) && $subject !== '' ? $subject : Str::headline(class_basename($mail));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Now, or once the transaction this was called in has committed. Never inside it.
|
|
182
|
+
*
|
|
183
|
+
* @param callable(): bool $send
|
|
184
|
+
*/
|
|
185
|
+
private static function whenSaved(callable $send): bool
|
|
186
|
+
{
|
|
187
|
+
$sent = null;
|
|
188
|
+
// Runs at once where there is no transaction to wait for.
|
|
189
|
+
DB::afterCommit(function () use (&$sent, $send) {
|
|
190
|
+
$sent = $send();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return $sent ?? true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Count and send as one step, so two requests at the same moment can't both be the
|
|
198
|
+
* last one allowed. Mail is never the reason a form fails: a problem here is reported
|
|
199
|
+
* and the answer is "not sent".
|
|
200
|
+
*
|
|
201
|
+
* @param callable(): bool $send
|
|
202
|
+
*/
|
|
203
|
+
private static function counted(string $name, callable $send): bool
|
|
204
|
+
{
|
|
205
|
+
try {
|
|
206
|
+
try {
|
|
207
|
+
return (bool) Cache::lock('nevela-mail:'.$name, 10)->block(5, $send);
|
|
208
|
+
} catch (\Illuminate\Contracts\Cache\LockTimeoutException) {
|
|
209
|
+
return (bool) $send();
|
|
210
|
+
}
|
|
211
|
+
} catch (Throwable $e) {
|
|
212
|
+
report($e);
|
|
213
|
+
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Forms;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Http\JsonResponse;
|
|
6
|
+
use Illuminate\Http\Response;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A public form that was turned away, and what its sender is told.
|
|
10
|
+
*
|
|
11
|
+
* Which of three it is depends on who could be on the other end. A filled honeypot or a
|
|
12
|
+
* forged token is a program, and is told it worked: it learns nothing to adjust to. A
|
|
13
|
+
* failed robot check can happen to a person, so they are told, in a sentence. A problem
|
|
14
|
+
* with the address they typed is a validation message on that field, like any other.
|
|
15
|
+
*/
|
|
16
|
+
final class Refusal
|
|
17
|
+
{
|
|
18
|
+
/** Refusals nobody is told about. The answer is the one a success gets, and nothing is stored. */
|
|
19
|
+
public const SILENT = ['honeypot', 'forged', 'too_fast'];
|
|
20
|
+
|
|
21
|
+
public function __construct(
|
|
22
|
+
public readonly string $reason,
|
|
23
|
+
public readonly int $status = 422,
|
|
24
|
+
public readonly string $message = '',
|
|
25
|
+
public readonly ?string $field = null,
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
public function silent(): bool
|
|
29
|
+
{
|
|
30
|
+
return in_array($this->reason, self::SILENT, true);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
public function response(): JsonResponse|Response
|
|
34
|
+
{
|
|
35
|
+
if ($this->silent()) {
|
|
36
|
+
$response = response()->noContent(201);
|
|
37
|
+
// Whoever is building the form sees why nothing arrived. A bot in production doesn't.
|
|
38
|
+
if (config('app.debug')) {
|
|
39
|
+
$response->header('X-Nevela-Blocked', $this->reason);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return $response;
|
|
43
|
+
}
|
|
44
|
+
if ($this->field !== null) {
|
|
45
|
+
return response()->json(['error' => 'Validation failed.', 'issues' => [['path' => $this->field, 'message' => $this->message]]], $this->status);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return response()->json(['error' => $this->message, 'code' => strtoupper($this->reason)], $this->status);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Generator;
|
|
4
|
+
|
|
5
|
+
use Nevela\Laravel\Support\Descriptor;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The site's blog: the pages that show a resource's public records as posts.
|
|
9
|
+
*
|
|
10
|
+
* Written once by `nevela:blog` into the web app, and the app's from then on, like the
|
|
11
|
+
* dashboard's pages. What differs between one app's blog and another's is which resource
|
|
12
|
+
* the posts are and what its fields are called, and all of that goes into one file,
|
|
13
|
+
* lib/blog.ts. The pages only know a post has a title, a body and an address.
|
|
14
|
+
*
|
|
15
|
+
* Pure, like the resource generator: this returns files, and Writer writes them.
|
|
16
|
+
*/
|
|
17
|
+
final class BlogPages
|
|
18
|
+
{
|
|
19
|
+
/**
|
|
20
|
+
* @param array{title: string, body: string, excerpt?: string|null, image?: string|null, published?: string|null} $fields The resource's field for each part of a post
|
|
21
|
+
* @return list<GeneratedFile>
|
|
22
|
+
*/
|
|
23
|
+
public static function files(Descriptor $d, array $fields): array
|
|
24
|
+
{
|
|
25
|
+
$path = (string) $d->blog;
|
|
26
|
+
$segment = ltrim($path, '/');
|
|
27
|
+
$image = $fields['image'] ?? null;
|
|
28
|
+
$published = $fields['published'] ?? null;
|
|
29
|
+
$excerpt = $fields['excerpt'] ?? null;
|
|
30
|
+
$quote = fn (?string $value) => $value === null ? 'null' : json_encode($value, JSON_UNESCAPED_SLASHES);
|
|
31
|
+
|
|
32
|
+
$replace = [
|
|
33
|
+
'__PATH__' => $path,
|
|
34
|
+
'__RESOURCE__' => $d->name,
|
|
35
|
+
'__SLUG__' => $d->slug,
|
|
36
|
+
'__PLURAL__' => strtolower($d->pluralLabel),
|
|
37
|
+
'__BY__' => (string) $d->public?->by,
|
|
38
|
+
'__TITLE__' => $fields['title'],
|
|
39
|
+
'__BODY__' => $fields['body'],
|
|
40
|
+
'__EXCERPT__' => $quote($excerpt),
|
|
41
|
+
'__IMAGE__' => $quote($image),
|
|
42
|
+
'__PUBLISHED__' => $quote($published),
|
|
43
|
+
// HTML only where Laravel cleans it: a rich text field. Anything else is shown as the text it is.
|
|
44
|
+
'__HTML__' => $d->fields[$fields['body']]->isRich() ? 'true' : 'false',
|
|
45
|
+
// An upload has renditions and is fetched through this app. A path kept in a plain column is the old app's own file.
|
|
46
|
+
'__UPLOAD__' => $image !== null && $d->fields[$image]->kind === 'file' ? 'true' : 'false',
|
|
47
|
+
'__SORT__' => $published !== null ? "-{$published}" : '-createdAt',
|
|
48
|
+
];
|
|
49
|
+
$header = "// Generated once by Nevela for the blog at {$path}. This file is yours.\n";
|
|
50
|
+
$files = [
|
|
51
|
+
'lib/blog.ts' => self::LIBRARY,
|
|
52
|
+
"app/{$segment}/page.tsx" => self::INDEX,
|
|
53
|
+
"app/{$segment}/[slug]/page.tsx" => self::POST,
|
|
54
|
+
"app/{$segment}/feed.xml/route.ts" => self::FEED,
|
|
55
|
+
'app/sitemap.ts' => self::SITEMAP,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
return array_map(
|
|
59
|
+
fn (string $file, string $contents) => new GeneratedFile(GeneratedFile::TARGET_WEB, $file, $header.strtr($contents, $replace), GeneratedFile::MODE_ONCE),
|
|
60
|
+
array_keys($files),
|
|
61
|
+
$files,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private const LIBRARY = <<<'TS'
|
|
66
|
+
//
|
|
67
|
+
// Everything the blog's pages know about where posts come from is here: which resource
|
|
68
|
+
// they are, what its fields are called, and how long a page of them may be reused.
|
|
69
|
+
// The posts are the resource's public records (its descriptor's "public" section, and
|
|
70
|
+
// the publicQuery scope on its model), read with no session, so the pages are the same
|
|
71
|
+
// for every visitor and are cached.
|
|
72
|
+
import { resourceTag } from "@/lib/cache";
|
|
73
|
+
import { fileUrl } from "@/lib/files";
|
|
74
|
+
import { API_URL, laravelPublic } from "@/lib/laravel";
|
|
75
|
+
import { site } from "@/lib/site";
|
|
76
|
+
|
|
77
|
+
export const blog = {
|
|
78
|
+
/** Where the blog is on the site. The pages are in app__PATH__. */
|
|
79
|
+
path: "__PATH__",
|
|
80
|
+
title: "Blog",
|
|
81
|
+
description: `News and notes from ${site.name}.`,
|
|
82
|
+
/** Posts on a page of the list. */
|
|
83
|
+
perPage: 9,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** The resource's field for each part of a post. `null` where it has none. */
|
|
87
|
+
const FIELDS = {
|
|
88
|
+
title: "__TITLE__",
|
|
89
|
+
body: "__BODY__",
|
|
90
|
+
excerpt: __EXCERPT__ as string | null,
|
|
91
|
+
image: __IMAGE__ as string | null,
|
|
92
|
+
published: __PUBLISHED__ as string | null,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether the body is HTML. True for a rich text field, which Laravel cleans before it
|
|
97
|
+
* sends it, so it can go on the page as it is. Leave it false for any other field: its
|
|
98
|
+
* value is then shown as plain text, and whatever is in it can do no harm.
|
|
99
|
+
*/
|
|
100
|
+
export const BODY_IS_HTML: boolean = __HTML__;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether the picture is a file uploaded through Nevela (an image field), which has
|
|
104
|
+
* smaller renditions and is fetched through this app. False for a plain column that
|
|
105
|
+
* holds a path or an address of the app's own.
|
|
106
|
+
*/
|
|
107
|
+
const IMAGE_IS_UPLOAD: boolean = __UPLOAD__;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* How long an answer may be reused, in seconds. Saving a post in the dashboard clears it
|
|
111
|
+
* at once (the tag below). The time is for what nobody saves: a post scheduled for later
|
|
112
|
+
* appears within this long of its moment.
|
|
113
|
+
*/
|
|
114
|
+
const FRESH_FOR = 60;
|
|
115
|
+
const TAGS = [resourceTag("__RESOURCE__")];
|
|
116
|
+
|
|
117
|
+
export interface BlogPost {
|
|
118
|
+
id: string;
|
|
119
|
+
title: string;
|
|
120
|
+
/** What its address ends in: __PATH__/<slug>. */
|
|
121
|
+
slug: string;
|
|
122
|
+
/** A sentence or two for the list and for search engines. From the body when the post has none. */
|
|
123
|
+
excerpt: string;
|
|
124
|
+
body: string;
|
|
125
|
+
/** A picture for the list, and a larger one for the post's own page and for sharing. */
|
|
126
|
+
image: string | null;
|
|
127
|
+
imageLarge: string | null;
|
|
128
|
+
/** When it was published, as an ISO date. */
|
|
129
|
+
publishedAt: string | null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The address a visitor reaches this site at, for the sitemap, the feed and shared links. */
|
|
133
|
+
export function siteUrl(): string {
|
|
134
|
+
return (process.env.NEXT_PUBLIC_SITE_URL ?? process.env.SITE_URL ?? "http://localhost:3000").replace(/\/+$/, "");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const words = (html: string) => html.replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/�*39;/g, "'").replace(/\s+/g, " ").trim();
|
|
138
|
+
|
|
139
|
+
function picture(value: unknown, rendition?: string): string | null {
|
|
140
|
+
if (typeof value !== "string" || value === "") return null;
|
|
141
|
+
// A file uploaded through Nevela: fetched through this app, in the size asked for.
|
|
142
|
+
if (IMAGE_IS_UPLOAD) return fileUrl(value, rendition);
|
|
143
|
+
// A path kept in a plain column: a file on the Laravel app's public disk, or an address already.
|
|
144
|
+
if (/^https?:\/\//.test(value)) return value;
|
|
145
|
+
return `${new URL(API_URL).origin}/storage/${value.replace(/^\/+/, "")}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toPost(row: Record<string, unknown>): BlogPost {
|
|
149
|
+
const text = (key: string | null) => (key && typeof row[key] === "string" ? (row[key] as string) : "");
|
|
150
|
+
const body = text(FIELDS.body);
|
|
151
|
+
const excerpt = text(FIELDS.excerpt) || words(body).slice(0, 180);
|
|
152
|
+
return {
|
|
153
|
+
id: String(row.id),
|
|
154
|
+
title: text(FIELDS.title),
|
|
155
|
+
slug: String(row["__BY__"] ?? row.id),
|
|
156
|
+
excerpt,
|
|
157
|
+
body,
|
|
158
|
+
// "thumb" is the small rendition every image profile makes. One that was never made is answered with the picture itself.
|
|
159
|
+
image: picture(FIELDS.image ? row[FIELDS.image] : null, "thumb"),
|
|
160
|
+
imageLarge: picture(FIELDS.image ? row[FIELDS.image] : null),
|
|
161
|
+
publishedAt: text(FIELDS.published) || null,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** One page of published posts, newest first. */
|
|
166
|
+
export async function listPosts(page = 1, perPage = blog.perPage): Promise<{ posts: BlogPost[]; page: number; totalPages: number; total: number }> {
|
|
167
|
+
const response = await laravelPublic(`__SLUG__?sort=__SORT__&page=${page}&perPage=${perPage}`, { revalidate: FRESH_FOR, tags: TAGS });
|
|
168
|
+
if (!response.ok) return { posts: [], page, totalPages: 1, total: 0 };
|
|
169
|
+
const { data, meta } = response.body as { data: Record<string, unknown>[]; meta: { page: number; totalPages: number; total: number } };
|
|
170
|
+
return { posts: data.map(toPost), page: meta.page, totalPages: meta.totalPages, total: meta.total };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** One published post by its address, or null: a draft, one still to come and one that never was are all "not here". */
|
|
174
|
+
export async function getPost(slug: string): Promise<BlogPost | null> {
|
|
175
|
+
const response = await laravelPublic(`__SLUG__/${encodeURIComponent(slug)}`, { revalidate: FRESH_FOR, tags: TAGS });
|
|
176
|
+
return response.ok ? toPost(response.body as Record<string, unknown>) : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Every published post, newest first, for the sitemap and the feed. */
|
|
180
|
+
export async function allPosts(most = 1000): Promise<BlogPost[]> {
|
|
181
|
+
const posts: BlogPost[] = [];
|
|
182
|
+
for (let page = 1; posts.length < most; page++) {
|
|
183
|
+
const result = await listPosts(page, 100);
|
|
184
|
+
posts.push(...result.posts);
|
|
185
|
+
if (page >= result.totalPages || result.posts.length === 0) break;
|
|
186
|
+
}
|
|
187
|
+
return posts.slice(0, most);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** "11 October 2026" */
|
|
191
|
+
export function postDate(post: BlogPost): string {
|
|
192
|
+
return post.publishedAt ? new Intl.DateTimeFormat("en-GB", { dateStyle: "long" }).format(new Date(post.publishedAt)) : "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
TS;
|
|
196
|
+
|
|
197
|
+
private const INDEX = <<<'TSX'
|
|
198
|
+
import type { Metadata } from "next";
|
|
199
|
+
import Link from "next/link";
|
|
200
|
+
import { SiteHeader } from "@/components/marketing/site-header";
|
|
201
|
+
import { blog, listPosts, postDate } from "@/lib/blog";
|
|
202
|
+
|
|
203
|
+
export const metadata: Metadata = {
|
|
204
|
+
title: blog.title,
|
|
205
|
+
description: blog.description,
|
|
206
|
+
alternates: { types: { "application/rss+xml": `${blog.path}/feed.xml` } },
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** The blog: published __PLURAL__, newest first. */
|
|
210
|
+
export default async function BlogPage({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
|
|
211
|
+
const asked = Number.parseInt((await searchParams).page ?? "1", 10);
|
|
212
|
+
const { posts, page, totalPages } = await listPosts(Number.isInteger(asked) && asked > 0 ? asked : 1);
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<>
|
|
216
|
+
<SiteHeader />
|
|
217
|
+
<main className="mx-auto max-w-6xl px-6 py-16">
|
|
218
|
+
<header className="max-w-2xl">
|
|
219
|
+
<h1 className="text-4xl md:text-5xl">{blog.title}</h1>
|
|
220
|
+
<p className="mt-4 text-lg text-foreground-muted">{blog.description}</p>
|
|
221
|
+
</header>
|
|
222
|
+
|
|
223
|
+
{posts.length === 0 ? (
|
|
224
|
+
<p className="mt-16 text-foreground-muted">Nothing has been published yet.</p>
|
|
225
|
+
) : (
|
|
226
|
+
<ul className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
|
227
|
+
{posts.map((post) => (
|
|
228
|
+
<li key={post.id}>
|
|
229
|
+
<Link href={`${blog.path}/${post.slug}`} className="group flex h-full flex-col overflow-hidden rounded-[calc(var(--radius)+4px)] border border-border bg-surface hover:border-foreground/30">
|
|
230
|
+
{post.image && (
|
|
231
|
+
// eslint-disable-next-line @next/next/no-img-element -- already the right size: Laravel made this rendition when the picture was uploaded
|
|
232
|
+
<img src={post.image} alt="" loading="lazy" decoding="async" className="aspect-[16/9] w-full object-cover" />
|
|
233
|
+
)}
|
|
234
|
+
<div className="flex flex-1 flex-col gap-2 p-6">
|
|
235
|
+
{post.publishedAt && (
|
|
236
|
+
<time dateTime={post.publishedAt} className="text-xs text-foreground-muted">
|
|
237
|
+
{postDate(post)}
|
|
238
|
+
</time>
|
|
239
|
+
)}
|
|
240
|
+
<h2 className="text-xl text-balance group-hover:underline">{post.title}</h2>
|
|
241
|
+
<p className="line-clamp-3 text-sm leading-relaxed text-foreground-muted">{post.excerpt}</p>
|
|
242
|
+
</div>
|
|
243
|
+
</Link>
|
|
244
|
+
</li>
|
|
245
|
+
))}
|
|
246
|
+
</ul>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{totalPages > 1 && (
|
|
250
|
+
<nav aria-label="Pages" className="mt-12 flex items-center justify-between text-sm">
|
|
251
|
+
{page > 1 ? (
|
|
252
|
+
<Link href={page === 2 ? blog.path : `${blog.path}?page=${page - 1}`} className="hover:underline">
|
|
253
|
+
← Newer
|
|
254
|
+
</Link>
|
|
255
|
+
) : (
|
|
256
|
+
<span />
|
|
257
|
+
)}
|
|
258
|
+
<span className="text-foreground-muted">
|
|
259
|
+
Page {page} of {totalPages}
|
|
260
|
+
</span>
|
|
261
|
+
{page < totalPages ? (
|
|
262
|
+
<Link href={`${blog.path}?page=${page + 1}`} className="hover:underline">
|
|
263
|
+
Older →
|
|
264
|
+
</Link>
|
|
265
|
+
) : (
|
|
266
|
+
<span />
|
|
267
|
+
)}
|
|
268
|
+
</nav>
|
|
269
|
+
)}
|
|
270
|
+
</main>
|
|
271
|
+
</>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
TSX;
|
|
276
|
+
|
|
277
|
+
private const POST = <<<'TSX'
|
|
278
|
+
import type { Metadata } from "next";
|
|
279
|
+
import Link from "next/link";
|
|
280
|
+
import { notFound } from "next/navigation";
|
|
281
|
+
import { SiteHeader } from "@/components/marketing/site-header";
|
|
282
|
+
import { BODY_IS_HTML, blog, getPost, postDate, siteUrl } from "@/lib/blog";
|
|
283
|
+
|
|
284
|
+
type Props = { params: Promise<{ slug: string }> };
|
|
285
|
+
|
|
286
|
+
/** What a search engine and a shared link show: the title, the excerpt and the picture. */
|
|
287
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
288
|
+
const post = await getPost((await params).slug);
|
|
289
|
+
if (!post) return { title: "Not found" };
|
|
290
|
+
const image = post.imageLarge ? [new URL(post.imageLarge, siteUrl()).toString()] : undefined;
|
|
291
|
+
return {
|
|
292
|
+
title: post.title,
|
|
293
|
+
description: post.excerpt,
|
|
294
|
+
alternates: { canonical: `${siteUrl()}${blog.path}/${post.slug}` },
|
|
295
|
+
openGraph: { type: "article", title: post.title, description: post.excerpt, publishedTime: post.publishedAt ?? undefined, images: image },
|
|
296
|
+
twitter: { card: image ? "summary_large_image" : "summary", title: post.title, description: post.excerpt, images: image },
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** One post. A draft, one scheduled for later and one that never was are all a 404. */
|
|
301
|
+
export default async function PostPage({ params }: Props) {
|
|
302
|
+
const post = await getPost((await params).slug);
|
|
303
|
+
if (!post) notFound();
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<>
|
|
307
|
+
<SiteHeader />
|
|
308
|
+
<main className="mx-auto max-w-3xl px-6 py-16">
|
|
309
|
+
<Link href={blog.path} className="text-sm text-foreground-muted hover:text-foreground">
|
|
310
|
+
← {blog.title}
|
|
311
|
+
</Link>
|
|
312
|
+
<article className="mt-8">
|
|
313
|
+
<header>
|
|
314
|
+
{post.publishedAt && (
|
|
315
|
+
<time dateTime={post.publishedAt} className="text-sm text-foreground-muted">
|
|
316
|
+
{postDate(post)}
|
|
317
|
+
</time>
|
|
318
|
+
)}
|
|
319
|
+
<h1 className="mt-2 text-4xl text-balance md:text-5xl">{post.title}</h1>
|
|
320
|
+
</header>
|
|
321
|
+
{post.imageLarge && (
|
|
322
|
+
// eslint-disable-next-line @next/next/no-img-element -- optimised by Laravel when it was uploaded
|
|
323
|
+
<img src={post.imageLarge} alt="" className="mt-10 w-full rounded-[calc(var(--radius)+4px)] border border-border" />
|
|
324
|
+
)}
|
|
325
|
+
{BODY_IS_HTML ? (
|
|
326
|
+
// Laravel cleaned this HTML when the post was saved and again when it was read.
|
|
327
|
+
<div className="rich-text mt-10 text-lg" dangerouslySetInnerHTML={{ __html: post.body }} />
|
|
328
|
+
) : (
|
|
329
|
+
<div className="mt-10 text-lg leading-relaxed whitespace-pre-line">{post.body}</div>
|
|
330
|
+
)}
|
|
331
|
+
</article>
|
|
332
|
+
</main>
|
|
333
|
+
</>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
TSX;
|
|
338
|
+
|
|
339
|
+
private const FEED = <<<'TS'
|
|
340
|
+
import { blog, allPosts, siteUrl } from "@/lib/blog";
|
|
341
|
+
import { site } from "@/lib/site";
|
|
342
|
+
|
|
343
|
+
/** Rebuilt at most once a minute, like the pages. */
|
|
344
|
+
export const revalidate = 60;
|
|
345
|
+
|
|
346
|
+
const escape = (text: string) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
347
|
+
|
|
348
|
+
/** The blog as an RSS feed: the latest fifty published __PLURAL__. */
|
|
349
|
+
export async function GET() {
|
|
350
|
+
const posts = await allPosts(50);
|
|
351
|
+
const home = siteUrl();
|
|
352
|
+
const items = posts
|
|
353
|
+
.map((post) => {
|
|
354
|
+
const link = `${home}${blog.path}/${post.slug}`;
|
|
355
|
+
return [
|
|
356
|
+
" <item>",
|
|
357
|
+
` <title>${escape(post.title)}</title>`,
|
|
358
|
+
` <link>${escape(link)}</link>`,
|
|
359
|
+
` <guid isPermaLink="true">${escape(link)}</guid>`,
|
|
360
|
+
post.publishedAt ? ` <pubDate>${new Date(post.publishedAt).toUTCString()}</pubDate>` : "",
|
|
361
|
+
` <description>${escape(post.excerpt)}</description>`,
|
|
362
|
+
" </item>",
|
|
363
|
+
]
|
|
364
|
+
.filter(Boolean)
|
|
365
|
+
.join("\n");
|
|
366
|
+
})
|
|
367
|
+
.join("\n");
|
|
368
|
+
|
|
369
|
+
const xml = [
|
|
370
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
371
|
+
'<rss version="2.0">',
|
|
372
|
+
" <channel>",
|
|
373
|
+
` <title>${escape(`${site.name}: ${blog.title}`)}</title>`,
|
|
374
|
+
` <link>${escape(`${home}${blog.path}`)}</link>`,
|
|
375
|
+
` <description>${escape(blog.description)}</description>`,
|
|
376
|
+
items,
|
|
377
|
+
" </channel>",
|
|
378
|
+
"</rss>",
|
|
379
|
+
"",
|
|
380
|
+
].join("\n");
|
|
381
|
+
|
|
382
|
+
return new Response(xml, { headers: { "Content-Type": "application/rss+xml; charset=utf-8" } });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
TS;
|
|
386
|
+
|
|
387
|
+
private const SITEMAP = <<<'TS'
|
|
388
|
+
import type { MetadataRoute } from "next";
|
|
389
|
+
import { allPosts, blog, siteUrl } from "@/lib/blog";
|
|
390
|
+
|
|
391
|
+
/** Rebuilt at most once a minute, so a post is in it soon after it is published. */
|
|
392
|
+
export const revalidate = 60;
|
|
393
|
+
|
|
394
|
+
/** The site's pages, and every published post. Add your own pages to the first list. */
|
|
395
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
396
|
+
const home = siteUrl();
|
|
397
|
+
const posts = await allPosts();
|
|
398
|
+
return [
|
|
399
|
+
{ url: home, changeFrequency: "monthly", priority: 1 },
|
|
400
|
+
{ url: `${home}${blog.path}`, changeFrequency: "daily", priority: 0.8 },
|
|
401
|
+
...posts.map((post) => ({
|
|
402
|
+
url: `${home}${blog.path}/${post.slug}`,
|
|
403
|
+
lastModified: post.publishedAt ? new Date(post.publishedAt) : undefined,
|
|
404
|
+
})),
|
|
405
|
+
];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
TS;
|
|
409
|
+
}
|