@goodfellow-cms/next 0.6.0 → 0.8.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.
@@ -10,11 +10,13 @@
10
10
  // content is kept in a repository of its own.
11
11
  // `goodfellow-next import wordpress <file or address> [--dry-run]` imports a WordPress site, as `goodfellow import` does.
12
12
  // `goodfellow-next export wordpress [file]` writes the site as a WordPress export file, as `goodfellow export` does.
13
+ // `goodfellow-next blocks add <item> [--registry <folder>] [--native <app>]` makes a block of a shadcn item, as
14
+ // `goodfellow blocks add` does, and with --native its version for an Expo app.
13
15
  // `goodfellow-next admin add <address>|check|remove` sets the admin address, and `goodfellow-next broker use <address>|remove`
14
16
  // the token broker, in goodfellow.config.tsx, as `goodfellow admin` and `goodfellow broker` do.
15
17
  import { existsSync, readFileSync } from "node:fs";
16
18
  import { rm } from "node:fs/promises";
17
- import { relative } from "node:path";
19
+ import { relative, resolve } from "node:path";
18
20
  import { parseArgs } from "node:util";
19
21
  import { buildNotes, CLOUDFLARE_REDIRECTS_LIMIT, configAdmin, formatUpdateResult } from "@goodfellow-cms/core";
20
22
  import { reportSiteProblems } from "@goodfellow-cms/core/accessibility";
@@ -39,8 +41,10 @@ import {
39
41
  update,
40
42
  } from "@goodfellow-cms/core/node";
41
43
  import { writePagesSearchIndex, writeSearchIndex } from "@goodfellow-cms/core/search-index";
44
+ import { addShadcnBlock, blockWorks, formatShadcnBlock, ShadcnBlockError } from "@goodfellow-cms/core/shadcn";
42
45
  import { WordPressReadError } from "@goodfellow-cms/core/wordpress";
