@goodfellow-cms/next 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,42 +3,72 @@
3
3
  // writes the calendar files of collections of events, collections' feeds, the pages at redirects' old addresses and Cloudflare Pages' _redirects, and the search index for sites with a search block,
4
4
  // copies the site's Google Fonts into the export, builds plugins' admin frames and runs their build steps, starts every page with the admin address's guard for a site with one, and reports what automated checks find on the exported pages. `index` only writes the index.
5
5
  // `goodfellow-next update` updates Goodfellow, as `goodfellow update` does for other sites.
6
- // `goodfellow-next task [--out <file>]` runs one of a plugin's tasks for the admin panel, as `goodfellow task` does.
6
+ // `goodfellow-next task [--describe] [--out <file>]` runs one of a plugin's tasks for the admin panel, or with
7
+ // --describe only writes it down for a job with no secrets, as `goodfellow task` does.
7
8
  // `goodfellow-next due [--since <time>] [--output <file>]` says whether a scheduled change has come due, as `goodfellow due` does.
8
9
  // `goodfellow-next nightly [--mode <mode>]` says whether tonight's build is needed, as `goodfellow nightly` does.
9
10
  // `goodfellow-next content link|copy [folder]` puts a content repository's files in place, for a site whose
10
11
  // content is kept in a repository of its own.
11
12
  // `goodfellow-next import wordpress <file or address> [--dry-run]` imports a WordPress site, as `goodfellow import` does.
12
- // `goodfellow-next export wordpress [file]` writes the site as a WordPress export file, as `goodfellow export` does.
13
+ // `goodfellow-next import markdown <folder> [--dry-run]` imports a folder of Markdown files, as `goodfellow import` does.
14
+ // `goodfellow-next export wordpress [file]` writes the site as a WordPress export file, and `goodfellow-next export
15
+ // markdown [folder] [--scheduled] [--links site|files]` as a folder of Markdown files, as `goodfellow export` does.
16
+ // `goodfellow-next export site [file]` and `goodfellow-next import site <zip or folder>` move content between
17
+ // Goodfellow sites, as `goodfellow export site` and `goodfellow import site` do.
13
18
  // `goodfellow-next blocks add <item> [--registry <folder>] [--native <app>]` makes a block of a shadcn item, as
14
19
  // `goodfellow blocks add` does, and with --native its version for an Expo app.
15
20
  // `goodfellow-next admin add <address>|check|remove` sets the admin address, and `goodfellow-next broker use <address>|remove`
16
- // the token broker, in goodfellow.config.tsx, as `goodfellow admin` and `goodfellow broker` do.
21
+ // the token broker, and `goodfellow-next sign-in service <address>|client-id <id>|remove` how editors sign in, in
22
+ // goodfellow.config.tsx, as `goodfellow admin`, `goodfellow broker` and `goodfellow sign-in` do.
17
23
  import { existsSync, readFileSync } from "node:fs";
18
24
  import { rm } from "node:fs/promises";
19
- import { relative, resolve } from "node:path";
25
+ import { join, relative, resolve } from "node:path";
20
26
  import { parseArgs } from "node:util";
21
- import { buildNotes, CLOUDFLARE_REDIRECTS_LIMIT, configAdmin, formatUpdateResult } from "@goodfellow-cms/core";
27
+ import {
28
+ buildNotes,
29
+ CLOUDFLARE_REDIRECTS_LIMIT,
30
+ configAdmin,
31
+ formatUpdateResult,
32
+ SiteImportError,
33
+ } from "@goodfellow-cms/core";
22
34
  import { reportSiteProblems } from "@goodfellow-cms/core/accessibility";
23
35
  import {
24
36
  AdminSetupError,
25
37
  BrokerSetupError,
26
38
  ContentRepositoryError,
39
+ checkEarlyTheme,
27
40
  checkSiteAdminAddress,
28
41
  contentRepositoryProblem,
42
+ exportMarkdown,
43
+ exportSite,
29
44
  exportWordPress,
30
45
  formatAdminResult,
46
+ formatAdminText,
47
+ formatMarkdownExport,
48
+ formatMarkdownImport,
49
+ formatSiteExport,
50
+ formatSiteImport,
31
51
  formatWordPressExport,
32
52
  formatWordPressImport,
53
+ importMarkdown,
54
+ importSite,
33
55
  importWordPress,
56
+ MarkdownExportError,
57
+ MarkdownFolderError,
58
+ markdownFolderChoices,
34
59
  nightlyCheck,
35
60
  reportDue,
36
61
  reportNightly,
62
+ SignInSetupError,
63
+ SiteTransferError,
37
64
  scheduleDue,
38
65
  setAdminAddress,
39
66
  setBrokerAddress,
67
+ setSignIn,
40
68
  setUpContentRepository,
41
69
  update,
70
+ writeAdminText,
71
+ writeTaskDescription,
42
72
  } from "@goodfellow-cms/core/node";
43
73
  import { writePagesSearchIndex, writeSearchIndex } from "@goodfellow-cms/core/search-index";
44
74
  import { addShadcnBlock, blockWorks, formatShadcnBlock, ShadcnBlockError } from "@goodfellow-cms/core/shadcn";
