create-nevela 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/index.mjs +24 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +41 -0
  5. package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
  6. package/template/laravel/resources/disposable-domains.txt +9880 -0
  7. package/template/laravel/src/Access/ClosedAccounts.php +2 -1
  8. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  9. package/template/laravel/src/Console/BlogCommand.php +254 -0
  10. package/template/laravel/src/Console/DevCommand.php +9 -0
  11. package/template/laravel/src/Console/DigestCommand.php +28 -0
  12. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  13. package/template/laravel/src/Console/GenerateCommand.php +25 -0
  14. package/template/laravel/src/Console/SetupCommand.php +15 -2
  15. package/template/laravel/src/Console/StatusCommand.php +14 -0
  16. package/template/laravel/src/Console/TrashCommand.php +9 -0
  17. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  18. package/template/laravel/src/Forms/Confirmation.php +88 -0
  19. package/template/laravel/src/Forms/Disposable.php +74 -0
  20. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  21. package/template/laravel/src/Forms/FormGuard.php +359 -0
  22. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  23. package/template/laravel/src/Forms/PublicMail.php +217 -0
  24. package/template/laravel/src/Forms/Refusal.php +50 -0
  25. package/template/laravel/src/Generator/BlogPages.php +409 -0
  26. package/template/laravel/src/Generator/ResourceGenerator.php +131 -7
  27. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  28. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  29. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  30. package/template/laravel/src/Http/ActionController.php +2 -1
  31. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  32. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  33. package/template/laravel/src/Http/FormsController.php +45 -0
  34. package/template/laravel/src/Http/PublicController.php +88 -4
  35. package/template/laravel/src/Http/ResourceRequest.php +43 -3
  36. package/template/laravel/src/Http/TrashController.php +2 -1
  37. package/template/laravel/src/Http/UploadController.php +2 -2
  38. package/template/laravel/src/Nevela.php +30 -1
  39. package/template/laravel/src/NevelaServiceProvider.php +11 -2
  40. package/template/laravel/src/Support/Descriptor.php +24 -0
  41. package/template/laravel/src/Support/Field.php +17 -0
  42. package/template/laravel/src/Support/Key.php +57 -0
  43. package/template/laravel/src/Support/ListQuery.php +29 -1
  44. package/template/laravel/src/Support/PublicAccess.php +75 -0
  45. package/template/laravel/src/Support/Slug.php +43 -0
  46. package/template/laravel/src/Trash/Trash.php +3 -1
  47. package/template/web/.env.example +4 -0
  48. package/template/web/app/confirm/page.tsx +51 -0
  49. package/template/web/app/dashboard/actions.ts +15 -1
  50. package/template/web/components/dashboard/form-activity.tsx +53 -0
  51. package/template/web/components/dashboard/record-actions.tsx +29 -5
  52. package/template/web/components/dashboard/record-detail.tsx +2 -1
  53. package/template/web/components/dashboard/resource-form.tsx +19 -2
  54. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  55. package/template/web/components/dashboard/resource-table.tsx +32 -3
  56. package/template/web/components/dashboard/row-actions.tsx +33 -3
  57. package/template/web/components/marketing/site-header.tsx +9 -2
  58. package/template/web/components/public-form-guard.tsx +125 -0
  59. package/template/web/hooks/use-public-form.ts +175 -0
  60. package/template/web/lib/extras.ts +39 -0
  61. 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(/&nbsp;/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#0*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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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
+ }