43
46
  import {
47
+ blockPageRender,
44
48
  buildFileFolders,
45
49
  builtPages,
46
50
  checkPages,
@@ -50,9 +54,12 @@ import {
50
54
  NEXT_SITE_FILES,
51
55
  writeBuildRecordFile,
52
56
  writeCalendarFiles,
57
+ writeContentApi,
53
58
  writeEditorPolicy,
59
+ writeExportHeaders,
54
60
  writeFeedFiles,
55
61
  writeFontFiles,
62
+ writeImageFiles,
56
63
  writePageLanguages,
57
64
  writePluginFiles,
58
65
  writeRedirectFiles,
@@ -80,6 +87,8 @@ if (command === "finish" && dir === "--server") {
80
87
  if (calendars > 0) console.log(`Wrote ${calendars} calendar files, in public/calendars`);
81
88
  const feeds = await writeFeedFiles("public");
82
89
  if (feeds > 0) console.log(`Wrote ${feeds} feeds, in public/feeds`);
90
+ const api = await writeContentApi("public");
91
+ if (api !== undefined) console.log(`Wrote the content API, with ${api} pages, in public/api`);
83
92
  // Before the pages are read, so the search index and the checks see each page's language.
84
93
  const languages = await writePageLanguages(undefined);
85
94
  if (languages > 0) console.log(`Marked ${languages} pages with their language`);
@@ -227,6 +236,31 @@ if (command === "export") {
227
236
  process.exit(0);
228
237
  }
229
238
  }
239
+ if (command === "blocks") {
240
+ const { positionals, values } = parseArgs({
241
+ args: process.argv.slice(3),
242
+ allowPositionals: true,
243
+ options: { registry: { type: "string" }, native: { type: "string" } },
244
+ });
245
+ const [action, item] = positionals;
246
+ if (action === "add" && item) {
247
+ try {
248
+ const result = await addShadcnBlock({
249
+ root: process.cwd(),
250
+ item,
251
+ ...(values.registry && { registry: resolve(values.registry) }),
252
+ ...(values.native && { native: resolve(values.native) }),
253
+ render: blockPageRender("."),
254
+ });
255
+ console.log(formatShadcnBlock(result));
256
+ process.exit(blockWorks(result) ? 0 : 1);
257
+ } catch (error) {
258
+ if (!(error instanceof ShadcnBlockError)) throw error;
259
+ console.error(error.message);
260
+ process.exit(1);
261
+ }
262
+ }
263
+ }
230
264
  if (command !== "finish" && command !== "index") {
231
265
  console.error(
232
266
  [
@@ -239,6 +273,7 @@ if (command !== "finish" && command !== "index") {
239
273
  " goodfellow-next content link|copy [folder]",
240
274
  " goodfellow-next import wordpress <file or address> [--dry-run]",
241
275
  " goodfellow-next export wordpress [file]",
276
+ " goodfellow-next blocks add <item> [--registry <folder>] [--native <app>]",
242
277
  " goodfellow-next admin add <address> | check | remove",
243
278
  " goodfellow-next broker use <address> | remove",
244
279
  "",
@@ -269,6 +304,11 @@ if (command !== "finish" && command !== "index") {
269
304
  " says what it would bring across and changes nothing.",
270
305
  "export Writes this site as a WordPress export file (default: named after the site),",
271
306
  " which WordPress imports with Tools → Import → WordPress.",
307
+ "blocks Installs a shadcn registry item (button, @acme/hero, or an item's address) with",
308
+ " shadcn's own CLI, makes a block of its component, with fields from its props, and",
309
+ " says what it couldn't do. With --registry, writes the block into the block registry",
310
+ " in folder instead. Needs TypeScript 7 as typescript-7, since Next.js builds with 5:",
311
+ " npm install --save-dev typescript-7@npm:typescript@7.0.2",
272
312
  "admin The admin panel's address of its own, such as admin.example.org, in",
273
313
  " goodfellow.config.tsx: add names it; check checks it once the site has rebuilt with",
274
314
  " it, then starts the admin panel only there; remove stops using it.",
@@ -288,6 +328,8 @@ if (command === "finish") {
288
328
  if (calendars > 0) console.log(`Wrote ${calendars} calendar files`);
289
329
  const feeds = await writeFeedFiles(dir);
290
330
  if (feeds > 0) console.log(`Wrote ${feeds} feeds`);
331
+ const api = await writeContentApi(dir);
332
+ if (api !== undefined) console.log(`Wrote the content API, with ${api} pages, in api/`);
291
333
  const languages = await writePageLanguages(dir);
292
334
  if (languages > 0) console.log(`Marked ${languages} pages with their language`);
293
335
  // Before the redirects' pages, which aren't the site's.
@@ -299,7 +341,10 @@ if (command === "finish") {
299
341
  `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.`,
300
342
  );
301
343
  }
302
- for (const line of buildNotes(await writeFontFiles(dir))) console.log(line);
344
+ // Cloudflare Pages' header rules, after the site's own public/_headers.
345
+ await writeExportHeaders(dir);
346
+ const images = await writeImageFiles(dir);
347
+ for (const line of buildNotes({ ...(await writeFontFiles(dir)), ...(images && { images }) })) console.log(line);
303
348
  // Plugins' admin frames and build steps, once Goodfellow has written the rest, before the index and the guards.
304
349
  const plugins = await writePluginFiles(dir);
305
350
  if (plugins.frames.length > 0) console.log(`Wrote the admin frames of ${plugins.frames.join(", ")}`);
package/dist/admin.js CHANGED
@@ -6,7 +6,7 @@ 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,21 @@ 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
+ * Opens the Search box's results with Next.js's navigation, and loads them
21
+ * ahead when they're pointed at or reached with the keyboard, as `next/link`
22
+ * does. The Search box writes its results itself (`searchScript()`), as plain
23
+ * links, so pages built with `goodfellow build` need no React for them.
24
+ */
25
+ export declare function SearchResultLinks({ basePath }: {
26
+ basePath?: string;
27
+ }): null;
18
28
  /**
19
29
  * On a site in several languages, says the page's language on `<html>`, which
20
30
  * the root layout shared by every page can't. `goodfellow-next finish` writes
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { CurrentPathProvider } from "@goodfellow-cms/react";
3
- import { usePathname } from "next/navigation";
3
+ import { usePathname, useRouter } from "next/navigation";
4
4
  import { useEffect } from "react";
5
5
  import Image from "next/image";
6
6
  import Link from "next/link";
@@ -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,52 @@ 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
+ * Opens the Search box's results with Next.js's navigation, and loads them
55
+ * ahead when they're pointed at or reached with the keyboard, as `next/link`
56
+ * does. The Search box writes its results itself (`searchScript()`), as plain
57
+ * links, so pages built with `goodfellow build` need no React for them.
58
+ */
59
+ function SearchResultLinks({ basePath = "" }) {
60
+ const router = useRouter();
61
+ useEffect(() => {
62
+ const prefix = trimSlash(basePath) === "/" ? "" : trimSlash(basePath);
63
+ /** A result's address within the site, without the base path, which Next.js's router adds. */
64
+ const target = (event) => {
65
+ const link = event.target?.closest?.("[data-gf-search-box] a[href]");
66
+ if (!(link instanceof HTMLAnchorElement) || link.target && link.target !== "_self") return void 0;
67
+ const url = new URL(link.href, location.href);
68
+ if (url.origin !== location.origin) return void 0;
69
+ if (prefix && url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`)) return void 0;
70
+ return `${url.pathname.slice(prefix.length) || "/"}${url.search}${url.hash}`;
71
+ };
72
+ const open = (event) => {
73
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
74
+ const to = target(event);
75
+ if (!to) return;
76
+ event.preventDefault();
77
+ router.push(to);
78
+ };
79
+ const prefetch = (event) => {
80
+ const to = target(event);
81
+ if (to) router.prefetch(to);
82
+ };
83
+ document.addEventListener("click", open);
84
+ document.addEventListener("pointerover", prefetch);
85
+ document.addEventListener("focusin", prefetch);
86
+ return () => {
87
+ document.removeEventListener("click", open);
88
+ document.removeEventListener("pointerover", prefetch);
89
+ document.removeEventListener("focusin", prefetch);
90
+ };
91
+ }, [router, basePath]);
92
+ return null;
93
+ }
94
+ /**
52
95
  * On a site in several languages, says the page's language on `<html>`, which
53
96
  * the root layout shared by every page can't. `goodfellow-next finish` writes
54
97
  * it into exported pages; this keeps it right as visitors move between pages.
@@ -74,4 +117,4 @@ function ByLanguage({ variants }) {
74
117
  });
75
118
  }
76
119
  //#endregion
77
- export { ByLanguage, CurrentMenuLinks, NextImage, NextLink, PageLanguage };
120
+ export { ByLanguage, CurrentMenuLinks, NextImage, NextLink, PageLanguage, SearchResultLinks };
package/dist/config.d.ts CHANGED
@@ -23,8 +23,10 @@ export interface GoodfellowNextOptions {
23
23
  * - Serves images as they are unless the site sets an image loader, since static files can't be optimized on request.
24
24
  * - Adds the site's redirects (Site Settings → Redirects) ahead of the site's own, for hosts that
25
25
  * 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.
26
+ * - Adds header rules for hosts that send them, such as Vercel: the media library's SVGs
27
+ * sandboxed, the admin panel unframed, and, for a site with an admin address, every page
28
+ * there sandboxed but the admin panel's own, with redirects sending pages opened at the
29
+ * wrong one of the two addresses to the right one.
28
30
  * - With `{ server: true }`, builds a full-stack site for a server instead of as static
29
31
  * files: see `goodfellowApi()` in `@goodfellow-cms/next/server`.
30
32
  * - 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, contentRepositoryProblem, fileSystemSource, handleDevApi, isLocalOrigin, localFileStore, localRegistryDir } from "@goodfellow-cms/core/node";
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";
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,13 +61,13 @@ 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-EIZQe3qO.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-eLJalBij.js").then((n) => n.a)).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) => {
@@ -101,7 +121,7 @@ function redirectSource(path) {
101
121
  * Content with a problem gives none, and the build reports the problem.
102
122
  */
103
123
  async function siteRedirects(root) {
104
- const content = await loadSiteContent(fileSystemSource(root)).then(shownContent, () => void 0);
124
+ const content = await loadSiteContent(builtSiteSource(root)).then(shownContent, () => void 0);
105
125
  if (!content?.redirects) return [];
106
126
  const taken = new Set(allPages(content).map((page) => page.path));
107
127
  const problems = redirectProblems(content.redirects);
@@ -116,6 +136,27 @@ async function siteRedirects(root) {
116
136
  });
117
137
  }
118
138
  /**
139
+ * Pages opened at the wrong one of the Site address and the admin address,
140
+ * sent to the right one before anything loads, for hosts that send redirects,
141
+ * such as Vercel (`vercelAdminRedirects()`). The Site address is
142
+ * `content/site.json`'s own, never a copy's, which leaves the admin address
143
+ * out. Their addresses already have the base path, so Next.js adds none.
144
+ */
145
+ function adminRedirects(root, basePath) {
146
+ const admin = siteAdmin(root);
147
+ if (!admin) return [];
148
+ let siteUrl;
149
+ try {
150
+ siteUrl = JSON.parse(readFileSync(join(root, "content/site.json"), "utf8")).url;
151
+ } catch {
152
+ return [];
153
+ }
154
+ return vercelAdminRedirects(admin, typeof siteUrl === "string" ? siteUrl : void 0, `${basePath}/`).map((rule) => ({
155
+ ...rule,
156
+ basePath: false
157
+ }));
158
+ }
159
+ /**
119
160
  * Sets up Next.js for a Goodfellow site, in `next.config.ts`:
120
161
  * `export default withGoodfellow({ ... })`.
121
162
  *
@@ -127,8 +168,10 @@ async function siteRedirects(root) {
127
168
  * - Serves images as they are unless the site sets an image loader, since static files can't be optimized on request.
128
169
  * - Adds the site's redirects (Site Settings → Redirects) ahead of the site's own, for hosts that
129
170
  * 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.
171
+ * - Adds header rules for hosts that send them, such as Vercel: the media library's SVGs
172
+ * sandboxed, the admin panel unframed, and, for a site with an admin address, every page
173
+ * there sandboxed but the admin panel's own, with redirects sending pages opened at the
174
+ * wrong one of the two addresses to the right one.
132
175
  * - With `{ server: true }`, builds a full-stack site for a server instead of as static
133
176
  * files: see `goodfellowApi()` in `@goodfellow-cms/next/server`.
134
177
  * - In `next dev`, runs the admin panel's local backend, so publishing saves to
@@ -160,11 +203,23 @@ function withGoodfellow(nextConfig = {}, options = {}) {
160
203
  force: true
161
204
  });
162
205
  process.env[BUILD_DATES_ENV] = dates;
206
+ const mark = join(root, nextConfig.distDir ?? ".next", IMAGE_SIZES_MARK);
207
+ await rm(mark, { force: true });
208
+ if (!options.server && config.images?.unoptimized) {
209
+ process.env[IMAGE_SIZES_ENV] = "1";
210
+ await mkdir(dirname(mark), { recursive: true });
211
+ await writeFile(mark, "");
212
+ } else delete process.env[IMAGE_SIZES_ENV];
163
213
  }
164
214
  if (phase !== PHASE_DEVELOPMENT_SERVER && options.server) {
165
215
  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.");
216
+ const fonts = phase === PHASE_PRODUCTION_BUILD ? await hostSiteFonts(root) : void 0;
166
217
  return {
167
218
  ...config,
219
+ ...fonts && { env: {
220
+ ...config.env,
221
+ GOODFELLOW_FONTS: JSON.stringify(fonts)
222
+ } },
168
223
  images: nextConfig.images,
169
224
  outputFileTracingIncludes: {
170
225
  ...nextConfig.outputFileTracingIncludes,
@@ -174,14 +229,19 @@ function withGoodfellow(nextConfig = {}, options = {}) {
174
229
  "./public/media/**/*"
175
230
  ]
176
231
  },
177
- redirects: async () => [...await siteRedirects(root), ...await nextConfig.redirects?.() ?? []]
232
+ redirects: async () => [...await siteRedirects(root), ...await nextConfig.redirects?.() ?? []],
233
+ headers: async () => [...siteHeaders(root), ...await nextConfig.headers?.() ?? []]
178
234
  };
179
235
  }
180
236
  if (phase !== PHASE_DEVELOPMENT_SERVER) return {
181
237
  output: "export",
182
238
  ...config,
183
- redirects: async () => [...await siteRedirects(root), ...await nextConfig.redirects?.() ?? []],
184
- ...siteAdmin(root) && { headers: async () => [...adminHeaders(root), ...await nextConfig.headers?.() ?? []] }
239
+ redirects: async () => [
240
+ ...adminRedirects(root, basePath),
241
+ ...await siteRedirects(root),
242
+ ...await nextConfig.redirects?.() ?? []
243
+ ],
244
+ ...(siteAdmin(root) || process.env.VERCEL) && { headers: async () => [...siteHeaders(root), ...await nextConfig.headers?.() ?? []] }
185
245
  };
186
246
  const port = await startDevApi(root);
187
247
  const rewrite = {
@@ -1,4 +1,4 @@
1
- import { i as loadSiteConfig, t as buildPluginFrame } from "./plugins-eLJalBij.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";
package/dist/export.d.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { PluginTaskRequest, SelfHostedResult, SiteProblem, TaskResultFile } from "@goodfellow-cms/core";
2
- import { SiteFile } from "@goodfellow-cms/core/node";
1
+ import { GoodfellowConfig, PluginTaskRequest, SelfHostedResult, SiteProblem, TaskResultFile } from "@goodfellow-cms/core";
2
+ import { ImageSizesResult, SiteFile } from "@goodfellow-cms/core/node";
3
+ import { PageRender } from "@goodfellow-cms/core/shadcn";
3
4
  //#region src/site-files.d.ts
4
5
  /**
5
6
  * The Next.js starter's files that `goodfellow-next update` keeps up to date
@@ -20,6 +21,14 @@ export declare function writeCalendarFiles(outDir: string, root?: string): Promi
20
21
  * build` does. Returns how many it wrote: none without the site's address.
21
22
  */
22
23
  export declare function writeFeedFiles(outDir: string, root?: string): Promise<number>;
24
+ /**
25
+ * Writes the content API (`/api/`) into the export, as `goodfellow build`
26
+ * does: the site's content as JSON, each page as it's shown, prepared in Node
27
+ * with the site's config as `goodfellow build` prepares it, so both write the
28
+ * same files. Returns how many pages it has, or `undefined` where the
29
+ * config's `api` is `false`.
30
+ */
31
+ export declare function writeContentApi(outDir: string, root?: string): Promise<number | undefined>;
23
32
  /**
24
33
  * The folders the calendar files and feeds go in: `calendars/` and `feeds/`,
25
34
  * and on a site in several languages, the same under each other language's
@@ -75,6 +84,19 @@ export interface RedirectsWritten {
75
84
  * has a page at keeps it.
76
85
  */
77
86
  export declare function writeRedirectFiles(outDir: string, root?: string): Promise<RedirectsWritten>;
87
+ /**
88
+ * Writes the smaller copies of the media library's photos that the export's
89
+ * pages list in their `srcset` (`writeImageSizes()`), with sharp, where
90
+ * `next build` said they list them (`IMAGE_SIZES_MARK`): a static export
91
+ * whose images Next.js doesn't optimize. `undefined` where they don't.
92
+ */
93
+ export declare function writeImageFiles(outDir: string, root?: string): Promise<ImageSizesResult | undefined>;
94
+ /**
95
+ * Adds Cloudflare Pages' header rules for every site to the export's
96
+ * `_headers`, as `goodfellow build` does (`writeSiteHeaders()`), from the
97
+ * admin address and broker the exported `goodfellow-site.json` names.
98
+ */
99
+ export declare function writeExportHeaders(outDir: string, root?: string): Promise<void>;
78
100
  /**
79
101
  * For a site with an admin address (its config's `admin`, which the exported
80
102
  * `goodfellow-site.json` carries), puts the guard first in every page of the
@@ -136,8 +158,16 @@ export declare function checkPages(pages: readonly BuiltPage[], root?: string):
136
158
  /**
137
159
  * Runs one of a plugin's tasks for the admin panel, as `goodfellow task`
138
160
  * does for other sites, with the site's config loaded in Node, and writes its
139
- * answer sealed to the request's key, even when the task fails. Secrets are
140
- * taken out of the environment before the config loads.
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.
141
163
  */
142
164
  export declare function writeTaskResult(root: string, request: PluginTaskRequest, out?: string): Promise<TaskResultFile>;
165
+ /** The site's config, loaded in Node, for `writeTaskResult()`'s process that loads it apart from the job's secrets. */
166
+ export declare function loadTaskConfig(root: string): Promise<GoodfellowConfig>;
167
+ /**
168
+ * Renders a page with a block from its own file as well as the site's config's,
169
+ * for `goodfellow-next blocks add`'s check that a block it made renders: both
170
+ * loaded in Node, as `goodfellow-next finish` loads the config.
171
+ */
172
+ export declare function blockPageRender(root: string): PageRender;
143
173
  //#endregion
package/dist/export.js CHANGED
@@ -1,11 +1,13 @@
1
- import { i as loadSiteConfig, n as buildPluginFrames, r as configHasPlugins } from "./plugins-eLJalBij.js";
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";
2
3
  import { r as NEXT_SITE_FILES } from "./site-files-BeLnLxCl.js";
3
- 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, taskRequestProblem } from "@goodfellow-cms/core";
4
- import { BUILD_DATES_DIR, fileSystemSource, guardBuiltSite, reportedDate, runPluginBuildSteps, runPluginTask, selfHostFonts, taskSecretsFrom, writeBuildRecord } from "@goodfellow-cms/core/node";
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
+ import { BUILD_DATES_DIR, builtSiteSource, guardBuiltSite, reportedDate, runPluginBuildSteps, runPluginTask, selfHostFonts, sitePluginsApart, taskSecretsFrom, writeBuildRecord, writeImageSizes, writeSiteHeaders } from "@goodfellow-cms/core/node";
5
6
  import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
6
7
  import { dirname, join, relative, resolve } from "node:path";
7
8
  import { createHash } from "node:crypto";
8
9
  import { checkBuiltPages } from "@goodfellow-cms/core/accessibility";
10
+ import { CONTENT_API_DIR } from "@goodfellow-cms/core/content-api";
9
11
  //#region src/export.ts
10
12
  /**
11
13
  * Writes the calendar files of the site's collections of events into the
@@ -13,7 +15,7 @@ import { checkBuiltPages } from "@goodfellow-cms/core/accessibility";
13
15
  * how many it wrote.
14
16
  */
15
17
  async function writeCalendarFiles(outDir, root = process.cwd()) {
16
- const files = calendarFiles(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
18
+ const files = calendarFiles(shownContent(await loadSiteContent(builtSiteSource(resolve(root)))));
17
19
  for (const file of files) {
18
20
  const target = join(outDir, file.path.slice(1));
19
21
  await mkdir(dirname(target), { recursive: true });
@@ -26,7 +28,7 @@ async function writeCalendarFiles(outDir, root = process.cwd()) {
26
28
  * build` does. Returns how many it wrote: none without the site's address.
27
29
  */
28
30
  async function writeFeedFiles(outDir, root = process.cwd()) {
29
- const files = feedFiles(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
31
+ const files = feedFiles(shownContent(await loadSiteContent(builtSiteSource(resolve(root)))));
30
32
  for (const file of files) {
31
33
  const target = join(outDir, file.path.slice(1));
32
34
  await mkdir(dirname(target), { recursive: true });
@@ -35,12 +37,38 @@ async function writeFeedFiles(outDir, root = process.cwd()) {
35
37
  return files.length;
36
38
  }
37
39
  /**
40
+ * Writes the content API (`/api/`) into the export, as `goodfellow build`
41
+ * does: the site's content as JSON, each page as it's shown, prepared in Node
42
+ * with the site's config as `goodfellow build` prepares it, so both write the
43
+ * same files. Returns how many pages it has, or `undefined` where the
44
+ * config's `api` is `false`.
45
+ */
46
+ async function writeContentApi(outDir, root = process.cwd()) {
47
+ const dir = resolve(root);
48
+ const config = await loadSiteConfig(dir);
49
+ if (config.api === false) return void 0;
50
+ const { contentApi } = await import("@goodfellow-cms/react/server");
51
+ const content = shownContent(await loadSiteContent(builtSiteSource(dir)));
52
+ const pages = sitePages(content, todayIn(content.settings.timeZone), config.blocks);
53
+ const files = await contentApi(config, content, pages, { base: await exportBasePath(dir) });
54
+ await rm(join(outDir, CONTENT_API_DIR), {
55
+ recursive: true,
56
+ force: true
57
+ });
58
+ for (const file of files) {
59
+ const target = join(outDir, file.path.slice(1));
60
+ await mkdir(dirname(target), { recursive: true });
61
+ await writeFile(target, file.content);
62
+ }
63
+ return pages.length;
64
+ }
65
+ /**
38
66
  * The folders the calendar files and feeds go in: `calendars/` and `feeds/`,
39
67
  * and on a site in several languages, the same under each other language's
40
68
  * code, such as `es/feeds/`.
41
69
  */
42
70
  async function buildFileFolders(root = process.cwd()) {
43
- const { settings } = await loadSiteContent(fileSystemSource(resolve(root)));
71
+ const { settings } = await loadSiteContent(builtSiteSource(resolve(root)));
44
72
  return siteLanguages(settings).flatMap((language) => [CALENDARS_DIR, FEEDS_DIR].map((dir) => `${language.prefix.slice(1)}${language.prefix ? "/" : ""}${dir}`));
45
73
  }
46
74
  /** The `basePath` the export was built with, which Next.js records in `.next/routes-manifest.json`. */
@@ -92,7 +120,7 @@ async function exists(path) {
92
120
  * subfolder (`rootRobotsUrl()`).
93
121
  */
94
122
  async function writeFontFiles(outDir, root = process.cwd()) {
95
- const { settings } = await loadSiteContent(fileSystemSource(resolve(root)));
123
+ const { settings } = await loadSiteContent(builtSiteSource(resolve(root)));
96
124
  const fonts = await selfHostFonts(outDir, settings.theme, { base: await exportBasePath(root) });
97
125
  const robotsElsewhere = rootRobotsUrl(settings.url);
98
126
  return {
@@ -109,7 +137,7 @@ async function writeFontFiles(outDir, root = process.cwd()) {
109
137
  */
110
138
  async function writeBuildRecordFile(outDir, root = process.cwd()) {
111
139
  const dir = resolve(root);
112
- const content = await loadSiteContent(fileSystemSource(dir));
140
+ const content = await loadSiteContent(builtSiteSource(dir));
113
141
  const date = await reportedDate(join(dir, ".next", BUILD_DATES_DIR));
114
142
  return (await writeBuildRecord(outDir, {
115
143
  root: dir,
@@ -126,7 +154,7 @@ async function writeBuildRecordFile(outDir, root = process.cwd()) {
126
154
  */
127
155
  async function writeRedirectFiles(outDir, root = process.cwd()) {
128
156
  const dir = resolve(root);
129
- const content = shownContent(await loadSiteContent(fileSystemSource(dir)));
157
+ const content = shownContent(await loadSiteContent(builtSiteSource(dir)));
130
158
  const base = await exportBasePath(dir);
131
159
  const files = redirectFiles(content, {
132
160
  taken: allPages(content).map((page) => page.path),
@@ -150,6 +178,41 @@ async function writeRedirectFiles(outDir, root = process.cwd()) {
150
178
  };
151
179
  }
152
180
  /**
181
+ * Writes the smaller copies of the media library's photos that the export's
182
+ * pages list in their `srcset` (`writeImageSizes()`), with sharp, where
183
+ * `next build` said they list them (`IMAGE_SIZES_MARK`): a static export
184
+ * whose images Next.js doesn't optimize. `undefined` where they don't.
185
+ */
186
+ async function writeImageFiles(outDir, root = process.cwd()) {
187
+ const dir = resolve(root);
188
+ if (!await exists(join(dir, ".next", "goodfellow-image-sizes"))) return void 0;
189
+ return writeImageSizes(dir, outDir, { resize: await sharpResizer() });
190
+ }
191
+ /** Makes the copies with sharp, turned as each photo's details say and without them, unless it can't load here. */
192
+ async function sharpResizer() {
193
+ try {
194
+ const { default: sharp } = await import("sharp");
195
+ return async (image, width) => new Uint8Array(await sharp(image).rotate().resize({ width }).toBuffer());
196
+ } catch {
197
+ return;
198
+ }
199
+ }
200
+ /**
201
+ * Adds Cloudflare Pages' header rules for every site to the export's
202
+ * `_headers`, as `goodfellow build` does (`writeSiteHeaders()`), from the
203
+ * admin address and broker the exported `goodfellow-site.json` names.
204
+ */
205
+ async function writeExportHeaders(outDir, root = process.cwd()) {
206
+ const text = await readFile(join(outDir, SITE_DESCRIPTION_PATH), "utf8").catch(() => void 0);
207
+ const parsed = text === void 0 ? void 0 : parseSiteDescription(text);
208
+ const description = parsed?.ok ? parsed.description : void 0;
209
+ await writeSiteHeaders(outDir, {
210
+ base: `${await exportBasePath(resolve(root))}/`,
211
+ ...description?.admin && { admin: description.admin },
212
+ ...description?.broker && { broker: description.broker }
213
+ });
214
+ }
215
+ /**
153
216
  * For a site with an admin address (its config's `admin`, which the exported
154
217
  * `goodfellow-site.json` carries), puts the guard first in every page of the
155
218
  * export and adds Cloudflare Pages' header rules, as `goodfellow build` does.
@@ -162,7 +225,7 @@ async function guardExport(outDir, root = process.cwd()) {
162
225
  const admin = parsed?.ok ? parsed.description.admin : void 0;
163
226
  if (!admin) return void 0;
164
227
  const dir = resolve(root);
165
- const content = await loadSiteContent(fileSystemSource(dir));
228
+ const content = await loadSiteContent(builtSiteSource(dir));
166
229
  return guardBuiltSite(outDir, {
167
230
  admin,
168
231
  ...parsed?.ok && parsed.description.broker && { broker: parsed.description.broker },
@@ -246,7 +309,7 @@ function withPageLanguage(html, language) {
246
309
  */
247
310
  async function writePageLanguages(outDir, root = process.cwd()) {
248
311
  const dir = resolve(root);
249
- const { settings } = await loadSiteContent(fileSystemSource(dir));
312
+ const { settings } = await loadSiteContent(builtSiteSource(dir));
250
313
  if (!hasLanguages(settings)) return 0;
251
314
  const base = outDir ?? join(dir, ".next/server/app");
252
315
  const files = (await readdir(base, { recursive: true })).map((file) => file.split("\\").join("/")).filter((file) => file.endsWith(".html") && !/^(admin|_next|pagefind)\//.test(file));
@@ -314,7 +377,7 @@ function contentFile(pages, path) {
314
377
  * Pages that aren't the site's, such as Next.js's own, are left out.
315
378
  */
316
379
  async function checkPages(pages, root = process.cwd()) {
317
- const known = allPages(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
380
+ const known = allPages(shownContent(await loadSiteContent(builtSiteSource(resolve(root)))));
318
381
  return checkBuiltPages(pages.flatMap((page) => {
319
382
  const path = page.url.replace(/(.)\/$/, "$1");
320
383
  const file = contentFile(known, path);
@@ -328,8 +391,8 @@ async function checkPages(pages, root = process.cwd()) {
328
391
  /**
329
392
  * Runs one of a plugin's tasks for the admin panel, as `goodfellow task`
330
393
  * does for other sites, with the site's config loaded in Node, and writes its
331
- * answer sealed to the request's key, even when the task fails. Secrets are
332
- * taken out of the environment before the config loads.
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.
333
396
  */
334
397
  async function writeTaskResult(root, request, out = TASK_RESULT_FILE) {
335
398
  const secrets = taskSecretsFrom(process.env);
@@ -337,7 +400,10 @@ async function writeTaskResult(root, request, out = TASK_RESULT_FILE) {
337
400
  if (problem) throw new Error(problem);
338
401
  let answer;
339
402
  try {
340
- const plugin = ((await loadSiteConfig(resolve(root))).plugins ?? []).find((candidate) => pluginId(candidate.name) === request.plugin);
403
+ const plugin = (await sitePluginsApart(resolve(root), {
404
+ module: import.meta.url,
405
+ export: "loadTaskConfig"
406
+ })).find((candidate) => pluginId(candidate.name) === request.plugin);
341
407
  answer = plugin ? await runPluginTask({
342
408
  root: resolve(root),
343
409
  plugin,
@@ -358,5 +424,33 @@ async function writeTaskResult(root, request, out = TASK_RESULT_FILE) {
358
424
  await writeFile(resolve(root, out), `${JSON.stringify(sealed)}\n`);
359
425
  return sealed;
360
426
  }
427
+ /** The site's config, loaded in Node, for `writeTaskResult()`'s process that loads it apart from the job's secrets. */
428
+ function loadTaskConfig(root) {
429
+ return loadSiteConfig(root);
430
+ }
431
+ /**
432
+ * Renders a page with a block from its own file as well as the site's config's,
433
+ * for `goodfellow-next blocks add`'s check that a block it made renders: both
434
+ * loaded in Node, as `goodfellow-next finish` loads the config.
435
+ */
436
+ function blockPageRender(root) {
437
+ return async (page, block) => {
438
+ const site = resolve(root);
439
+ const config = await loadSiteConfig(site);
440
+ const entry = (await loadSiteModule(site, join(site, block.entry))).default;
441
+ const { createPageRenderer } = await import("@goodfellow-cms/react/server");
442
+ return createPageRenderer({
443
+ ...config,
444
+ blocks: {
445
+ ...config.blocks,
446
+ [block.name]: entry
447
+ }
448
+ })(await loadSiteContent(builtSiteSource(site)), {
449
+ path: "/",
450
+ file: "content/pages/index.json",
451
+ content: page
452
+ });
453
+ };
454
+ }
361
455
  //#endregion
362
- export { EDITOR_PAGES, NEXT_SITE_FILES, buildFileFolders, builtPages, checkPages, exportedPages, fixSegmentFiles, guardExport, writeBuildRecordFile, writeCalendarFiles, writeEditorPolicy, writeFeedFiles, writeFontFiles, writePageLanguages, writePluginFiles, writeRedirectFiles, writeTaskResult };
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 };
@@ -0,0 +1,11 @@
1
+ //#region src/image-sizes-mark.ts
2
+ /**
3
+ * Set while `next build` renders a static export whose images Next.js doesn't
4
+ * optimize, so pages list the smaller copies of the media library's photos
5
+ * (`imageWidths`) that `goodfellow-next finish` writes.
6
+ */
7
+ const IMAGE_SIZES_ENV = "GOODFELLOW_IMAGE_SIZES";
8
+ /** The file in `.next/` that tells `goodfellow-next finish` the pages list them. */
9
+ const IMAGE_SIZES_MARK = "goodfellow-image-sizes";
10
+ //#endregion
11
+ export { IMAGE_SIZES_MARK as n, IMAGE_SIZES_ENV as t };
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
- import { ByLanguage, CurrentMenuLinks, NextImage, NextLink, PageLanguage } from "./components.js";
2
- import { absoluteUrl, colorSchemeMeta, colorSchemeScript, contentForPage, demoContent, feedLinks, findRedirect, fontPreloads, googleFontsUrl, hasLanguages, languageMap, loadSiteContent, normalizePagePath, pageHead, shownContent, siteLanguages, sitePages, sitemapPages, storedMediaSizes, themeToCss, todayIn, withBase } from "@goodfellow-cms/core";
3
- import { BUILD_DATES_ENV, fileSystemSource, fontStandIns, readMediaSizes, readStylesheet, reportDatesTo } from "@goodfellow-cms/core/node";
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";
4
+ import { BUILD_DATES_ENV, builtSiteSource, fileSystemSource, fontStandIns, readMediaSizes, readStylesheet, reportDatesTo } from "@goodfellow-cms/core/node";
4
5
  import { BodyCode, HeadCode, PageBody, PageContent, PageFooter, PageHeader, adminSite, applyPageEntry, createPuckConfigs, describeSite, prepareLayout, preparePage } from "@goodfellow-cms/react";
5
6
  import { notFound, redirect } from "next/navigation";
6
7
  import { cache } from "react";
@@ -19,12 +20,28 @@ function configuredBasePath() {
19
20
  return process.env.GOODFELLOW_BASE_PATH ?? "";
20
21
  }
21
22
  /**
23
+ * Where a full-stack site's build copied its Google Fonts (`hostFonts()`), for
24
+ * the stylesheet at `source`. `withGoodfellow()` puts them in the env, which
25
+ * Next.js writes into this code by its name, so pages it renders later have
26
+ * them too. Static exports have none: `goodfellow-next finish` copies theirs.
27
+ */
28
+ function hostedFonts(source) {
29
+ const value = process.env.GOODFELLOW_FONTS;
30
+ if (!value) return void 0;
31
+ try {
32
+ const fonts = JSON.parse(value);
33
+ return fonts.source === source ? fonts : void 0;
34
+ } catch {
35
+ return;
36
+ }
37
+ }
38
+ /**
22
39
  * Reads the site's content from disk, as visitors see it now: without pages
23
40
  * and items scheduled for later, or hidden again (`shownContent()`). Cached
24
41
  * for the length of one request, so a page and its metadata read it once, and
25
42
  * content edits show on reload in development.
26
43
  */
27
- const loadSite = cache(async (root = process.cwd()) => shownContent(await loadSiteContent(fileSystemSource(root))));
44
+ const loadSite = cache(async (root = process.cwd()) => shownContent(await loadSiteContent(builtSiteSource(root))));
28
45
  function pathOf(segments) {
29
46
  try {
30
47
  return normalizePagePath(`/${(segments ?? []).map((segment) => decodeURIComponent(segment)).join("/")}`);
@@ -39,6 +56,26 @@ function exportedPages(content, blocks) {
39
56
  function findPage(content, blocks, path) {
40
57
  return exportedPages(content, blocks).find((page) => page.path === path);
41
58
  }
59
+ /** Whether the site asks visitors before loading other sites' content. */
60
+ function asks(content) {
61
+ return content.settings.consent !== void 0;
62
+ }
63
+ /** The consent script, after the footer's notice, on a site that asks. */
64
+ function ConsentAsk({ content }) {
65
+ if (!asks(content)) return null;
66
+ return /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: consentScript() } });
67
+ }
68
+ /**
69
+ * What the built-in blocks need in the browser (copying code, the Dark mode
70
+ * switch, search), each a small script that works on what navigations add.
71
+ * `goodfellow build` adds each only to pages that use it; a layout can't know
72
+ * the page, so it has them all, but the Dark mode switch's only where the
73
+ * site has dark mode, since it shows nothing otherwise.
74
+ */
75
+ function BlockScripts({ content }) {
76
+ const scripts = BLOCK_SCRIPTS.filter(({ attribute }) => attribute !== DARK_MODE_ATTRIBUTE || content.settings.theme.darkMode);
77
+ return /* @__PURE__ */ jsx(Fragment, { children: scripts.map(({ attribute, script }) => /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: script() } }, attribute)) });
78
+ }
42
79
  function toAbsolute(siteUrl, url) {
43
80
  if (!url || !siteUrl || !url.startsWith("/") || url.startsWith("//")) return url;
44
81
  return absoluteUrl(siteUrl, url);
@@ -90,17 +127,24 @@ function goodfellowPages(config, options = {}) {
90
127
  media: {
91
128
  ...await readMediaSizes(root),
92
129
  ...storedMediaSizes((await site()).mediaStorage)
93
- }
130
+ },
131
+ ...process.env["GOODFELLOW_IMAGE_SIZES"] && { imageWidths: IMAGE_WIDTHS }
94
132
  });
95
133
  /** The site's theme and fonts, which React moves into the document's head. themeToCss escapes its output. */
96
134
  async function Theme({ content }) {
97
- const fontsUrl = googleFontsUrl(content.settings.theme);
135
+ const googleUrl = googleFontsUrl(content.settings.theme);
136
+ const hosted = googleUrl ? hostedFonts(googleUrl) : void 0;
137
+ const fontsUrl = googleUrl && hosted ? withSelfHostedFonts(googleUrl, hosted, basePath()) : googleUrl;
98
138
  const [fontFiles, standIns] = await Promise.all([fontPreloads(content.settings.theme, readStylesheet), fontStandIns(content.settings.theme)]);
99
- for (const href of fontFiles) preload(href, {
100
- as: "font",
101
- type: "font/woff2",
102
- crossOrigin: ""
103
- });
139
+ for (const file of fontFiles) {
140
+ const href = hosted ? withSelfHostedFonts(file, hosted, basePath()) : file;
141
+ if (hosted && href === file) continue;
142
+ preload(href, {
143
+ as: "font",
144
+ type: "font/woff2",
145
+ crossOrigin: ""
146
+ });
147
+ }
104
148
  const colorScheme = colorSchemeMeta(content.settings.theme);
105
149
  const schemeScript = colorSchemeScript(content.settings.theme);
106
150
  return /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -137,7 +181,7 @@ function goodfellowPages(config, options = {}) {
137
181
  * editor's frames, at `app/admin/editor/page.tsx`.
138
182
  */
139
183
  admin() {
140
- return adminSite(config);
184
+ return adminSiteForCopy(adminSite(config), siteCopyFrom(process.env));
141
185
  },
142
186
  /** For `app/admin/layout.tsx`: the admin panel's title, kept out of search engines, with the site's icon. */
143
187
  async adminMetadata() {
@@ -197,12 +241,22 @@ function goodfellowPages(config, options = {}) {
197
241
  }
198
242
  return /* @__PURE__ */ jsxs(Fragment, { children: [
199
243
  /* @__PURE__ */ jsx(Theme, { content }),
200
- /* @__PURE__ */ jsx(HeadCode, { code: content.code?.head }),
244
+ /* @__PURE__ */ jsx(HeadCode, {
245
+ code: content.code?.head,
246
+ held: heldCode(content.settings.consent, "head"),
247
+ siteUrl: content.settings.url
248
+ }),
201
249
  header,
202
250
  children,
203
251
  footer,
204
252
  /* @__PURE__ */ jsx(CurrentMenuLinks, { basePath: basePath() }),
205
- /* @__PURE__ */ jsx(BodyCode, { code: content.code?.body })
253
+ /* @__PURE__ */ jsx(SearchResultLinks, { basePath: basePath() }),
254
+ /* @__PURE__ */ jsx(BodyCode, {
255
+ code: content.code?.body,
256
+ held: heldCode(content.settings.consent, "body")
257
+ }),
258
+ /* @__PURE__ */ jsx(ConsentAsk, { content }),
259
+ /* @__PURE__ */ jsx(BlockScripts, { content })
206
260
  ] });
207
261
  },
208
262
  /** The page at the route's address, inside `Layout`'s header and footer. */
@@ -237,7 +291,11 @@ function goodfellowPages(config, options = {}) {
237
291
  return /* @__PURE__ */ jsxs(Fragment, { children: [
238
292
  /* @__PURE__ */ jsx("title", { children: pageHead(content, page).title }),
239
293
  /* @__PURE__ */ jsx(Theme, { content }),
240
- /* @__PURE__ */ jsx(HeadCode, { code: content.code?.head }),
294
+ /* @__PURE__ */ jsx(HeadCode, {
295
+ code: content.code?.head,
296
+ held: heldCode(content.settings.consent, "head"),
297
+ siteUrl: content.settings.url
298
+ }),
241
299
  /* @__PURE__ */ jsx(PageBody, {
242
300
  site: prepared.site,
243
301
  pageConfig: prepared.pageConfig,
@@ -246,7 +304,13 @@ function goodfellowPages(config, options = {}) {
246
304
  header: prepared.header,
247
305
  footer: prepared.footer
248
306
  }),
249
- /* @__PURE__ */ jsx(BodyCode, { code: content.code?.body })
307
+ /* @__PURE__ */ jsx(BodyCode, {
308
+ code: content.code?.body,
309
+ held: heldCode(content.settings.consent, "body")
310
+ }),
311
+ /* @__PURE__ */ jsx(ConsentAsk, { content }),
312
+ /* @__PURE__ */ jsx(BlockScripts, { content }),
313
+ /* @__PURE__ */ jsx(SearchResultLinks, { basePath: basePath() })
250
314
  ] });
251
315
  },
252
316
  /** For `app/sitemap.ts`: every page but "Page not found" and pages hidden from search engines, once the site's address is set. */
@@ -271,7 +335,7 @@ function goodfellowPages(config, options = {}) {
271
335
  * edit its content without its code, such as the Claude connector.
272
336
  */
273
337
  siteDescription() {
274
- return Response.json(describeSite(config));
338
+ return Response.json(describedForCopy(describeSite(config), siteCopyFrom(process.env)));
275
339
  },
276
340
  /** For `app/robots.ts`: lets search engines in, and points them at the sitemap once the site's address is set. */
277
341
  async robots() {
@@ -1,5 +1,5 @@
1
1
  import { EDITOR_PRELUDE, INSTALLED_PLUGINS_INDEX, PLUGINS_PATH, framedPlugins, pluginFrameEntry, pluginFramePage, pluginId } from "@goodfellow-cms/core";
2
- import { LICENSES_FILE, licensesPlugin } from "@goodfellow-cms/core/node";
2
+ import { LICENSES_FILE, licensesPlugin, mediaStorageOrigins } from "@goodfellow-cms/core/node";
3
3
  import { existsSync } from "node:fs";
4
4
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
5
  import { isAbsolute, join, resolve } from "node:path";
@@ -28,7 +28,8 @@ var plugins_exports = /* @__PURE__ */ __exportAll({
28
28
  buildPluginFrames: () => buildPluginFrames,
29
29
  configHasPlugins: () => configHasPlugins,
30
30
  findConfigFile: () => findConfigFile,
31
- loadSiteConfig: () => loadSiteConfig
31
+ loadSiteConfig: () => loadSiteConfig,
32
+ loadSiteModule: () => loadSiteModule
32
33
  });
33
34
  const CONFIG_FILES = [
34
35
  "goodfellow.config.tsx",
@@ -55,19 +56,17 @@ async function configHasPlugins(root) {
55
56
  return existsSync(index) && /\binstalledPlugin\(/.test(await readFile(index, "utf8"));
56
57
  }
57
58
  /**
58
- * The site's config, loaded in Node: bundled with Rolldown, with the
59
+ * A module of the site, loaded in Node: bundled with Rolldown, with the
59
60
  * packages it imports left for Node to load and stylesheets left out, then
60
- * imported. Only what plugins need is read from it.
61
+ * imported.
61
62
  */
62
- async function loadSiteConfig(root) {
63
- const configFile = findConfigFile(root);
64
- if (!configFile) throw new Error(`No goodfellow.config.tsx in ${root}.`);
63
+ async function loadSiteModule(root, input) {
65
64
  const { build } = await import("rolldown");
66
65
  const outDir = join(root, "node_modules/.goodfellow");
67
66
  await mkdir(outDir, { recursive: true });
68
- const file = join(outDir, `config-${process.pid}-${Date.now()}.mjs`);
67
+ const file = join(outDir, `module-${process.pid}-${Date.now()}.mjs`);
69
68
  await build({
70
- input: configFile,
69
+ input,
71
70
  cwd: root,
72
71
  platform: "node",
73
72
  tsconfig: existsSync(join(root, "tsconfig.json")) ? join(root, "tsconfig.json") : false,
@@ -91,11 +90,17 @@ async function loadSiteConfig(root) {
91
90
  }
92
91
  });
93
92
  try {
94
- return (await import(pathToFileURL(file).href)).default;
93
+ return await import(pathToFileURL(file).href);
95
94
  } finally {
96
95
  await rm(file, { force: true });
97
96
  }
98
97
  }
98
+ /** The site's config, loaded in Node with `loadSiteModule()`. Only what plugins and checks need is read from it. */
99
+ async function loadSiteConfig(root) {
100
+ const configFile = findConfigFile(root);
101
+ if (!configFile) throw new Error(`No goodfellow.config.tsx in ${root}.`);
102
+ return (await loadSiteModule(root, configFile)).default;
103
+ }
99
104
  /** A plugin's admin module as the bundler imports it: a package's export, or a path from the site's root. */
100
105
  function adminModule(root, specifier) {
101
106
  return specifier.startsWith("./") || specifier.startsWith("../") ? resolve(root, specifier) : specifier;
@@ -119,7 +124,9 @@ async function buildPluginFrame(plugin, { root, dir, watch }) {
119
124
  permissions: plugin.permissions ?? {},
120
125
  scripts: ["plugin.js"],
121
126
  stylesheets: ["plugin.css"],
122
- licenses: LICENSES_FILE
127
+ licenses: LICENSES_FILE,
128
+ editor: Boolean(plugin.editor),
129
+ ...plugin.editor && { media: await mediaStorageOrigins(root) }
123
130
  }));
124
131
  };
125
132
  const options = {
@@ -188,4 +195,4 @@ async function buildPluginFrames(plugins, { root, outDir }) {
188
195
  return built;
189
196
  }
190
197
  //#endregion
191
- export { plugins_exports as a, loadSiteConfig as i, buildPluginFrames as n, configHasPlugins as r, buildPluginFrame as t };
198
+ export { loadSiteModule as a, loadSiteConfig as i, buildPluginFrames as n, plugins_exports as o, configHasPlugins as r, buildPluginFrame as t };
package/dist/server.d.ts CHANGED
@@ -38,6 +38,13 @@ export interface GoodfellowApiOptions {
38
38
  * `plugins/<id>/…`, and runs with everything the server has.
39
39
  */
40
40
  plugins?: AppApiOptions["plugins"];
41
+ /**
42
+ * Whether owners' changes to the site's code, such as adding blocks on the
43
+ * Blocks screen, go to a developer for review (`"review"`, the default),
44
+ * since this server runs that code with its token and keys, or are published
45
+ * (`"publish"`).
46
+ */
47
+ codeChanges?: AppApiOptions["codeChanges"];
41
48
  }
42
49
  /** The route parameters of a catch-all route named `[...path]`. */
43
50
  interface RouteContext {
package/dist/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { ownersOnlyFields } from "@goodfellow-cms/core";
1
2
  import { appApi } from "@goodfellow-cms/core/node";
2
3
  import { aiServer } from "@goodfellow-cms/ai";
3
4
  //#region src/server.ts
@@ -32,7 +33,9 @@ function goodfellowApi(options) {
32
33
  ...options.signOut && { signOut: options.signOut },
33
34
  ...ai && ai.services.length > 0 && { ai },
34
35
  ...options.onError && { onError: options.onError },
35
- ...options.plugins && { plugins: options.plugins }
36
+ ...options.plugins && { plugins: options.plugins },
37
+ ...options.codeChanges && { codeChanges: options.codeChanges },
38
+ ownersOnly: ownersOnlyFields(options.config.blocks ?? {})
36
39
  });
37
40
  return api;
38
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodfellow-cms/next",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Goodfellow pages and the admin panel in a Next.js site.",
5
5
  "keywords": [
6
6
  "goodfellow",
@@ -63,23 +63,24 @@
63
63
  "dist"
64
64
  ],
65
65
  "dependencies": {
66
- "@goodfellow-cms/ai": "0.6.0",
67
- "@goodfellow-cms/core": "0.6.0",
68
- "@goodfellow-cms/react": "0.6.0",
69
- "rolldown": "1.2.12"
66
+ "@goodfellow-cms/ai": "0.8.0",
67
+ "@goodfellow-cms/core": "0.8.0",
68
+ "@goodfellow-cms/react": "0.8.0",
69
+ "rolldown": "1.2.12",
70
+ "sharp": "0.35.5"
70
71
  },
71
72
  "peerDependencies": {
72
- "@goodfellow-cms/admin": "0.6.0",
73
+ "@goodfellow-cms/admin": "0.8.0",
73
74
  "@puckeditor/core": "0.23.0",
74
75
  "next": "^16.0.0",
75
76
  "react": "^19.0.0",
76
77
  "react-dom": "^19.0.0"
77
78
  },
78
79
  "devDependencies": {
79
- "@goodfellow-cms/admin": "0.6.0",
80
- "@goodfellow-cms/bitbucket": "0.6.0",
81
- "@goodfellow-cms/github": "0.6.0",
82
- "@goodfellow-cms/gitlab": "0.6.0",
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",
83
84
  "@puckeditor/core": "0.23.0",
84
85
  "@tailwindcss/browser": "4.3.3",
85
86
  "@types/react": "^19.2.0",