create-nevela 0.7.1 → 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 (87) hide show
  1. package/index.mjs +25 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +66 -3
  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/Access/Permissions.php +36 -5
  9. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  10. package/template/laravel/src/Concerns/Trashable.php +23 -0
  11. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  12. package/template/laravel/src/Console/BlogCommand.php +254 -0
  13. package/template/laravel/src/Console/DevCommand.php +9 -0
  14. package/template/laravel/src/Console/DigestCommand.php +28 -0
  15. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  16. package/template/laravel/src/Console/GenerateCommand.php +38 -3
  17. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  18. package/template/laravel/src/Console/SeedCommand.php +3 -1
  19. package/template/laravel/src/Console/SetupCommand.php +15 -2
  20. package/template/laravel/src/Console/StatusCommand.php +14 -0
  21. package/template/laravel/src/Console/TrashCommand.php +17 -0
  22. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  23. package/template/laravel/src/Forms/Confirmation.php +88 -0
  24. package/template/laravel/src/Forms/Disposable.php +74 -0
  25. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  26. package/template/laravel/src/Forms/FormGuard.php +359 -0
  27. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  28. package/template/laravel/src/Forms/PublicMail.php +217 -0
  29. package/template/laravel/src/Forms/Refusal.php +50 -0
  30. package/template/laravel/src/Generator/BlogPages.php +409 -0
  31. package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
  32. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  33. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  34. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  35. package/template/laravel/src/Http/ActionController.php +223 -0
  36. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  37. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  38. package/template/laravel/src/Http/FormsController.php +45 -0
  39. package/template/laravel/src/Http/PublicController.php +294 -0
  40. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  41. package/template/laravel/src/Http/ResourceRequest.php +193 -4
  42. package/template/laravel/src/Http/TrashController.php +19 -2
  43. package/template/laravel/src/Http/UploadController.php +42 -1
  44. package/template/laravel/src/Media/Uploads.php +111 -0
  45. package/template/laravel/src/Nevela.php +83 -2
  46. package/template/laravel/src/NevelaServiceProvider.php +27 -2
  47. package/template/laravel/src/Support/Action.php +168 -0
  48. package/template/laravel/src/Support/Adoption.php +352 -0
  49. package/template/laravel/src/Support/Descriptor.php +83 -4
  50. package/template/laravel/src/Support/Fake.php +15 -1
  51. package/template/laravel/src/Support/Field.php +91 -3
  52. package/template/laravel/src/Support/Html.php +185 -0
  53. package/template/laravel/src/Support/Key.php +57 -0
  54. package/template/laravel/src/Support/ListQuery.php +29 -1
  55. package/template/laravel/src/Support/PublicAccess.php +208 -0
  56. package/template/laravel/src/Support/Slug.php +43 -0
  57. package/template/laravel/src/Trash/Trash.php +3 -1
  58. package/template/web/.env.example +4 -0
  59. package/template/web/app/api/[...path]/route.ts +42 -9
  60. package/template/web/app/confirm/page.tsx +51 -0
  61. package/template/web/app/dashboard/actions.ts +48 -0
  62. package/template/web/app/globals.css +46 -0
  63. package/template/web/components/access/role-form.tsx +40 -5
  64. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  65. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  66. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  67. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  68. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  69. package/template/web/components/dashboard/form-activity.tsx +53 -0
  70. package/template/web/components/dashboard/record-actions.tsx +33 -4
  71. package/template/web/components/dashboard/record-detail.tsx +10 -2
  72. package/template/web/components/dashboard/resource-action.tsx +281 -0
  73. package/template/web/components/dashboard/resource-form.tsx +19 -2
  74. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  75. package/template/web/components/dashboard/resource-table.tsx +46 -4
  76. package/template/web/components/dashboard/row-actions.tsx +56 -2
  77. package/template/web/components/dashboard/table-selection.tsx +19 -1
  78. package/template/web/components/marketing/site-header.tsx +9 -2
  79. package/template/web/components/public-form-guard.tsx +125 -0
  80. package/template/web/hooks/use-public-form.ts +175 -0
  81. package/template/web/lib/access.ts +3 -1
  82. package/template/web/lib/dashboard.ts +10 -0
  83. package/template/web/lib/extras.ts +105 -0
  84. package/template/web/lib/files.ts +14 -0
  85. package/template/web/lib/laravel.ts +60 -0
  86. package/template/web/lib/rich.ts +79 -0
  87. package/template/web/package.json +1 -1
@@ -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
+ }