@@ -49,9 +79,12 @@ import {
49
79
  builtPages,
50
80
  checkPages,
51
81
  exportedPages,
82
+ exportTrailingSlash,
52
83
  fixSegmentFiles,
53
84
  guardExport,
85
+ loadTaskConfig,
54
86
  NEXT_SITE_FILES,
87
+ nextDistDir,
55
88
  writeBuildRecordFile,
56
89
  writeCalendarFiles,
57
90
  writeContentApi,
@@ -71,6 +104,19 @@ const CONFIG_FILES = ["goodfellow.config.tsx", "goodfellow.config.ts", "goodfell
71
104
  // A full-stack site (withGoodfellow(config, { server: true })): Next.js serves its pages and sends redirects
72
105
  // itself, so the calendar files, feeds and the search index are written into public/, which `next start` and hosts
73
106
  // serve as static files, as they are for an export.
107
+ // A theme installed before it was three days old is used only once its provenance checks out, whatever the
108
+ // admin panel said, since a browser can lie.
109
+ if (command === "finish") {
110
+ // Pages' full addresses end as Next.js served the pages, unless where the site is built says otherwise.
111
+ process.env.GOODFELLOW_TRAILING_SLASH ||= String(await exportTrailingSlash());
112
+ try {
113
+ const theme = await checkEarlyTheme(process.cwd());
114
+ if (theme) console.log(`${theme.name} ${theme.release} was installed early: its provenance checks out.`);
115
+ } catch (error) {
116
+ console.error(error instanceof Error ? error.message : error);
117
+ process.exit(1);
118
+ }
119
+ }
74
120
  if (command === "finish" && dir === "--server") {
75
121
  // Next.js renders a full-stack site's pages as it serves them, so nothing can put a guard first in each.
76
122
  const config = CONFIG_FILES.find((file) => existsSync(file));
@@ -82,13 +128,15 @@ if (command === "finish" && dir === "--server") {
82
128
  }
83
129
  for (const folder of await buildFileFolders()) await rm(`public/${folder}`, { recursive: true, force: true });
84
130
  // The editor's frames' page, which next start serves as it was rendered.
85
- await writeEditorPolicy(".next/server/app");
131
+ await writeEditorPolicy(join(await nextDistDir(), "server/app"));
86
132
  const calendars = await writeCalendarFiles("public");
87
133
  if (calendars > 0) console.log(`Wrote ${calendars} calendar files, in public/calendars`);
88
134
  const feeds = await writeFeedFiles("public");
89
135
  if (feeds > 0) console.log(`Wrote ${feeds} feeds, in public/feeds`);
90
136
  const api = await writeContentApi("public");
91
137
  if (api !== undefined) console.log(`Wrote the content API, with ${api} pages, in public/api`);
138
+ // The admin panel's text in the site's own languages, for the sign-in screen, which can't read the site yet.
139
+ for (const line of formatAdminText(await writeAdminText("public"))) console.log(line);
92
140
  // Before the pages are read, so the search index and the checks see each page's language.
93
141
  const languages = await writePageLanguages(undefined);
94
142
  if (languages > 0) console.log(`Marked ${languages} pages with their language`);
@@ -119,8 +167,20 @@ if (command === "update") {
119
167
  process.exit(result.state === "failed" ? 1 : 0);
120
168
  }
121
169
  if (command === "task") {
122
- const { values } = parseArgs({ args: process.argv.slice(3), options: { out: { type: "string" } } });
170
+ const { values } = parseArgs({
171
+ args: process.argv.slice(3),
172
+ options: { out: { type: "string" }, describe: { type: "boolean" } },
173
+ });
123
174
  const env = process.env;
175
+ if (values.describe) {
176
+ await writeTaskDescription(
177
+ ".",
178
+ { module: new URL("../dist/export.js", import.meta.url).href, export: "loadTaskConfig" },
179
+ { plugin: env.GOODFELLOW_TASK_PLUGIN ?? "", task: env.GOODFELLOW_TASK_NAME ?? "" },
180
+ values.out,
181
+ );
182
+ process.exit(0);
183
+ }
124
184
  await writeTaskResult(
125
185
  ".",
126
186
  {
@@ -173,9 +233,24 @@ if (command === "content") {
173
233
  }
174
234
  }
175
235
  }
176
- if (command === "admin" || command === "broker") {
236
+ if (command === "admin" || command === "broker" || command === "sign-in") {
177
237
  const [action, argument] = process.argv.slice(3);
178
238
  try {
239
+ if (
240
+ command === "sign-in" &&
241
+ (((action === "service" || action === "client-id") && argument) || action === "remove")
242
+ ) {
243
+ const { host, value } = await setSignIn(".", action === "remove" ? undefined : argument);
244
+ const name = host === "github" ? "GitHub" : host === "gitlab" ? "GitLab" : "Bitbucket";
245
+ console.log(
246
+ !value
247
+ ? "Editors sign in with a token once the site has rebuilt with goodfellow.config.tsx."
248
+ : host === "github"
249
+ ? `Editors can choose Sign in with GitHub, through ${value}, once the site has rebuilt with goodfellow.config.tsx.`
250
+ : `Editors can choose Sign in with ${name} once the site has rebuilt with goodfellow.config.tsx.`,
251
+ );
252
+ process.exit(0);
253
+ }
179
254
  if (command === "admin" && ((action === "add" && argument) || action === "check" || action === "remove")) {
180
255
  const result =
181
256
  action === "check"
@@ -195,7 +270,9 @@ if (command === "admin" || command === "broker") {
195
270
  process.exit(0);
196
271
  }
197
272
  } catch (error) {
198
- if (!(error instanceof AdminSetupError || error instanceof BrokerSetupError)) throw error;
273
+ if (!(error instanceof AdminSetupError || error instanceof BrokerSetupError || error instanceof SignInSetupError)) {
274
+ throw error;
275
+ }
199
276
  console.error(error.message);
200
277
  process.exit(1);
201
278
  }
@@ -204,10 +281,73 @@ if (command === "import") {
204
281
  const { positionals, values } = parseArgs({
205
282
  args: process.argv.slice(3),
206
283
  allowPositionals: true,
207
- options: { "dry-run": { type: "boolean" } },
284
+ options: {
285
+ "dry-run": { type: "boolean" },
286
+ pages: { type: "string", multiple: true },
287
+ collection: { type: "string", multiple: true },
288
+ "replace-home": { type: "boolean" },
289
+ "replace-pages": { type: "boolean" },
290
+ drafts: { type: "boolean" },
291
+ menu: { type: "boolean" },
292
+ "keep-fields": { type: "boolean" },
293
+ take: { type: "string" },
294
+ replace: { type: "boolean" },
295
+ menus: { type: "boolean" },
296
+ settings: { type: "boolean" },
297
+ layout: { type: "boolean" },
298
+ redirects: { type: "boolean" },
299
+ "without-missing-blocks": { type: "boolean" },
300
+ },
208
301
  });
209
302
  const [kind, from] = positionals;
210
303
  const problem = contentRepositoryProblem(".");
304
+ if (kind === "site" && from && !problem) {
305
+ if (values.take !== undefined && values.take !== "site" && values.take !== "export") {
306
+ console.error('Say "--take site" or "--take export".');
307
+ process.exit(1);
308
+ }
309
+ try {
310
+ const config = await loadTaskConfig(resolve("."));
311
+ const result = await importSite(".", from, {
312
+ blocks: Object.keys(config.blocks),
313
+ replace: values.replace,
314
+ menus: values.menus,
315
+ settings: values.settings,
316
+ layout: values.layout,
317
+ redirects: values.redirects,
318
+ withoutMissingBlocks: values["without-missing-blocks"],
319
+ ...(values.take && { take: values.take }),
320
+ dryRun: values["dry-run"],
321
+ log: (line) => console.log(line),
322
+ });
323
+ console.log(formatSiteImport(result));
324
+ process.exit(0);
325
+ } catch (error) {
326
+ if (!(error instanceof SiteTransferError || error instanceof SiteImportError)) throw error;
327
+ console.error(error.message);
328
+ process.exit(1);
329
+ }
330
+ }
331
+ if (kind === "markdown" && from && !problem) {
332
+ try {
333
+ const result = await importMarkdown(".", from, {
334
+ folders: markdownFolderChoices(values.pages, values.collection),
335
+ replaceHome: values["replace-home"],
336
+ replacePages: values["replace-pages"],
337
+ drafts: values.drafts,
338
+ menu: values.menu,
339
+ keepFields: values["keep-fields"],
340
+ dryRun: values["dry-run"],
341
+ log: (line) => console.log(line),
342
+ });
343
+ console.log(formatMarkdownImport(result));
344
+ process.exit(0);
345
+ } catch (error) {
346
+ if (!(error instanceof MarkdownFolderError)) throw error;
347
+ console.error(error.message);
348
+ process.exit(1);
349
+ }
350
+ }
211
351
  if (kind === "wordpress" && from && !problem) {
212
352
  try {
213
353
  const result = await importWordPress(".", from, { dryRun: values["dry-run"], log: (line) => console.log(line) });
@@ -225,7 +365,12 @@ if (command === "import") {
225
365
  }
226
366
  }
227
367
  if (command === "export") {
228
- const [kind, file] = process.argv.slice(3);
368
+ const { positionals, values } = parseArgs({
369
+ args: process.argv.slice(3),
370
+ allowPositionals: true,
371
+ options: { scheduled: { type: "boolean" }, links: { type: "string" } },
372
+ });
373
+ const [kind, file] = positionals;
229
374
  const problem = contentRepositoryProblem(".");
230
375
  if (problem) {
231
376
  console.error(problem);
@@ -235,6 +380,34 @@ if (command === "export") {
235
380
  console.log(formatWordPressExport(await exportWordPress(".", file)));
236
381
  process.exit(0);
237
382
  }
383
+ if (kind === "site") {
384
+ try {
385
+ console.log(formatSiteExport(await exportSite(".", file)));
386
+ process.exit(0);
387
+ } catch (error) {
388
+ if (!(error instanceof SiteTransferError)) throw error;
389
+ console.error(error.message);
390
+ process.exit(1);
391
+ }
392
+ }
393
+ if (kind === "markdown") {
394
+ if (values.links !== undefined && values.links !== "site" && values.links !== "files") {
395
+ console.error('Say "--links site" or "--links files".');
396
+ process.exit(1);
397
+ }
398
+ try {
399
+ const result = await exportMarkdown(".", file, {
400
+ scheduled: values.scheduled === true,
401
+ ...(values.links && { links: values.links }),
402
+ });
403
+ console.log(formatMarkdownExport(result));
404
+ process.exit(0);
405
+ } catch (error) {
406
+ if (!(error instanceof MarkdownExportError)) throw error;
407
+ console.error(error.message);
408
+ process.exit(1);
409
+ }
410
+ }
238
411
  }
239
412
  if (command === "blocks") {
240
413
  const { positionals, values } = parseArgs({
@@ -272,10 +445,17 @@ if (command !== "finish" && command !== "index") {
272
445
  " goodfellow-next nightly [--mode changed|always|off] [--output <file>]",
273
446
  " goodfellow-next content link|copy [folder]",
274
447
  " goodfellow-next import wordpress <file or address> [--dry-run]",
448
+ " goodfellow-next import markdown <folder> [--dry-run] [--pages <folder>] [--collection <folder>]",
449
+ " [--replace-home] [--replace-pages] [--drafts] [--menu] [--keep-fields]",
275
450
  " goodfellow-next export wordpress [file]",
451
+ " goodfellow-next export markdown [folder] [--scheduled] [--links site|files]",
452
+ " goodfellow-next export site [file]",
453
+ " goodfellow-next import site <zip or folder> [--dry-run] [--take site|export] [--replace]",
454
+ " [--menus] [--settings] [--layout] [--redirects] [--without-missing-blocks]",
276
455
  " goodfellow-next blocks add <item> [--registry <folder>] [--native <app>]",
277
456
  " goodfellow-next admin add <address> | check | remove",
278
457
  " goodfellow-next broker use <address> | remove",
458
+ " goodfellow-next sign-in service <address> | client-id <id> | remove",
279
459
  "",
280
460
  "finish Gets the exported site in folder (default: out) ready to publish: puts the files",
281
461
  " Next.js uses to load pages ahead where browsers look for them, writes calendar files",
@@ -301,9 +481,17 @@ if (command !== "finish" && command !== "index") {
301
481
  " or copies them from it, as builds do.",
302
482
  "import Brings a WordPress site's pages, posts, categories, tags, menus and media into",
303
483
  " this site, from its export file (Tools → Export) or its address. With --dry-run,",
304
- " says what it would bring across and changes nothing.",
484
+ " says what it would bring across and changes nothing. import markdown brings a folder",
485
+ " of Markdown files in: the files at the top as pages, each folder as a collection,",
486
+ " unless --pages or --collection names it (. for the top). import site brings another",
487
+ " Goodfellow site's export in: its pages, items, synced blocks and media, keeping this",
488
+ " site's version of what both have unless --take export; --menus, --settings, --layout",
489
+ " and --redirects bring those too, and --replace makes this site's content the export's.",
305
490
  "export Writes this site as a WordPress export file (default: named after the site),",
306
- " which WordPress imports with Tools → Import → WordPress.",
491
+ " which WordPress imports with Tools → Import → WordPress, or as a folder of Markdown",
492
+ " files, for Hugo, Jekyll, Eleventy, Astro and notes apps (--scheduled also exports what",
493
+ " isn't shown yet, as drafts; --links files links to the files rather than the addresses).",
494
+ " export site writes this site's content and media as a zip for another Goodfellow site.",
307
495
  "blocks Installs a shadcn registry item (button, @acme/hero, or an item's address) with",
308
496
  " shadcn's own CLI, makes a block of its component, with fields from its props, and",
309
497
  " says what it couldn't do. With --registry, writes the block into the block registry",
@@ -314,6 +502,9 @@ if (command !== "finish" && command !== "index") {
314
502
  " it, then starts the admin panel only there; remove stops using it.",
315
503
  "broker The site's token broker in goodfellow.config.tsx: use signs editors in there once",
316
504
  " it's online; remove stops using it.",
505
+ "sign-in How editors sign in, in goodfellow.config.tsx: service names a GitHub site's sign-in",
506
+ " service, client-id a GitLab site's OAuth application's ID or a Bitbucket site's OAuth",
507
+ " consumer's Key; remove takes either out.",
317
508
  ].join("\n"),
318
509
  );
319
510
  process.exit(1);
@@ -330,6 +521,7 @@ if (command === "finish") {
330
521
  if (feeds > 0) console.log(`Wrote ${feeds} feeds`);
331
522
  const api = await writeContentApi(dir);
332
523
  if (api !== undefined) console.log(`Wrote the content API, with ${api} pages, in api/`);
524
+ for (const line of formatAdminText(await writeAdminText(dir))) console.log(line);
333
525
  const languages = await writePageLanguages(dir);
334
526
  if (languages > 0) console.log(`Marked ${languages} pages with their language`);
335
527
  // Before the redirects' pages, which aren't the site's.
package/dist/admin.js CHANGED
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { n as EDITOR_PAGE, t as ADMIN_PAGE } from "./site-files-BeLnLxCl.js";
2
+ import { n as EDITOR_PAGE, t as ADMIN_PAGE } from "./site-files-Cmu3ovIs.js";
3
3
  import { ADMIN_POLICY, DEV_EDITOR_PREFIX } from "@goodfellow-cms/core";
4
4
  import { useEffect, useState } from "react";
5
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
@@ -16,6 +16,13 @@ export declare function NextImage({ src, alt, width, height, loading, ...props }
16
16
  export declare function CurrentMenuLinks({ basePath }: {
17
17
  basePath?: string;
18
18
  }): null;
19
+ /**
20
+ * Counts each page visitors move to with GoatCounter, whose script counts only
21
+ * the page it loads on. Plausible's and Umami's scripts follow Next.js's
22
+ * navigations themselves. Does nothing until the script has loaded, which on a
23
+ * site that asks visitors first is once they allow statistics.
24
+ */
25
+ export declare function GoatCounterNavigation(): null;
19
26
  /**
20
27
  * Opens the Search box's results with Next.js's navigation, and loads them
21
28
  * ahead when they're pointed at or reached with the keyboard, as `next/link`
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { CurrentPathProvider } from "@goodfellow-cms/react";
3
3
  import { usePathname, useRouter } from "next/navigation";
4
- import { useEffect } from "react";
4
+ import { useEffect, useRef } from "react";
5
5
  import Image from "next/image";
6
6
  import Link from "next/link";
7
7
  import { jsx } from "react/jsx-runtime";
@@ -51,6 +51,24 @@ function CurrentMenuLinks({ basePath = "" }) {
51
51
  return null;
52
52
  }
53
53
  /**
54
+ * Counts each page visitors move to with GoatCounter, whose script counts only
55
+ * the page it loads on. Plausible's and Umami's scripts follow Next.js's
56
+ * navigations themselves. Does nothing until the script has loaded, which on a
57
+ * site that asks visitors first is once they allow statistics.
58
+ */
59
+ function GoatCounterNavigation() {
60
+ const pathname = usePathname();
61
+ const first = useRef(true);
62
+ useEffect(() => {
63
+ if (first.current) {
64
+ first.current = false;
65
+ return;
66
+ }
67
+ window.goatcounter?.count?.({ path: location.pathname + location.search });
68
+ }, [pathname]);
69
+ return null;
70
+ }
71
+ /**
54
72
  * Opens the Search box's results with Next.js's navigation, and loads them
55
73
  * ahead when they're pointed at or reached with the keyboard, as `next/link`
56
74
  * does. The Search box writes its results itself (`searchScript()`), as plain
@@ -117,4 +135,4 @@ function ByLanguage({ variants }) {
117
135
  });
118
136
  }
119
137
  //#endregion
120
- export { ByLanguage, CurrentMenuLinks, NextImage, NextLink, PageLanguage, SearchResultLinks };
138
+ export { ByLanguage, CurrentMenuLinks, GoatCounterNavigation, NextImage, NextLink, PageLanguage, SearchResultLinks };
package/dist/config.d.ts CHANGED
@@ -11,6 +11,14 @@ export interface GoodfellowNextOptions {
11
11
  */
12
12
  server?: boolean;
13
13
  }
14
+ /**
15
+ * A redirect's target as Next.js's destination: Next.js reads `:name`, `*`,
16
+ * `+`, `(`, `)`, `{` and `}` in its path and hash as a pattern, which would
17
+ * fail the build (`/a/:b` names a value the source doesn't have), so they're
18
+ * percent-encoded there, which browsers and servers read as the same address.
19
+ * Next.js escapes its query values itself.
20
+ */
21
+ export declare function redirectDestination(to: string): string;
14
22
  /**
15
23
  * Sets up Next.js for a Goodfellow site, in `next.config.ts`:
16
24
  * `export default withGoodfellow({ ... })`.
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
- import { n as IMAGE_SIZES_MARK, t as IMAGE_SIZES_ENV } from "./image-sizes-mark-Ctz9p44g.js";
2
- import { CALENDARS_DIR, FEEDS_DIR, allPages, brokerOrigin, buildNotes, configAdmin, configBroker, loadSiteContent, normalizeBase, redirectFrom, redirectProblems, shownContent, vercelAdminRedirects, vercelHeaders } from "@goodfellow-cms/core";
3
- import { BUILD_DATES_DIR, BUILD_DATES_ENV, DEV_API_PREFIX, builtSiteSource, contentRepositoryProblem, handleDevApi, hostFonts, isLocalOrigin, localFileStore, localRegistryDir } from "@goodfellow-cms/core/node";
1
+ import { n as IMAGE_SIZES_ENV, r as IMAGE_SIZES_MARK, t as DIST_DIR_RECORD } from "./image-sizes-mark-BOE7ObB9.js";
2
+ import { CALENDARS_DIR, FEEDS_DIR, TRAILING_SLASH_VARIABLE, allPages, brokerOrigin, buildNotes, configAdmin, configBroker, loadSiteContent, normalizeBase, redirectFrom, redirectProblems, shownContent, vercelAdminRedirects, vercelHeaders } from "@goodfellow-cms/core";
3
+ import { BUILD_DATES_DIR, BUILD_DATES_ENV, DEV_API_PREFIX, builtSiteSource, contentRepositoryProblem, handleDevApi, hostFonts, isAllowedDevHost, isLocalOrigin, localFileStore, localRegistryDir } from "@goodfellow-cms/core/node";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { mkdir, rm, writeFile } from "node:fs/promises";
6
6
  import { createServer } from "node:http";
@@ -71,6 +71,11 @@ function startDevApi(root) {
71
71
  server: "next-dev"
72
72
  };
73
73
  const server = createServer(async (req, res) => {
74
+ const forwarded = req.headers["x-forwarded-host"];
75
+ if (!isAllowedDevHost(req.headers.host) || forwarded !== void 0 && !isAllowedDevHost(String(forwarded))) {
76
+ res.writeHead(403).end();
77
+ return;
78
+ }
74
79
  if (await (await editor).serve(req, res)) return;
75
80
  if (!await handleDevApi(store, req, res, {
76
81
  trustOrigin: isLocalOrigin,
@@ -114,6 +119,25 @@ function redirectSource(path) {
114
119
  return encodeURI(path).replace(/[()[\]{}+*?:!]/g, "\\$&");
115
120
  }
116
121
  /**
122
+ * A redirect's target as Next.js's destination: Next.js reads `:name`, `*`,
123
+ * `+`, `(`, `)`, `{` and `}` in its path and hash as a pattern, which would
124
+ * fail the build (`/a/:b` names a value the source doesn't have), so they're
125
+ * percent-encoded there, which browsers and servers read as the same address.
126
+ * Next.js escapes its query values itself.
127
+ */
128
+ function redirectDestination(to) {
129
+ const target = to.trim();
130
+ const origin = /^https:\/\/[^/?#]*/i.exec(target)?.[0] ?? "";
131
+ const rest = target.slice(origin.length);
132
+ const query = rest.indexOf("?");
133
+ const hash = rest.indexOf("#");
134
+ const encode = (part) => part.replace(/[:*+(){}]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
135
+ const pathEnd = [query, hash].filter((index) => index >= 0).reduce((a, b) => Math.min(a, b), rest.length);
136
+ const search = query >= 0 && (hash < 0 || query < hash) ? rest.slice(query, hash > query ? hash : void 0) : "";
137
+ const fragment = hash >= 0 ? rest.slice(hash) : "";
138
+ return `${origin}${encode(rest.slice(0, pathEnd))}${search}${encode(fragment)}`;
139
+ }
140
+ /**
117
141
  * The site's redirects (`content/redirects.json`) for hosts that send them
118
142
  * themselves, such as Vercel, which reads them from the build. Redirects from
119
143
  * an address a page or a collection's item has are left out, since pages win,
@@ -130,7 +154,7 @@ async function siteRedirects(root) {
130
154
  if (problems[index] || !from.ok || taken.has(from.path)) return [];
131
155
  return [{
132
156
  source: redirectSource(from.path),
133
- destination: redirect.to.trim(),
157
+ destination: redirectDestination(redirect.to),
134
158
  permanent: true
135
159
  }];
136
160
  });
@@ -193,10 +217,13 @@ function withGoodfellow(nextConfig = {}, options = {}) {
193
217
  images: imageSettings(nextConfig.images),
194
218
  env: {
195
219
  ...nextConfig.env,
196
- GOODFELLOW_BASE_PATH: basePath
220
+ GOODFELLOW_BASE_PATH: basePath,
221
+ [TRAILING_SLASH_VARIABLE]: String(nextConfig.trailingSlash ?? true)
197
222
  }
198
223
  };
199
224
  if (phase === PHASE_PRODUCTION_BUILD) {
225
+ await mkdir(dirname(join(root, DIST_DIR_RECORD)), { recursive: true });
226
+ await writeFile(join(root, DIST_DIR_RECORD), nextConfig.distDir ?? ".next");
200
227
  const dates = join(root, nextConfig.distDir ?? ".next", BUILD_DATES_DIR);
201
228
  await rm(dates, {
202
229
  recursive: true,
@@ -276,4 +303,4 @@ function withGoodfellow(nextConfig = {}, options = {}) {
276
303
  };
277
304
  }
278
305
  //#endregion
279
- export { withGoodfellow };
306
+ export { redirectDestination, withGoodfellow };
package/dist/export.d.ts CHANGED
@@ -2,11 +2,6 @@ import { GoodfellowConfig, PluginTaskRequest, SelfHostedResult, SiteProblem, Tas
2
2
  import { ImageSizesResult, SiteFile } from "@goodfellow-cms/core/node";
3
3
  import { PageRender } from "@goodfellow-cms/core/shadcn";
4
4
  //#region src/site-files.d.ts
5
- /**
6
- * The Next.js starter's files that `goodfellow-next update` keeps up to date
7
- * where nobody has changed them, as the starter now has them. A test checks
8
- * they match `templates/next`.
9
- */
10
5
  export declare const NEXT_SITE_FILES: readonly SiteFile[];
11
6
  //#endregion
12
7
  //#region src/export.d.ts
@@ -35,6 +30,15 @@ export declare function writeContentApi(outDir: string, root?: string): Promise<
35
30
  * code, such as `es/feeds/`.
36
31
  */
37
32
  export declare function buildFileFolders(root?: string): Promise<string[]>;
33
+ /** The folder `next build` wrote into, as `withGoodfellow()` recorded it: the config's `distDir`, or `.next`. */
34
+ export declare function nextDistDir(root?: string): Promise<string>;
35
+ /**
36
+ * Whether the export's pages end their addresses with `/`, as Next.js's
37
+ * `trailingSlash` says, which Next.js records in
38
+ * `.next/required-server-files.json`. `true` where it can't be read, as
39
+ * `withGoodfellow()` sets it.
40
+ */
41
+ export declare function exportTrailingSlash(root?: string): Promise<boolean>;
38
42
  /** What `writePluginFiles()` wrote and ran. */
39
43
  export interface PluginFilesWritten {
40
44
  /** The plugins whose admin frames were written. */
@@ -156,10 +160,14 @@ export declare function exportedPages(outDir: string): Promise<BuiltPage[]>;
156
160
  */
157
161
  export declare function checkPages(pages: readonly BuiltPage[], root?: string): Promise<SiteProblem[]>;
158
162
  /**
159
- * Runs one of a plugin's tasks for the admin panel, as `goodfellow task`
160
- * does for other sites, with the site's config loaded in Node, and writes its
161
- * answer sealed to the request's key, even when the task fails. The config
162
- * loads in a process of its own, with none of the job's secrets.
163
+ * Runs one of a plugin's tasks for the admin panel in one job, as
164
+ * `goodfellow task` does for other sites, with the site's config loaded in
165
+ * Node, and writes its answer sealed to the request's key, even when the task
166
+ * fails: for sites' task setups from before 0.9. The config loads in a process
167
+ * of its own, without the job's secrets in its environment, but where it can
168
+ * read its parent's, so the starters now write the task down with
169
+ * `goodfellow-next task --describe` in a job with no secrets and run it with
170
+ * `@goodfellow-cms/core`'s `goodfellow-task`.
163
171
  */
164
172
  export declare function writeTaskResult(root: string, request: PluginTaskRequest, out?: string): Promise<TaskResultFile>;
165
173
  /** The site's config, loaded in Node, for `writeTaskResult()`'s process that loads it apart from the job's secrets. */
package/dist/export.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { a as loadSiteModule, i as loadSiteConfig, n as buildPluginFrames, r as configHasPlugins } from "./plugins-UKkfu0Uj.js";
2
- import "./image-sizes-mark-Ctz9p44g.js";
3
- import { r as NEXT_SITE_FILES } from "./site-files-BeLnLxCl.js";
2
+ import { t as DIST_DIR_RECORD } from "./image-sizes-mark-BOE7ObB9.js";
3
+ import { r as NEXT_SITE_FILES } from "./site-files-Cmu3ovIs.js";
4
4
  import { CALENDARS_DIR, CLOUDFLARE_REDIRECTS_PATH, EDITOR_PRELUDE, FEEDS_DIR, SITE_DESCRIPTION_PATH, TASK_RESULT_FILE, absoluteUrl, allPages, calendarFiles, cloudflareRedirects, editorPolicy, feedFiles, hasLanguages, loadSiteContent, parseSiteDescription, pathLanguage, pluginId, redirectFiles, rootRobotsUrl, sealTaskAnswer, shownContent, siteLanguages, sitePages, taskRequestProblem, todayIn } from "@goodfellow-cms/core";
5
5
  import { BUILD_DATES_DIR, builtSiteSource, guardBuiltSite, reportedDate, runPluginBuildSteps, runPluginTask, selfHostFonts, sitePluginsApart, taskSecretsFrom, writeBuildRecord, writeImageSizes, writeSiteHeaders } from "@goodfellow-cms/core/node";
6
6
  import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
@@ -71,16 +71,34 @@ async function buildFileFolders(root = process.cwd()) {
71
71
  const { settings } = await loadSiteContent(builtSiteSource(resolve(root)));
72
72
  return siteLanguages(settings).flatMap((language) => [CALENDARS_DIR, FEEDS_DIR].map((dir) => `${language.prefix.slice(1)}${language.prefix ? "/" : ""}${dir}`));
73
73
  }
74
+ /** The folder `next build` wrote into, as `withGoodfellow()` recorded it: the config's `distDir`, or `.next`. */
75
+ async function nextDistDir(root = process.cwd()) {
76
+ const recorded = await readFile(join(root, DIST_DIR_RECORD), "utf8").catch(() => "");
77
+ return join(root, recorded.trim() || ".next");
78
+ }
74
79
  /** The `basePath` the export was built with, which Next.js records in `.next/routes-manifest.json`. */
75
80
  async function exportBasePath(root) {
76
81
  try {
77
- const manifest = JSON.parse(await readFile(join(root, ".next/routes-manifest.json"), "utf8"));
82
+ const manifest = JSON.parse(await readFile(join(await nextDistDir(root), "routes-manifest.json"), "utf8"));
78
83
  return typeof manifest.basePath === "string" ? manifest.basePath : "";
79
84
  } catch {
80
85
  return "";
81
86
  }
82
87
  }
83
88
  /**
89
+ * Whether the export's pages end their addresses with `/`, as Next.js's
90
+ * `trailingSlash` says, which Next.js records in
91
+ * `.next/required-server-files.json`. `true` where it can't be read, as
92
+ * `withGoodfellow()` sets it.
93
+ */
94
+ async function exportTrailingSlash(root = ".") {
95
+ try {
96
+ return JSON.parse(await readFile(join(await nextDistDir(root), "required-server-files.json"), "utf8")).config?.trailingSlash !== false;
97
+ } catch {
98
+ return true;
99
+ }
100
+ }
101
+ /**
84
102
  * Plugins' parts that builds make: each plugin's admin frame, beside the
85
103
  * editor's frames at `admin/plugins/<id>/`, and its build steps, each in a
86
104
  * process of its own held to what the plugin declared. Into a static export
@@ -138,7 +156,7 @@ async function writeFontFiles(outDir, root = process.cwd()) {
138
156
  async function writeBuildRecordFile(outDir, root = process.cwd()) {
139
157
  const dir = resolve(root);
140
158
  const content = await loadSiteContent(builtSiteSource(dir));
141
- const date = await reportedDate(join(dir, ".next", BUILD_DATES_DIR));
159
+ const date = await reportedDate(join(await nextDistDir(dir), BUILD_DATES_DIR));
142
160
  return (await writeBuildRecord(outDir, {
143
161
  root: dir,
144
162
  content,
@@ -169,7 +187,7 @@ async function writeRedirectFiles(outDir, root = process.cwd()) {
169
187
  await writeFile(target, file.content);
170
188
  written.push(file);
171
189
  }
172
- const cloudflare = cloudflareRedirects(written, base);
190
+ const cloudflare = cloudflareRedirects(written, base, content.settings.trailingSlash !== false);
173
191
  const redirects = join(outDir, CLOUDFLARE_REDIRECTS_PATH.slice(1));
174
192
  if (cloudflare.file && !await exists(redirects)) await writeFile(redirects, cloudflare.file.content);
175
193
  return {
@@ -185,14 +203,14 @@ async function writeRedirectFiles(outDir, root = process.cwd()) {
185
203
  */
186
204
  async function writeImageFiles(outDir, root = process.cwd()) {
187
205
  const dir = resolve(root);
188
- if (!await exists(join(dir, ".next", "goodfellow-image-sizes"))) return void 0;
206
+ if (!await exists(join(await nextDistDir(dir), "goodfellow-image-sizes"))) return void 0;
189
207
  return writeImageSizes(dir, outDir, { resize: await sharpResizer() });
190
208
  }
191
- /** Makes the copies with sharp, turned as each photo's details say and without them, unless it can't load here. */
209
+ /** Makes the copies with sharp, as WebP, turned as each photo's details say and without them, unless it can't load here. */
192
210
  async function sharpResizer() {
193
211
  try {
194
212
  const { default: sharp } = await import("sharp");
195
- return async (image, width) => new Uint8Array(await sharp(image).rotate().resize({ width }).toBuffer());
213
+ return async (image, width) => new Uint8Array(await sharp(image).rotate().resize({ width }).webp({ quality: 80 }).toBuffer());
196
214
  } catch {
197
215
  return;
198
216
  }
@@ -311,7 +329,7 @@ async function writePageLanguages(outDir, root = process.cwd()) {
311
329
  const dir = resolve(root);
312
330
  const { settings } = await loadSiteContent(builtSiteSource(dir));
313
331
  if (!hasLanguages(settings)) return 0;
314
- const base = outDir ?? join(dir, ".next/server/app");
332
+ const base = outDir ?? join(await nextDistDir(dir), "server/app");
315
333
  const files = (await readdir(base, { recursive: true })).map((file) => file.split("\\").join("/")).filter((file) => file.endsWith(".html") && !/^(admin|_next|pagefind)\//.test(file));
316
334
  let changed = 0;
317
335
  for (const file of files) {
@@ -331,7 +349,7 @@ async function writePageLanguages(outDir, root = process.cwd()) {
331
349
  * and keeps their HTML in `.next/server/app/`. The admin panel and Next.js's own pages are left out.
332
350
  */
333
351
  async function builtPages(root = process.cwd()) {
334
- const next = join(resolve(root), ".next");
352
+ const next = await nextDistDir(resolve(root));
335
353
  const manifest = JSON.parse(await readFile(join(next, "prerender-manifest.json"), "utf8"));
336
354
  const trailingSlash = JSON.parse(await readFile(join(next, "required-server-files.json"), "utf8")).config?.trailingSlash === true;
337
355
  const pages = [];
@@ -389,10 +407,14 @@ async function checkPages(pages, root = process.cwd()) {
389
407
  }));
390
408
  }
391
409
  /**
392
- * Runs one of a plugin's tasks for the admin panel, as `goodfellow task`
393
- * does for other sites, with the site's config loaded in Node, and writes its
394
- * answer sealed to the request's key, even when the task fails. The config
395
- * loads in a process of its own, with none of the job's secrets.
410
+ * Runs one of a plugin's tasks for the admin panel in one job, as
411
+ * `goodfellow task` does for other sites, with the site's config loaded in
412
+ * Node, and writes its answer sealed to the request's key, even when the task
413
+ * fails: for sites' task setups from before 0.9. The config loads in a process
414
+ * of its own, without the job's secrets in its environment, but where it can
415
+ * read its parent's, so the starters now write the task down with
416
+ * `goodfellow-next task --describe` in a job with no secrets and run it with
417
+ * `@goodfellow-cms/core`'s `goodfellow-task`.
396
418
  */
397
419
  async function writeTaskResult(root, request, out = TASK_RESULT_FILE) {
398
420
  const secrets = taskSecretsFrom(process.env);
@@ -453,4 +475,4 @@ function blockPageRender(root) {
453
475
  };
454
476
  }
455
477
  //#endregion
456
- export { EDITOR_PAGES, NEXT_SITE_FILES, blockPageRender, buildFileFolders, builtPages, checkPages, exportedPages, fixSegmentFiles, guardExport, loadTaskConfig, writeBuildRecordFile, writeCalendarFiles, writeContentApi, writeEditorPolicy, writeExportHeaders, writeFeedFiles, writeFontFiles, writeImageFiles, writePageLanguages, writePluginFiles, writeRedirectFiles, writeTaskResult };
478
+ export { EDITOR_PAGES, NEXT_SITE_FILES, blockPageRender, buildFileFolders, builtPages, checkPages, exportTrailingSlash, exportedPages, fixSegmentFiles, guardExport, loadTaskConfig, nextDistDir, writeBuildRecordFile, writeCalendarFiles, writeContentApi, writeEditorPolicy, writeExportHeaders, writeFeedFiles, writeFontFiles, writeImageFiles, writePageLanguages, writePluginFiles, writeRedirectFiles, writeTaskResult };
@@ -7,5 +7,11 @@
7
7
  const IMAGE_SIZES_ENV = "GOODFELLOW_IMAGE_SIZES";
8
8
  /** The file in `.next/` that tells `goodfellow-next finish` the pages list them. */
9
9
  const IMAGE_SIZES_MARK = "goodfellow-image-sizes";
10
+ /**
11
+ * Where `withGoodfellow()` records the folder `next build` writes into (the
12
+ * config's `distDir`, `.next` unless it says otherwise), relative to the site,
13
+ * for `goodfellow-next finish`, which runs after it in a process of its own.
14
+ */
15
+ const DIST_DIR_RECORD = "node_modules/.goodfellow/next-dist-dir";
10
16
  //#endregion
11
- export { IMAGE_SIZES_MARK as n, IMAGE_SIZES_ENV as t };
17
+ export { IMAGE_SIZES_ENV as n, IMAGE_SIZES_MARK as r, DIST_DIR_RECORD as t };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AdminSite, GoodfellowConfig, Page, SiteContent } from "@goodfellow-cms/core";
1
+ import { AdminSite, GoodfellowConfig, ImageSize, Page, SiteContent } from "@goodfellow-cms/core";
2
2
  import { ReactNode } from "react";
3
3
  import { Metadata, MetadataRoute } from "next";
4
4
  //#region src/index.d.ts
@@ -24,8 +24,11 @@ export interface PageProps {
24
24
  * content edits show on reload in development.
25
25
  */
26
26
  export declare const loadSite: (root?: string) => Promise<SiteContent>;
27
- /** A page's title, description and link previews, as Next.js metadata. */
28
- export declare function pageMetadata(content: SiteContent, page: Page, basePath?: string): Metadata;
27
+ /**
28
+ * A page's title, description, search engine settings and link previews, as
29
+ * Next.js metadata, with the shared picture's size where `media` has it.
30
+ */
31
+ export declare function pageMetadata(content: SiteContent, page: Page, basePath?: string, media?: Readonly<Record<string, ImageSize>>): Metadata;
29
32
  /**
30
33
  * Renders the site's pages in a Next.js App Router site, as Server Components.
31
34
  * Blocks' links to the site's pages use `next/link`, and their images
@@ -75,7 +78,10 @@ export declare function goodfellowPages(config: GoodfellowConfig, options?: Good
75
78
  * edit its content without its code, such as the Claude connector.
76
79
  */
77
80
  siteDescription(): Response;
78
- /** For `app/robots.ts`: lets search engines in, and points them at the sitemap once the site's address is set. */
81
+ /**
82
+ * For `app/robots.ts`: lets search engines in, asks AI training's crawlers to stay out where Site Settings
83
+ * says so, and points them at the sitemap once the site's address is set (`robotsRules()`).
84
+ */
79
85
  robots(): Promise<MetadataRoute.Robots>;
80
86
  };
81
87
  //#endregion
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { ByLanguage, CurrentMenuLinks, NextImage, NextLink, PageLanguage, SearchResultLinks } from "./components.js";
2
- import "./image-sizes-mark-Ctz9p44g.js";
3
- import { BLOCK_SCRIPTS, DARK_MODE_ATTRIBUTE, IMAGE_WIDTHS, absoluteUrl, adminSiteForCopy, colorSchemeMeta, colorSchemeScript, consentScript, contentForPage, demoContent, describedForCopy, feedLinks, findRedirect, fontPreloads, googleFontsUrl, hasLanguages, heldCode, languageMap, loadSiteContent, normalizePagePath, pageHead, shownContent, siteCopyFrom, siteLanguages, sitePages, sitemapPages, storedMediaSizes, themeToCss, todayIn, withBase, withSelfHostedFonts } from "@goodfellow-cms/core";
1
+ import { ByLanguage, CurrentMenuLinks, GoatCounterNavigation, NextImage, NextLink, PageLanguage, SearchResultLinks } from "./components.js";
2
+ import "./image-sizes-mark-BOE7ObB9.js";
3
+ import { BLOCK_SCRIPTS, DARK_MODE_ATTRIBUTE, IMAGE_WIDTHS, TRAILING_SLASH_VARIABLE, absoluteUrl, adminSiteForCopy, colorSchemeMeta, colorSchemeScript, consentScript, contentForPage, customFontPreloads, demoContent, describedForCopy, feedLinks, findRedirect, fontPreloads, googleFontsUrl, hasLanguages, heldCode, heldStats, languageMap, loadSiteContent, normalizePagePath, pageHead, robotsRules, shownContent, siteCopyFrom, siteLanguages, sitePages, sitemapPages, storedMediaSizes, themeToCss, todayIn, withBase, withSelfHostedFonts } from "@goodfellow-cms/core";
4
4
  import { BUILD_DATES_ENV, builtSiteSource, fileSystemSource, fontStandIns, readMediaSizes, readStylesheet, reportDatesTo } from "@goodfellow-cms/core/node";
5
- import { BodyCode, HeadCode, PageBody, PageContent, PageFooter, PageHeader, adminSite, applyPageEntry, createPuckConfigs, describeSite, prepareLayout, preparePage } from "@goodfellow-cms/react";
5
+ import { BodyCode, HeadCode, PageBody, PageContent, PageFooter, PageHeader, StatsCode, adminSite, applyPageEntry, createPuckConfigs, describeSite, prepareLayout, preparePage } from "@goodfellow-cms/react";
6
6
  import { notFound, redirect } from "next/navigation";
7
7
  import { cache } from "react";
8
8
  import { preload } from "react-dom";
@@ -41,7 +41,10 @@ function hostedFonts(source) {
41
41
  * for the length of one request, so a page and its metadata read it once, and
42
42
  * content edits show on reload in development.
43
43
  */
44
- const loadSite = cache(async (root = process.cwd()) => shownContent(await loadSiteContent(builtSiteSource(root))));
44
+ const loadSite = cache(async (root = process.cwd()) => shownContent(await loadSiteContent(builtSiteSource(root, {
45
+ ...process.env,
46
+ [TRAILING_SLASH_VARIABLE]: process.env.GOODFELLOW_TRAILING_SLASH
47
+ }))));
45
48
  function pathOf(segments) {
46
49
  try {
47
50
  return normalizePagePath(`/${(segments ?? []).map((segment) => decodeURIComponent(segment)).join("/")}`);
@@ -80,16 +83,25 @@ function toAbsolute(siteUrl, url) {
80
83
  if (!url || !siteUrl || !url.startsWith("/") || url.startsWith("//")) return url;
81
84
  return absoluteUrl(siteUrl, url);
82
85
  }
83
- /** A page's title, description and link previews, as Next.js metadata. */
84
- function pageMetadata(content, page, basePath = configuredBasePath()) {
86
+ /**
87
+ * A page's title, description, search engine settings and link previews, as
88
+ * Next.js metadata, with the shared picture's size where `media` has it.
89
+ */
90
+ function pageMetadata(content, page, basePath = configuredBasePath(), media = {}) {
85
91
  const head = pageHead(content, page);
86
92
  const socialImage = toAbsolute(content.settings.url, head.socialImage);
93
+ const socialSize = head.socialImage ? media[head.socialImage] : void 0;
87
94
  const feeds = feedLinks(contentForPage(content, page));
88
95
  return {
89
96
  ...content.settings.url && { metadataBase: new URL(content.settings.url) },
90
97
  title: { absolute: head.title },
91
98
  ...head.description && { description: head.description },
92
- ...head.noIndex && { robots: { index: false } },
99
+ robots: {
100
+ ...head.noIndex && { index: false },
101
+ ...head.noFollow && { follow: false },
102
+ ...!head.noIndex && head.noSnippet && { nosnippet: true },
103
+ ...!head.noIndex && { "max-image-preview": "large" }
104
+ },
93
105
  ...(head.canonicalUrl || feeds.length > 0) && { alternates: {
94
106
  ...head.canonicalUrl && { canonical: head.canonicalUrl },
95
107
  ...head.alternates && { languages: Object.fromEntries(head.alternates.map(({ language, href }) => [language, href])) },
@@ -100,12 +112,25 @@ function pageMetadata(content, page, basePath = configuredBasePath()) {
100
112
  } },
101
113
  ...head.favicon && { icons: { icon: withBase(head.favicon, basePath) } },
102
114
  openGraph: {
103
- type: "website",
115
+ ...head.type === "article" ? {
116
+ type: "article",
117
+ ...head.publishedTime && { publishedTime: head.publishedTime }
118
+ } : { type: "website" },
119
+ siteName: head.siteName,
120
+ locale: head.locale,
104
121
  title: head.title,
105
122
  ...head.description && { description: head.description },
106
123
  ...head.canonicalUrl && { url: head.canonicalUrl },
107
- ...socialImage && /^https?:\/\//.test(socialImage) && { images: [socialImage] }
108
- }
124
+ ...socialImage && /^https?:\/\//.test(socialImage) && { images: [{
125
+ url: socialImage,
126
+ ...socialSize && {
127
+ width: socialSize.width,
128
+ height: socialSize.height
129
+ },
130
+ ...head.socialImageAlt && { alt: head.socialImageAlt }
131
+ }] }
132
+ },
133
+ twitter: { card: head.card }
109
134
  };
110
135
  }
111
136
  /**
@@ -135,7 +160,12 @@ function goodfellowPages(config, options = {}) {
135
160
  const googleUrl = googleFontsUrl(content.settings.theme);
136
161
  const hosted = googleUrl ? hostedFonts(googleUrl) : void 0;
137
162
  const fontsUrl = googleUrl && hosted ? withSelfHostedFonts(googleUrl, hosted, basePath()) : googleUrl;
138
- const [fontFiles, standIns] = await Promise.all([fontPreloads(content.settings.theme, readStylesheet), fontStandIns(content.settings.theme)]);
163
+ const [fontFiles, standIns] = await Promise.all([fontPreloads(content.settings.theme, readStylesheet), fontStandIns(content.settings.theme, { root })]);
164
+ for (const file of customFontPreloads(content.settings.theme)) preload(withBase(file, basePath()), {
165
+ as: "font",
166
+ type: "font/woff2",
167
+ crossOrigin: ""
168
+ });
139
169
  for (const file of fontFiles) {
140
170
  const href = hosted ? withSelfHostedFonts(file, hosted, basePath()) : file;
141
171
  if (hosted && href === file) continue;
@@ -156,7 +186,7 @@ function goodfellowPages(config, options = {}) {
156
186
  /* @__PURE__ */ jsx("style", {
157
187
  href: "goodfellow-theme",
158
188
  precedence: "default",
159
- children: themeToCss(content.settings.theme, standIns)
189
+ children: themeToCss(content.settings.theme, standIns, { base: basePath() })
160
190
  }),
161
191
  fontsUrl && /* @__PURE__ */ jsx("link", {
162
192
  rel: "stylesheet",
@@ -173,7 +203,8 @@ function goodfellowPages(config, options = {}) {
173
203
  async generateMetadata({ params }) {
174
204
  const content = await site();
175
205
  const page = findPage(content, config.blocks, pathOf((await params).path));
176
- return page ? pageMetadata(content, applyPageEntry(configs, content, page), basePath()) : {};
206
+ if (!page) return {};
207
+ return pageMetadata(content, applyPageEntry(configs, content, page), basePath(), (await renderOptions()).media);
177
208
  },
178
209
  /**
179
210
  * For `app/admin/page.tsx`: what the admin panel's page knows about the
@@ -246,10 +277,15 @@ function goodfellowPages(config, options = {}) {
246
277
  held: heldCode(content.settings.consent, "head"),
247
278
  siteUrl: content.settings.url
248
279
  }),
280
+ /* @__PURE__ */ jsx(StatsCode, {
281
+ stats: content.settings.stats,
282
+ held: heldStats(content.settings.consent, content.settings.stats)
283
+ }),
249
284
  header,
250
285
  children,
251
286
  footer,
252
287
  /* @__PURE__ */ jsx(CurrentMenuLinks, { basePath: basePath() }),
288
+ content.settings.stats?.service === "goatcounter" && process.env.NODE_ENV !== "development" && /* @__PURE__ */ jsx(GoatCounterNavigation, {}),
253
289
  /* @__PURE__ */ jsx(SearchResultLinks, { basePath: basePath() }),
254
290
  /* @__PURE__ */ jsx(BodyCode, {
255
291
  code: content.code?.body,
@@ -337,15 +373,15 @@ function goodfellowPages(config, options = {}) {
337
373
  siteDescription() {
338
374
  return Response.json(describedForCopy(describeSite(config), siteCopyFrom(process.env)));
339
375
  },
340
- /** For `app/robots.ts`: lets search engines in, and points them at the sitemap once the site's address is set. */
376
+ /**
377
+ * For `app/robots.ts`: lets search engines in, asks AI training's crawlers to stay out where Site Settings
378
+ * says so, and points them at the sitemap once the site's address is set (`robotsRules()`).
379
+ */
341
380
  async robots() {
342
- const url = (await site()).settings.url;
381
+ const { settings } = await site();
343
382
  return {
344
- rules: {
345
- userAgent: "*",
346
- allow: "/"
347
- },
348
- ...url && { sitemap: absoluteUrl(url, "/sitemap.xml") }
383
+ rules: robotsRules(settings),
384
+ ...settings.url && { sitemap: absoluteUrl(settings.url, "/sitemap.xml") }
349
385
  };
350
386
  }
351
387
  };
@@ -1,6 +1,8 @@
1
1
  //#region src/site-files.ts
2
2
  /** The release whose Next.js sites have the editor's sandboxed frames at `app/admin/editor/`. */
3
3
  const FRAMES_RELEASE = "0.5.0";
4
+ /** The release whose Next.js sites tell Vercel not to build drafts' and plugins' tasks' branches. */
5
+ const VERCEL_BRANCHES_RELEASE = "0.9.0";
4
6
  /** `app/admin/page.tsx` as every release before the frames wrote it, loading the site's config itself. */
5
7
  const CLIENT_ADMIN_PAGE = `"use client";
6
8
 
@@ -30,21 +32,33 @@ export default function EditorPage() {
30
32
  return <GoodfellowEditor config={config} />;
31
33
  }
32
34
  `;
33
- /**
34
- * The Next.js starter's files that `goodfellow-next update` keeps up to date
35
- * where nobody has changed them, as the starter now has them. A test checks
36
- * they match `templates/next`.
37
- */
38
- const NEXT_SITE_FILES = [{
39
- path: "app/admin/page.tsx",
40
- since: FRAMES_RELEASE,
41
- content: ADMIN_PAGE,
42
- earlier: [CLIENT_ADMIN_PAGE]
43
- }, {
44
- path: "app/admin/editor/page.tsx",
45
- since: FRAMES_RELEASE,
46
- content: EDITOR_PAGE,
47
- earlier: []
48
- }];
35
+ const NEXT_SITE_FILES = [
36
+ {
37
+ path: "app/admin/page.tsx",
38
+ since: FRAMES_RELEASE,
39
+ content: ADMIN_PAGE,
40
+ earlier: [CLIENT_ADMIN_PAGE]
41
+ },
42
+ {
43
+ path: "app/admin/editor/page.tsx",
44
+ since: FRAMES_RELEASE,
45
+ content: EDITOR_PAGE,
46
+ earlier: []
47
+ },
48
+ {
49
+ path: "vercel.json",
50
+ since: VERCEL_BRANCHES_RELEASE,
51
+ content: `{
52
+ "git": {
53
+ "deploymentEnabled": {
54
+ "goodfellow-draft-*": false,
55
+ "goodfellow-task-*": false
56
+ }
57
+ }
58
+ }
59
+ `,
60
+ earlier: []
61
+ }
62
+ ];
49
63
  //#endregion
50
64
  export { EDITOR_PAGE as n, NEXT_SITE_FILES as r, ADMIN_PAGE as t };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/next",
3
- "version": "0.8.0",
4
- "description": "Goodfellow pages and the admin panel in a Next.js site.",
3
+ "version": "0.10.0",
4
+ "description": "Goodfellow CMS pages and the admin panel in a Next.js site.",
5
5
  "keywords": [
6
6
  "goodfellow",
7
7
  "cms",
@@ -12,6 +12,10 @@
12
12
  ],
13
13
  "homepage": "https://goodfellow-cms.github.io/goodfellow-cms/docs/nextjs",
14
14
  "bugs": "https://github.com/goodfellow-cms/goodfellow-cms/issues",
15
+ "funding": {
16
+ "type": "github",
17
+ "url": "https://github.com/sponsors/goodfellow-cms"
18
+ },
15
19
  "license": "MIT",
16
20
  "repository": {
17
21
  "type": "git",
@@ -63,24 +67,24 @@
63
67
  "dist"
64
68
  ],
65
69
  "dependencies": {
66
- "@goodfellow-cms/ai": "0.8.0",
67
- "@goodfellow-cms/core": "0.8.0",
68
- "@goodfellow-cms/react": "0.8.0",
70
+ "@goodfellow-cms/ai": "0.10.0",
71
+ "@goodfellow-cms/core": "0.10.0",
72
+ "@goodfellow-cms/react": "0.10.0",
69
73
  "rolldown": "1.2.12",
70
74
  "sharp": "0.35.5"
71
75
  },
72
76
  "peerDependencies": {
73
- "@goodfellow-cms/admin": "0.8.0",
77
+ "@goodfellow-cms/admin": "0.10.0",
74
78
  "@puckeditor/core": "0.23.0",
75
79
  "next": "^16.0.0",
76
80
  "react": "^19.0.0",
77
81
  "react-dom": "^19.0.0"
78
82
  },
79
83
  "devDependencies": {
80
- "@goodfellow-cms/admin": "0.8.0",
81
- "@goodfellow-cms/bitbucket": "0.8.0",
82
- "@goodfellow-cms/github": "0.8.0",
83
- "@goodfellow-cms/gitlab": "0.8.0",
84
+ "@goodfellow-cms/admin": "0.10.0",
85
+ "@goodfellow-cms/bitbucket": "0.10.0",
86
+ "@goodfellow-cms/github": "0.10.0",
87
+ "@goodfellow-cms/gitlab": "0.10.0",
84
88
  "@puckeditor/core": "0.23.0",
85
89
  "@tailwindcss/browser": "4.3.3",
86
90
  "@types/react": "^19.2.0",