@goodfellow-cms/next 0.7.0 → 0.9.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,12 +3,14 @@
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.
13
+ // `goodfellow-next import markdown <folder> [--dry-run]` imports a folder of Markdown files, as `goodfellow import` does.
12
14
  // `goodfellow-next export wordpress [file]` writes the site as a WordPress export file, as `goodfellow export` does.
13
15
  // `goodfellow-next blocks add <item> [--registry <folder>] [--native <app>]` makes a block of a shadcn item, as
14
16
  // `goodfellow blocks add` does, and with --native its version for an Expo app.
@@ -16,7 +18,7 @@
16
18
  // the token broker, in goodfellow.config.tsx, as `goodfellow admin` and `goodfellow broker` do.
17
19
  import { existsSync, readFileSync } from "node:fs";
18
20
  import { rm } from "node:fs/promises";
19
- import { relative, resolve } from "node:path";
21
+ import { join, relative, resolve } from "node:path";
20
22
  import { parseArgs } from "node:util";
21
23
  import { buildNotes, CLOUDFLARE_REDIRECTS_LIMIT, configAdmin, formatUpdateResult } from "@goodfellow-cms/core";
22
24
  import { reportSiteProblems } from "@goodfellow-cms/core/accessibility";
@@ -24,13 +26,19 @@ import {
24
26
  AdminSetupError,
25
27
  BrokerSetupError,
26
28
  ContentRepositoryError,
29
+ checkEarlyTheme,
27
30
  checkSiteAdminAddress,
28
31
  contentRepositoryProblem,
29
32
  exportWordPress,
30
33
  formatAdminResult,
34
+ formatAdminText,
35
+ formatMarkdownImport,
31
36
  formatWordPressExport,
32
37
  formatWordPressImport,
38
+ importMarkdown,
33
39
  importWordPress,
40
+ MarkdownFolderError,
41
+ markdownFolderChoices,
34
42
  nightlyCheck,
35
43
  reportDue,
36
44
  reportNightly,
@@ -39,6 +47,8 @@ import {
39
47
  setBrokerAddress,
40
48
  setUpContentRepository,
41
49
  update,
50
+ writeAdminText,
51
+ writeTaskDescription,
42
52
  } from "@goodfellow-cms/core/node";
43
53
  import { writePagesSearchIndex, writeSearchIndex } from "@goodfellow-cms/core/search-index";
44
54
  import { addShadcnBlock, blockWorks, formatShadcnBlock, ShadcnBlockError } from "@goodfellow-cms/core/shadcn";
@@ -49,15 +59,19 @@ import {
49
59
  builtPages,
50
60
  checkPages,
51
61
  exportedPages,
62
+ exportTrailingSlash,
52
63
  fixSegmentFiles,
53
64
  guardExport,
54
65
  NEXT_SITE_FILES,
66
+ nextDistDir,
55
67
  writeBuildRecordFile,
56
68
  writeCalendarFiles,
57
69
  writeContentApi,
58
70
  writeEditorPolicy,
71
+ writeExportHeaders,
59
72
  writeFeedFiles,
60
73
  writeFontFiles,
74
+ writeImageFiles,
61
75
  writePageLanguages,
62
76
  writePluginFiles,
63
77
  writeRedirectFiles,
@@ -69,6 +83,19 @@ const CONFIG_FILES = ["goodfellow.config.tsx", "goodfellow.config.ts", "goodfell
69
83
  // A full-stack site (withGoodfellow(config, { server: true })): Next.js serves its pages and sends redirects
70
84
  // itself, so the calendar files, feeds and the search index are written into public/, which `next start` and hosts
71
85
  // serve as static files, as they are for an export.
86
+ // A theme installed before it was three days old is used only once its provenance checks out, whatever the
87
+ // admin panel said, since a browser can lie.
88
+ if (command === "finish") {
89
+ // Pages' full addresses end as Next.js served the pages, unless where the site is built says otherwise.
90
+ process.env.GOODFELLOW_TRAILING_SLASH ||= String(await exportTrailingSlash());
91
+ try {
92
+ const theme = await checkEarlyTheme(process.cwd());
93
+ if (theme) console.log(`${theme.name} ${theme.release} was installed early: its provenance checks out.`);
94
+ } catch (error) {
95
+ console.error(error instanceof Error ? error.message : error);
96
+ process.exit(1);
97
+ }
98
+ }
72
99
  if (command === "finish" && dir === "--server") {
73
100
  // Next.js renders a full-stack site's pages as it serves them, so nothing can put a guard first in each.
74
101
  const config = CONFIG_FILES.find((file) => existsSync(file));
@@ -80,13 +107,15 @@ if (command === "finish" && dir === "--server") {
80
107
  }
81
108
  for (const folder of await buildFileFolders()) await rm(`public/${folder}`, { recursive: true, force: true });
82
109
  // The editor's frames' page, which next start serves as it was rendered.
83
- await writeEditorPolicy(".next/server/app");
110
+ await writeEditorPolicy(join(await nextDistDir(), "server/app"));
84
111
  const calendars = await writeCalendarFiles("public");
85
112
  if (calendars > 0) console.log(`Wrote ${calendars} calendar files, in public/calendars`);
86
113
  const feeds = await writeFeedFiles("public");
87
114
  if (feeds > 0) console.log(`Wrote ${feeds} feeds, in public/feeds`);
88
115
  const api = await writeContentApi("public");
89
116
  if (api !== undefined) console.log(`Wrote the content API, with ${api} pages, in public/api`);
117
+ // The admin panel's text in the site's own languages, for the sign-in screen, which can't read the site yet.
118
+ for (const line of formatAdminText(await writeAdminText("public"))) console.log(line);
90
119
  // Before the pages are read, so the search index and the checks see each page's language.
91
120
  const languages = await writePageLanguages(undefined);
92
121
  if (languages > 0) console.log(`Marked ${languages} pages with their language`);
@@ -117,8 +146,20 @@ if (command === "update") {
117
146
  process.exit(result.state === "failed" ? 1 : 0);
118
147
  }
119
148
  if (command === "task") {
120
- const { values } = parseArgs({ args: process.argv.slice(3), options: { out: { type: "string" } } });
149
+ const { values } = parseArgs({
150
+ args: process.argv.slice(3),
151
+ options: { out: { type: "string" }, describe: { type: "boolean" } },
152
+ });
121
153
  const env = process.env;
154
+ if (values.describe) {
155
+ await writeTaskDescription(
156
+ ".",
157
+ { module: new URL("../dist/export.js", import.meta.url).href, export: "loadTaskConfig" },
158
+ { plugin: env.GOODFELLOW_TASK_PLUGIN ?? "", task: env.GOODFELLOW_TASK_NAME ?? "" },
159
+ values.out,
160
+ );
161
+ process.exit(0);
162
+ }
122
163
  await writeTaskResult(
123
164
  ".",
124
165
  {
@@ -202,10 +243,39 @@ if (command === "import") {
202
243
  const { positionals, values } = parseArgs({
203
244
  args: process.argv.slice(3),
204
245
  allowPositionals: true,
205
- options: { "dry-run": { type: "boolean" } },
246
+ options: {
247
+ "dry-run": { type: "boolean" },
248
+ pages: { type: "string", multiple: true },
249
+ collection: { type: "string", multiple: true },
250
+ "replace-home": { type: "boolean" },
251
+ "replace-pages": { type: "boolean" },
252
+ drafts: { type: "boolean" },
253
+ menu: { type: "boolean" },
254
+ "keep-fields": { type: "boolean" },
255
+ },
206
256
  });
207
257
  const [kind, from] = positionals;
208
258
  const problem = contentRepositoryProblem(".");
259
+ if (kind === "markdown" && from && !problem) {
260
+ try {
261
+ const result = await importMarkdown(".", from, {
262
+ folders: markdownFolderChoices(values.pages, values.collection),
263
+ replaceHome: values["replace-home"],
264
+ replacePages: values["replace-pages"],
265
+ drafts: values.drafts,
266
+ menu: values.menu,
267
+ keepFields: values["keep-fields"],
268
+ dryRun: values["dry-run"],
269
+ log: (line) => console.log(line),
270
+ });
271
+ console.log(formatMarkdownImport(result));
272
+ process.exit(0);
273
+ } catch (error) {
274
+ if (!(error instanceof MarkdownFolderError)) throw error;
275
+ console.error(error.message);
276
+ process.exit(1);
277
+ }
278
+ }
209
279
  if (kind === "wordpress" && from && !problem) {
210
280
  try {
211
281
  const result = await importWordPress(".", from, { dryRun: values["dry-run"], log: (line) => console.log(line) });
@@ -270,6 +340,8 @@ if (command !== "finish" && command !== "index") {
270
340
  " goodfellow-next nightly [--mode changed|always|off] [--output <file>]",
271
341
  " goodfellow-next content link|copy [folder]",
272
342
  " goodfellow-next import wordpress <file or address> [--dry-run]",
343
+ " goodfellow-next import markdown <folder> [--dry-run] [--pages <folder>] [--collection <folder>]",
344
+ " [--replace-home] [--replace-pages] [--drafts] [--menu] [--keep-fields]",
273
345
  " goodfellow-next export wordpress [file]",
274
346
  " goodfellow-next blocks add <item> [--registry <folder>] [--native <app>]",
275
347
  " goodfellow-next admin add <address> | check | remove",
@@ -299,7 +371,9 @@ if (command !== "finish" && command !== "index") {
299
371
  " or copies them from it, as builds do.",
300
372
  "import Brings a WordPress site's pages, posts, categories, tags, menus and media into",
301
373
  " this site, from its export file (Tools → Export) or its address. With --dry-run,",
302
- " says what it would bring across and changes nothing.",
374
+ " says what it would bring across and changes nothing. import markdown brings a folder",
375
+ " of Markdown files in: the files at the top as pages, each folder as a collection,",
376
+ " unless --pages or --collection names it (. for the top).",
303
377
  "export Writes this site as a WordPress export file (default: named after the site),",
304
378
  " which WordPress imports with Tools → Import → WordPress.",
305
379
  "blocks Installs a shadcn registry item (button, @acme/hero, or an item's address) with",
@@ -328,6 +402,7 @@ if (command === "finish") {
328
402
  if (feeds > 0) console.log(`Wrote ${feeds} feeds`);
329
403
  const api = await writeContentApi(dir);
330
404
  if (api !== undefined) console.log(`Wrote the content API, with ${api} pages, in api/`);
405
+ for (const line of formatAdminText(await writeAdminText(dir))) console.log(line);
331
406
  const languages = await writePageLanguages(dir);
332
407
  if (languages > 0) console.log(`Marked ${languages} pages with their language`);
333
408
  // Before the redirects' pages, which aren't the site's.
@@ -339,7 +414,10 @@ if (command === "finish") {
339
414
  `Cloudflare Pages reads only the first ${CLOUDFLARE_REDIRECTS_LIMIT.toLocaleString("en")} lines of _redirects, so ${redirects.cloudflareLeft} old address${redirects.cloudflareLeft === 1 ? " is" : "es are"} left to the pages that send visitors on.`,
340
415
  );
341
416
  }
342
- for (const line of buildNotes(await writeFontFiles(dir))) console.log(line);
417
+ // Cloudflare Pages' header rules, after the site's own public/_headers.
418
+ await writeExportHeaders(dir);
419
+ const images = await writeImageFiles(dir);
420
+ for (const line of buildNotes({ ...(await writeFontFiles(dir)), ...(images && { images }) })) console.log(line);
343
421
  // Plugins' admin frames and build steps, once Goodfellow has written the rest, before the index and the guards.
344
422
  const plugins = await writePluginFiles(dir);
345
423
  if (plugins.frames.length > 0) console.log(`Wrote the admin frames of ${plugins.frames.join(", ")}`);
package/dist/admin.js CHANGED
@@ -1,12 +1,12 @@
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";
6
6
  import { Admin, OutdatedAdminPage, reloadFrames } from "@goodfellow-cms/admin";
7
7
  import "@goodfellow-cms/admin/styles.css";
8
8
  //#region src/generated/theme-css.ts
9
- var theme_css_default = "/*\n * Maps the site theme (CSS variables written from content/site.json) onto\n * Tailwind, so classes like bg-primary, text-muted-foreground, font-heading and\n * rounded-lg follow the theme. Import after \"tailwindcss\".\n */\n/*\n * Blocks' dark: classes apply only where the site has dark mode\n * (Site Settings), which gives its pages <meta name=\"color-scheme\"\n * content=\"light dark\">: when the visitor's computer is set to dark, or when\n * the admin panel's preview shows the site dark. Light-only sites never\n * use them, even on a computer set to dark.\n */\n@custom-variant dark {\n @media (prefers-color-scheme: dark) {\n &:where(\n :root:has(meta[name=\"color-scheme\"][content~=\"dark\"]):not([data-gf-color-scheme=\"light\"]),\n :root:has(meta[name=\"color-scheme\"][content~=\"dark\"]):not([data-gf-color-scheme=\"light\"]) *\n ) {\n @slot;\n }\n }\n &:where([data-gf-color-scheme=\"dark\"], [data-gf-color-scheme=\"dark\"] *) {\n @slot;\n }\n}\n\n@theme inline {\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n --color-border: var(--border);\n /* The rest of shadcn/ui's colors, for blocks built with it, follow the ones above unless custom CSS sets them. */\n --color-card: var(--card, var(--background));\n --color-card-foreground: var(--card-foreground, var(--foreground));\n --color-popover: var(--popover, var(--background));\n --color-popover-foreground: var(--popover-foreground, var(--foreground));\n --color-destructive: var(--destructive, #dc2626);\n --color-input: var(--input, var(--border));\n --color-ring: var(--ring, var(--primary));\n --font-sans: var(--gf-font-body);\n --font-heading: var(--gf-font-heading);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n@layer base {\n *,\n ::before,\n ::after {\n border-color: var(--color-border);\n }\n\n body {\n background-color: var(--color-background);\n color: var(--color-foreground);\n /* Keeps the footer at the bottom of short pages. */\n display: flex;\n flex-direction: column;\n min-height: 100dvh;\n }\n\n .gf-main {\n flex: 1;\n }\n\n /* The credits of the photos on a page, which their licenses ask for. */\n .gf-credits {\n max-width: 72rem;\n margin: 0 auto;\n padding: 1.5rem 1rem;\n font-size: 0.75rem;\n line-height: 1.5;\n color: var(--color-muted-foreground);\n }\n\n .gf-credits a {\n text-decoration: underline;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: var(--font-heading);\n }\n}\n";
9
+ var theme_css_default = "/*\n * Maps the site theme (CSS variables written from content/site.json) onto\n * Tailwind, so classes like bg-primary, text-muted-foreground, font-heading and\n * rounded-lg follow the theme. Import after \"tailwindcss\".\n */\n/*\n * Blocks' dark: classes apply only where the site has dark mode\n * (Site Settings), which gives its pages <meta name=\"color-scheme\"\n * content=\"light dark\">: when the visitor's computer is set to dark, or when\n * the admin panel's preview shows the site dark. Light-only sites never\n * use them, even on a computer set to dark.\n */\n@custom-variant dark {\n @media (prefers-color-scheme: dark) {\n &:where(\n :root:has(meta[name=\"color-scheme\"][content~=\"dark\"]):not([data-gf-color-scheme=\"light\"]),\n :root:has(meta[name=\"color-scheme\"][content~=\"dark\"]):not([data-gf-color-scheme=\"light\"]) *\n ) {\n @slot;\n }\n }\n &:where([data-gf-color-scheme=\"dark\"], [data-gf-color-scheme=\"dark\"] *) {\n @slot;\n }\n}\n\n@theme inline {\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n --color-border: var(--border);\n /* The rest of shadcn/ui's colors, for blocks built with it, follow the ones above unless custom CSS sets them. */\n --color-card: var(--card, var(--background));\n --color-card-foreground: var(--card-foreground, var(--foreground));\n --color-popover: var(--popover, var(--background));\n --color-popover-foreground: var(--popover-foreground, var(--foreground));\n --color-destructive: var(--destructive, #dc2626);\n --color-input: var(--input, var(--border));\n --color-ring: var(--ring, var(--primary));\n --font-sans: var(--gf-font-body);\n --font-heading: var(--gf-font-heading);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n@layer base {\n *,\n ::before,\n ::after {\n border-color: var(--color-border);\n }\n\n body {\n background-color: var(--color-background);\n color: var(--color-foreground);\n /* Keeps the footer at the bottom of short pages. */\n display: flex;\n flex-direction: column;\n min-height: 100dvh;\n }\n\n .gf-main {\n flex: 1;\n }\n\n /* The credits of the photos on a page, which their licenses ask for. */\n .gf-credits {\n max-width: 72rem;\n margin: 0 auto;\n padding: 1.5rem 1rem;\n font-size: 0.75rem;\n line-height: 1.5;\n color: var(--color-muted-foreground);\n }\n\n .gf-credits a {\n text-decoration: underline;\n }\n\n /*\n * On a site that asks visitors before loading other sites' content: the\n * notice, and what's shown in a held-back player's place.\n */\n .gf-consent {\n position: fixed;\n inset-inline: 1rem;\n bottom: 1rem;\n z-index: 50;\n max-width: 40rem;\n margin-inline: auto;\n display: flex;\n flex-direction: column;\n gap: 0.75rem;\n padding: 1rem;\n border: 1px solid var(--color-border);\n border-radius: var(--radius-lg);\n background-color: var(--color-background);\n color: var(--color-foreground);\n box-shadow: 0 10px 30px rgb(0 0 0 / 0.15);\n font-size: 0.875rem;\n line-height: 1.5;\n }\n\n .gf-consent a {\n text-decoration: underline;\n }\n\n .gf-consent-buttons {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.5rem;\n }\n\n .gf-consent button,\n .gf-consent-placeholder button {\n padding: 0.5rem 1rem;\n border: 1px solid var(--color-border);\n border-radius: var(--radius-md);\n font-weight: 500;\n cursor: pointer;\n }\n\n .gf-consent [data-gf-consent-accept],\n .gf-consent-placeholder button {\n border-color: var(--color-primary);\n background-color: var(--color-primary);\n color: var(--color-primary-foreground);\n }\n\n .gf-consent-placeholder {\n position: absolute;\n inset: 0;\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 0.75rem;\n padding: 1rem;\n border-radius: inherit;\n background-color: var(--color-muted);\n color: var(--color-foreground);\n font-size: 0.875rem;\n text-align: center;\n }\n\n /* In a form, beside its other fields, rather than over a player. */\n .gf-consent-placeholder.gf-consent-inline {\n position: static;\n align-items: flex-start;\n text-align: start;\n border-radius: var(--radius-md);\n }\n\n .gf-consent-box {\n position: relative;\n }\n\n /* A choice for each kind of content, which Choose shows. */\n .gf-consent-choices {\n display: flex;\n flex-direction: column;\n gap: 0.5rem;\n margin: 0;\n padding: 0;\n border: 0;\n }\n\n .gf-consent-choices label {\n display: flex;\n align-items: flex-start;\n gap: 0.5rem;\n }\n\n .gf-consent-choices input {\n margin-top: 0.25rem;\n accent-color: var(--color-primary);\n }\n\n /* Hidden until the consent script shows them, whatever else styles them. */\n .gf-consent[hidden],\n .gf-consent-placeholder[hidden],\n .gf-consent-choices[hidden],\n .gf-consent button[hidden] {\n display: none;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: var(--font-heading);\n }\n}\n";
10
10
  //#endregion
11
11
  //#region src/admin.tsx
12
12
  /**
@@ -10,11 +10,28 @@ export declare function NextImage({ src, alt, width, height, loading, ...props }
10
10
  * Marks the current page's links in the site's menus (`data-current`, and
11
11
  * `aria-current` for the page itself), as Menu does when it knows the page. The
12
12
  * layout's header and footer are shared by every page, so they can't know it;
13
- * this runs in the browser after every navigation instead.
13
+ * this runs in the browser after every navigation instead, and closes a menu
14
+ * button's links, which would otherwise stay open on the new page.
14
15
  */
15
16
  export declare function CurrentMenuLinks({ basePath }: {
16
17
  basePath?: string;
17
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;
26
+ /**
27
+ * Opens the Search box's results with Next.js's navigation, and loads them
28
+ * ahead when they're pointed at or reached with the keyboard, as `next/link`
29
+ * does. The Search box writes its results itself (`searchScript()`), as plain
30
+ * links, so pages built with `goodfellow build` need no React for them.
31
+ */
32
+ export declare function SearchResultLinks({ basePath }: {
33
+ basePath?: string;
34
+ }): null;
18
35
  /**
19
36
  * On a site in several languages, says the page's language on `<html>`, which
20
37
  * the root layout shared by every page can't. `goodfellow-next finish` writes
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { CurrentPathProvider } from "@goodfellow-cms/react";
3
- import { usePathname } from "next/navigation";
4
- import { useEffect } from "react";
3
+ import { usePathname, useRouter } from "next/navigation";
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";
@@ -29,7 +29,8 @@ function trimSlash(path) {
29
29
  * Marks the current page's links in the site's menus (`data-current`, and
30
30
  * `aria-current` for the page itself), as Menu does when it knows the page. The
31
31
  * layout's header and footer are shared by every page, so they can't know it;
32
- * this runs in the browser after every navigation instead.
32
+ * this runs in the browser after every navigation instead, and closes a menu
33
+ * button's links, which would otherwise stay open on the new page.
33
34
  */
34
35
  function CurrentMenuLinks({ basePath = "" }) {
35
36
  const pathname = usePathname();
@@ -45,10 +46,70 @@ function CurrentMenuLinks({ basePath = "" }) {
45
46
  if (exact) link.setAttribute("aria-current", "page");
46
47
  else link.removeAttribute("aria-current");
47
48
  }
49
+ for (const panel of document.querySelectorAll("[data-gf-menu] details[open]")) panel.open = false;
48
50
  }, [pathname, basePath]);
49
51
  return null;
50
52
  }
51
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
+ /**
72
+ * Opens the Search box's results with Next.js's navigation, and loads them
73
+ * ahead when they're pointed at or reached with the keyboard, as `next/link`
74
+ * does. The Search box writes its results itself (`searchScript()`), as plain
75
+ * links, so pages built with `goodfellow build` need no React for them.
76
+ */
77
+ function SearchResultLinks({ basePath = "" }) {
78
+ const router = useRouter();
79
+ useEffect(() => {
80
+ const prefix = trimSlash(basePath) === "/" ? "" : trimSlash(basePath);
81
+ /** A result's address within the site, without the base path, which Next.js's router adds. */
82
+ const target = (event) => {
83
+ const link = event.target?.closest?.("[data-gf-search-box] a[href]");
84
+ if (!(link instanceof HTMLAnchorElement) || link.target && link.target !== "_self") return void 0;
85
+ const url = new URL(link.href, location.href);
86
+ if (url.origin !== location.origin) return void 0;
87
+ if (prefix && url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return void 0;
88
+ return `${url.pathname.slice(prefix.length) || "/"}${url.search}${url.hash}`;
89
+ };
90
+ const open = (event) => {
91
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
92
+ const to = target(event);
93
+ if (!to) return;
94
+ event.preventDefault();
95
+ router.push(to);
96
+ };
97
+ const prefetch = (event) => {
98
+ const to = target(event);
99
+ if (to) router.prefetch(to);
100
+ };
101
+ document.addEventListener("click", open);
102
+ document.addEventListener("pointerover", prefetch);
103
+ document.addEventListener("focusin", prefetch);
104
+ return () => {
105
+ document.removeEventListener("click", open);
106
+ document.removeEventListener("pointerover", prefetch);
107
+ document.removeEventListener("focusin", prefetch);
108
+ };
109
+ }, [router, basePath]);
110
+ return null;
111
+ }
112
+ /**
52
113
  * On a site in several languages, says the page's language on `<html>`, which
53
114
  * the root layout shared by every page can't. `goodfellow-next finish` writes
54
115
  * it into exported pages; this keeps it right as visitors move between pages.
@@ -74,4 +135,4 @@ function ByLanguage({ variants }) {
74
135
  });
75
136
  }
76
137
  //#endregion
77
- export { ByLanguage, CurrentMenuLinks, NextImage, NextLink, PageLanguage };
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({ ... })`.
@@ -23,8 +31,10 @@ export interface GoodfellowNextOptions {
23
31
  * - Serves images as they are unless the site sets an image loader, since static files can't be optimized on request.
24
32
  * - Adds the site's redirects (Site Settings → Redirects) ahead of the site's own, for hosts that
25
33
  * send them, such as Vercel. Others get the pages `goodfellow-next finish` writes at old addresses.
26
- * - For a site with an admin address, adds header rules that sandbox every page there but the
27
- * admin panel's own, for hosts that send headers, such as Vercel.
34
+ * - Adds header rules for hosts that send them, such as Vercel: the media library's SVGs
35
+ * sandboxed, the admin panel unframed, and, for a site with an admin address, every page
36
+ * there sandboxed but the admin panel's own, with redirects sending pages opened at the
37
+ * wrong one of the two addresses to the right one.
28
38
  * - With `{ server: true }`, builds a full-stack site for a server instead of as static
29
39
  * files: see `goodfellowApi()` in `@goodfellow-cms/next/server`.
30
40
  * - In `next dev`, runs the admin panel's local backend, so publishing saves to
package/dist/config.js CHANGED
@@ -1,9 +1,10 @@
1
- import { CALENDARS_DIR, FEEDS_DIR, allPages, brokerOrigin, configAdmin, configBroker, loadSiteContent, normalizeBase, redirectFrom, redirectProblems, shownContent, vercelAdminHeaders } from "@goodfellow-cms/core";
2
- import { BUILD_DATES_DIR, BUILD_DATES_ENV, DEV_API_PREFIX, builtSiteSource, contentRepositoryProblem, handleDevApi, 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";
3
4
  import { existsSync, readFileSync } from "node:fs";
4
- import { rm } from "node:fs/promises";
5
+ import { mkdir, rm, writeFile } from "node:fs/promises";
5
6
  import { createServer } from "node:http";
6
- import { join, resolve } from "node:path";
7
+ import { dirname, join, resolve } from "node:path";
7
8
  //#region src/config.ts
8
9
  const CONFIG_FILES = [
9
10
  "goodfellow.config.tsx",
@@ -17,15 +18,34 @@ function siteAdmin(root) {
17
18
  return file ? configAdmin(readFileSync(file, "utf8")) : void 0;
18
19
  }
19
20
  /**
20
- * The admin address's header rules, for hosts that read them from the build,
21
- * such as Vercel: every page there sandboxed but the admin panel's own, as
22
- * `goodfellow build`'s `_headers` does on Cloudflare Pages. Next.js adds the base path itself.
21
+ * The site's header rules, for hosts that read them from the build, such as
22
+ * Vercel, as `goodfellow build`'s `_headers` does on Cloudflare Pages: the
23
+ * media library's SVGs sandboxed, the admin panel unframed, and with an admin
24
+ * address every page there sandboxed but the admin panel's own. Next.js adds the base path itself.
23
25
  */
24
- function adminHeaders(root) {
26
+ function siteHeaders(root) {
25
27
  const admin = siteAdmin(root);
26
28
  const file = CONFIG_FILES.map((name) => join(root, name)).find((path) => existsSync(path));
27
29
  const broker = brokerOrigin(file ? configBroker(readFileSync(file, "utf8")) : void 0);
28
- return admin ? vercelAdminHeaders(admin, "/", broker) : [];
30
+ return vercelHeaders({
31
+ ...admin && { admin },
32
+ ...broker && { broker }
33
+ });
34
+ }
35
+ /**
36
+ * A full-stack site's Google Fonts, copied into its `public/` folder
37
+ * (`hostFonts()`), or `undefined` where it has none or Google can't be
38
+ * reached, which the build says, as `goodfellow build` does.
39
+ */
40
+ async function hostSiteFonts(root) {
41
+ const { settings } = await loadSiteContent(builtSiteSource(root));
42
+ const fonts = await hostFonts(join(root, "public"), settings.theme);
43
+ if (!fonts) return void 0;
44
+ if ("unavailable" in fonts) {
45
+ for (const line of buildNotes({ fonts })) console.warn(line);
46
+ return;
47
+ }
48
+ return fonts;
29
49
  }
30
50
  /** `PHASE_DEVELOPMENT_SERVER` from `next/constants`, which Node can't import by that name. */
31
51
  const PHASE_DEVELOPMENT_SERVER = "phase-development-server";
@@ -41,16 +61,21 @@ function startDevApi(root) {
41
61
  globals[SERVER_KEY] ??= new Promise((resolvePort, reject) => {
42
62
  const store = localFileStore(root);
43
63
  const registryDir = localRegistryDir(root);
44
- const editor = import("./dev-editor-CzE9HofK.js").then(({ startDevEditor }) => startDevEditor(root)).catch((error) => {
64
+ const editor = import("./dev-editor-BYUVUdPI.js").then(({ startDevEditor }) => startDevEditor(root)).catch((error) => {
45
65
  console.error(`The editor's frames can't be served: ${error.message}`);
46
66
  return { serve: async () => false };
47
67
  });
48
68
  const tasks = {
49
69
  root,
50
- plugins: async () => (await (await import("./plugins-K5i8XztM.js").then((n) => n.o)).loadSiteConfig(root)).plugins ?? [],
70
+ plugins: async () => (await (await import("./plugins-UKkfu0Uj.js").then((n) => n.o)).loadSiteConfig(root)).plugins ?? [],
51
71
  server: "next-dev"
52
72
  };
53
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
+ }
54
79
  if (await (await editor).serve(req, res)) return;
55
80
  if (!await handleDevApi(store, req, res, {
56
81
  trustOrigin: isLocalOrigin,
@@ -94,6 +119,25 @@ function redirectSource(path) {
94
119
  return encodeURI(path).replace(/[()[\]{}+*?:!]/g, "\\$&");
95
120
  }
96
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
+ /**
97
141
  * The site's redirects (`content/redirects.json`) for hosts that send them
98
142
  * themselves, such as Vercel, which reads them from the build. Redirects from
99
143
  * an address a page or a collection's item has are left out, since pages win,
@@ -110,12 +154,33 @@ async function siteRedirects(root) {
110
154
  if (problems[index] || !from.ok || taken.has(from.path)) return [];
111
155
  return [{
112
156
  source: redirectSource(from.path),
113
- destination: redirect.to.trim(),
157
+ destination: redirectDestination(redirect.to),
114
158
  permanent: true
115
159
  }];
116
160
  });
117
161
  }
118
162
  /**
163
+ * Pages opened at the wrong one of the Site address and the admin address,
164
+ * sent to the right one before anything loads, for hosts that send redirects,
165
+ * such as Vercel (`vercelAdminRedirects()`). The Site address is
166
+ * `content/site.json`'s own, never a copy's, which leaves the admin address
167
+ * out. Their addresses already have the base path, so Next.js adds none.
168
+ */
169
+ function adminRedirects(root, basePath) {
170
+ const admin = siteAdmin(root);
171
+ if (!admin) return [];
172
+ let siteUrl;
173
+ try {
174
+ siteUrl = JSON.parse(readFileSync(join(root, "content/site.json"), "utf8")).url;
175
+ } catch {
176
+ return [];
177
+ }
178
+ return vercelAdminRedirects(admin, typeof siteUrl === "string" ? siteUrl : void 0, `${basePath}/`).map((rule) => ({
179
+ ...rule,
180
+ basePath: false
181
+ }));
182
+ }
183
+ /**
119
184
  * Sets up Next.js for a Goodfellow site, in `next.config.ts`:
120
185
  * `export default withGoodfellow({ ... })`.
121
186
  *
@@ -127,8 +192,10 @@ async function siteRedirects(root) {
127
192
  * - Serves images as they are unless the site sets an image loader, since static files can't be optimized on request.
128
193
  * - Adds the site's redirects (Site Settings → Redirects) ahead of the site's own, for hosts that
129
194
  * send them, such as Vercel. Others get the pages `goodfellow-next finish` writes at old addresses.
130
- * - For a site with an admin address, adds header rules that sandbox every page there but the
131
- * admin panel's own, for hosts that send headers, such as Vercel.
195
+ * - Adds header rules for hosts that send them, such as Vercel: the media library's SVGs
196
+ * sandboxed, the admin panel unframed, and, for a site with an admin address, every page
197
+ * there sandboxed but the admin panel's own, with redirects sending pages opened at the
198
+ * wrong one of the two addresses to the right one.
132
199
  * - With `{ server: true }`, builds a full-stack site for a server instead of as static
133
200
  * files: see `goodfellowApi()` in `@goodfellow-cms/next/server`.
134
201
  * - In `next dev`, runs the admin panel's local backend, so publishing saves to
@@ -150,21 +217,36 @@ function withGoodfellow(nextConfig = {}, options = {}) {
150
217
  images: imageSettings(nextConfig.images),
151
218
  env: {
152
219
  ...nextConfig.env,
153
- GOODFELLOW_BASE_PATH: basePath
220
+ GOODFELLOW_BASE_PATH: basePath,
221
+ [TRAILING_SLASH_VARIABLE]: String(nextConfig.trailingSlash ?? true)
154
222
  }
155
223
  };
156
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");
157
227
  const dates = join(root, nextConfig.distDir ?? ".next", BUILD_DATES_DIR);
158
228
  await rm(dates, {
159
229
  recursive: true,
160
230
  force: true
161
231
  });
162
232
  process.env[BUILD_DATES_ENV] = dates;
233
+ const mark = join(root, nextConfig.distDir ?? ".next", IMAGE_SIZES_MARK);
234
+ await rm(mark, { force: true });
235
+ if (!options.server && config.images?.unoptimized) {
236
+ process.env[IMAGE_SIZES_ENV] = "1";
237
+ await mkdir(dirname(mark), { recursive: true });
238
+ await writeFile(mark, "");
239
+ } else delete process.env[IMAGE_SIZES_ENV];
163
240
  }
164
241
  if (phase !== PHASE_DEVELOPMENT_SERVER && options.server) {
165
242
  if (siteAdmin(root)) throw new Error("A full-stack site can't have an admin address: its admin panel signs in with the site's own sign-in.");
243
+ const fonts = phase === PHASE_PRODUCTION_BUILD ? await hostSiteFonts(root) : void 0;
166
244
  return {
167
245
  ...config,
246
+ ...fonts && { env: {
247
+ ...config.env,
248
+ GOODFELLOW_FONTS: JSON.stringify(fonts)
249
+ } },
168
250
  images: nextConfig.images,
169
251
  outputFileTracingIncludes: {
170
252
  ...nextConfig.outputFileTracingIncludes,
@@ -174,14 +256,19 @@ function withGoodfellow(nextConfig = {}, options = {}) {
174
256
  "./public/media/**/*"
175
257
  ]
176
258
  },
177
- redirects: async () => [...await siteRedirects(root), ...await nextConfig.redirects?.() ?? []]
259
+ redirects: async () => [...await siteRedirects(root), ...await nextConfig.redirects?.() ?? []],
260
+ headers: async () => [...siteHeaders(root), ...await nextConfig.headers?.() ?? []]
178
261
  };
179
262
  }
180
263
  if (phase !== PHASE_DEVELOPMENT_SERVER) return {
181
264
  output: "export",
182
265
  ...config,
183
- redirects: async () => [...await siteRedirects(root), ...await nextConfig.redirects?.() ?? []],
184
- ...siteAdmin(root) && { headers: async () => [...adminHeaders(root), ...await nextConfig.headers?.() ?? []] }
266
+ redirects: async () => [
267
+ ...adminRedirects(root, basePath),
268
+ ...await siteRedirects(root),
269
+ ...await nextConfig.redirects?.() ?? []
270
+ ],
271
+ ...(siteAdmin(root) || process.env.VERCEL) && { headers: async () => [...siteHeaders(root), ...await nextConfig.headers?.() ?? []] }
185
272
  };
186
273
  const port = await startDevApi(root);
187
274
  const rewrite = {
@@ -216,4 +303,4 @@ function withGoodfellow(nextConfig = {}, options = {}) {
216
303
  };
217
304
  }
218
305
  //#endregion
219
- export { withGoodfellow };
306
+ export { redirectDestination, withGoodfellow };
@@ -1,4 +1,4 @@
1
- import { i as loadSiteConfig, t as buildPluginFrame } from "./plugins-K5i8XztM.js";
1
+ import { i as loadSiteConfig, t as buildPluginFrame } from "./plugins-UKkfu0Uj.js";
2
2
  import { DEV_EDITOR_PREFIX, EDITOR_PRELUDE, editorPage, pluginId } from "@goodfellow-cms/core";
3
3
  import { existsSync } from "node:fs";
4
4
  import { mkdir, readFile, writeFile } from "node:fs/promises";