@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.
package/dist/export.d.ts CHANGED
@@ -1,12 +1,7 @@
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
3
  import { PageRender } from "@goodfellow-cms/core/shadcn";
4
4
  //#region src/site-files.d.ts
5
- /**
6
- * The Next.js starter's files that `goodfellow-next update` keeps up to date
7
- * where nobody has changed them, as the starter now has them. A test checks
8
- * they match `templates/next`.
9
- */
10
5
  export declare const NEXT_SITE_FILES: readonly SiteFile[];
11
6
  //#endregion
12
7
  //#region src/export.d.ts
@@ -35,6 +30,15 @@ export declare function writeContentApi(outDir: string, root?: string): Promise<
35
30
  * code, such as `es/feeds/`.
36
31
  */
37
32
  export declare function buildFileFolders(root?: string): Promise<string[]>;
33
+ /** The folder `next build` wrote into, as `withGoodfellow()` recorded it: the config's `distDir`, or `.next`. */
34
+ export declare function nextDistDir(root?: string): Promise<string>;
35
+ /**
36
+ * Whether the export's pages end their addresses with `/`, as Next.js's
37
+ * `trailingSlash` says, which Next.js records in
38
+ * `.next/required-server-files.json`. `true` where it can't be read, as
39
+ * `withGoodfellow()` sets it.
40
+ */
41
+ export declare function exportTrailingSlash(root?: string): Promise<boolean>;
38
42
  /** What `writePluginFiles()` wrote and ran. */
39
43
  export interface PluginFilesWritten {
40
44
  /** The plugins whose admin frames were written. */
@@ -84,6 +88,19 @@ export interface RedirectsWritten {
84
88
  * has a page at keeps it.
85
89
  */
86
90
  export declare function writeRedirectFiles(outDir: string, root?: string): Promise<RedirectsWritten>;
91
+ /**
92
+ * Writes the smaller copies of the media library's photos that the export's
93
+ * pages list in their `srcset` (`writeImageSizes()`), with sharp, where
94
+ * `next build` said they list them (`IMAGE_SIZES_MARK`): a static export
95
+ * whose images Next.js doesn't optimize. `undefined` where they don't.
96
+ */
97
+ export declare function writeImageFiles(outDir: string, root?: string): Promise<ImageSizesResult | undefined>;
98
+ /**
99
+ * Adds Cloudflare Pages' header rules for every site to the export's
100
+ * `_headers`, as `goodfellow build` does (`writeSiteHeaders()`), from the
101
+ * admin address and broker the exported `goodfellow-site.json` names.
102
+ */
103
+ export declare function writeExportHeaders(outDir: string, root?: string): Promise<void>;
87
104
  /**
88
105
  * For a site with an admin address (its config's `admin`, which the exported
89
106
  * `goodfellow-site.json` carries), puts the guard first in every page of the
@@ -143,12 +160,18 @@ export declare function exportedPages(outDir: string): Promise<BuiltPage[]>;
143
160
  */
144
161
  export declare function checkPages(pages: readonly BuiltPage[], root?: string): Promise<SiteProblem[]>;
145
162
  /**
146
- * Runs one of a plugin's tasks for the admin panel, as `goodfellow task`
147
- * does for other sites, with the site's config loaded in Node, and writes its
148
- * answer sealed to the request's key, even when the task fails. Secrets are
149
- * taken out of the environment before the config loads.
163
+ * Runs one of a plugin's tasks for the admin panel in one job, as
164
+ * `goodfellow task` does for other sites, with the site's config loaded in
165
+ * Node, and writes its answer sealed to the request's key, even when the task
166
+ * fails: for sites' task setups from before 0.9. The config loads in a process
167
+ * of its own, without the job's secrets in its environment, but where it can
168
+ * read its parent's, so the starters now write the task down with
169
+ * `goodfellow-next task --describe` in a job with no secrets and run it with
170
+ * `@goodfellow-cms/core`'s `goodfellow-task`.
150
171
  */
151
172
  export declare function writeTaskResult(root: string, request: PluginTaskRequest, out?: string): Promise<TaskResultFile>;
173
+ /** The site's config, loaded in Node, for `writeTaskResult()`'s process that loads it apart from the job's secrets. */
174
+ export declare function loadTaskConfig(root: string): Promise<GoodfellowConfig>;
152
175
  /**
153
176
  * Renders a page with a block from its own file as well as the site's config's,
154
177
  * for `goodfellow-next blocks add`'s check that a block it made renders: both
package/dist/export.js CHANGED
@@ -1,7 +1,8 @@
1
- import { a as loadSiteModule, i as loadSiteConfig, n as buildPluginFrames, r as configHasPlugins } from "./plugins-K5i8XztM.js";
2
- import { r as NEXT_SITE_FILES } from "./site-files-BeLnLxCl.js";
1
+ import { a as loadSiteModule, i as loadSiteConfig, n as buildPluginFrames, r as configHasPlugins } from "./plugins-UKkfu0Uj.js";
2
+ import { t as DIST_DIR_RECORD } from "./image-sizes-mark-BOE7ObB9.js";
3
+ import { r as NEXT_SITE_FILES } from "./site-files-Cmu3ovIs.js";
3
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";
4
- import { BUILD_DATES_DIR, builtSiteSource, guardBuiltSite, reportedDate, runPluginBuildSteps, runPluginTask, selfHostFonts, taskSecretsFrom, writeBuildRecord } from "@goodfellow-cms/core/node";
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";
@@ -70,16 +71,34 @@ async function buildFileFolders(root = process.cwd()) {
70
71
  const { settings } = await loadSiteContent(builtSiteSource(resolve(root)));
71
72
  return siteLanguages(settings).flatMap((language) => [CALENDARS_DIR, FEEDS_DIR].map((dir) => `${language.prefix.slice(1)}${language.prefix ? "/" : ""}${dir}`));
72
73
  }
74
+ /** The folder `next build` wrote into, as `withGoodfellow()` recorded it: the config's `distDir`, or `.next`. */
75
+ async function nextDistDir(root = process.cwd()) {
76
+ const recorded = await readFile(join(root, DIST_DIR_RECORD), "utf8").catch(() => "");
77
+ return join(root, recorded.trim() || ".next");
78
+ }
73
79
  /** The `basePath` the export was built with, which Next.js records in `.next/routes-manifest.json`. */
74
80
  async function exportBasePath(root) {
75
81
  try {
76
- const manifest = JSON.parse(await readFile(join(root, ".next/routes-manifest.json"), "utf8"));
82
+ const manifest = JSON.parse(await readFile(join(await nextDistDir(root), "routes-manifest.json"), "utf8"));
77
83
  return typeof manifest.basePath === "string" ? manifest.basePath : "";
78
84
  } catch {
79
85
  return "";
80
86
  }
81
87
  }
82
88
  /**
89
+ * Whether the export's pages end their addresses with `/`, as Next.js's
90
+ * `trailingSlash` says, which Next.js records in
91
+ * `.next/required-server-files.json`. `true` where it can't be read, as
92
+ * `withGoodfellow()` sets it.
93
+ */
94
+ async function exportTrailingSlash(root = ".") {
95
+ try {
96
+ return JSON.parse(await readFile(join(await nextDistDir(root), "required-server-files.json"), "utf8")).config?.trailingSlash !== false;
97
+ } catch {
98
+ return true;
99
+ }
100
+ }
101
+ /**
83
102
  * Plugins' parts that builds make: each plugin's admin frame, beside the
84
103
  * editor's frames at `admin/plugins/<id>/`, and its build steps, each in a
85
104
  * process of its own held to what the plugin declared. Into a static export
@@ -137,7 +156,7 @@ async function writeFontFiles(outDir, root = process.cwd()) {
137
156
  async function writeBuildRecordFile(outDir, root = process.cwd()) {
138
157
  const dir = resolve(root);
139
158
  const content = await loadSiteContent(builtSiteSource(dir));
140
- const date = await reportedDate(join(dir, ".next", BUILD_DATES_DIR));
159
+ const date = await reportedDate(join(await nextDistDir(dir), BUILD_DATES_DIR));
141
160
  return (await writeBuildRecord(outDir, {
142
161
  root: dir,
143
162
  content,
@@ -168,7 +187,7 @@ async function writeRedirectFiles(outDir, root = process.cwd()) {
168
187
  await writeFile(target, file.content);
169
188
  written.push(file);
170
189
  }
171
- const cloudflare = cloudflareRedirects(written, base);
190
+ const cloudflare = cloudflareRedirects(written, base, content.settings.trailingSlash !== false);
172
191
  const redirects = join(outDir, CLOUDFLARE_REDIRECTS_PATH.slice(1));
173
192
  if (cloudflare.file && !await exists(redirects)) await writeFile(redirects, cloudflare.file.content);
174
193
  return {
@@ -177,6 +196,41 @@ async function writeRedirectFiles(outDir, root = process.cwd()) {
177
196
  };
178
197
  }
179
198
  /**
199
+ * Writes the smaller copies of the media library's photos that the export's
200
+ * pages list in their `srcset` (`writeImageSizes()`), with sharp, where
201
+ * `next build` said they list them (`IMAGE_SIZES_MARK`): a static export
202
+ * whose images Next.js doesn't optimize. `undefined` where they don't.
203
+ */
204
+ async function writeImageFiles(outDir, root = process.cwd()) {
205
+ const dir = resolve(root);
206
+ if (!await exists(join(await nextDistDir(dir), "goodfellow-image-sizes"))) return void 0;
207
+ return writeImageSizes(dir, outDir, { resize: await sharpResizer() });
208
+ }
209
+ /** Makes the copies with sharp, as WebP, turned as each photo's details say and without them, unless it can't load here. */
210
+ async function sharpResizer() {
211
+ try {
212
+ const { default: sharp } = await import("sharp");
213
+ return async (image, width) => new Uint8Array(await sharp(image).rotate().resize({ width }).webp({ quality: 80 }).toBuffer());
214
+ } catch {
215
+ return;
216
+ }
217
+ }
218
+ /**
219
+ * Adds Cloudflare Pages' header rules for every site to the export's
220
+ * `_headers`, as `goodfellow build` does (`writeSiteHeaders()`), from the
221
+ * admin address and broker the exported `goodfellow-site.json` names.
222
+ */
223
+ async function writeExportHeaders(outDir, root = process.cwd()) {
224
+ const text = await readFile(join(outDir, SITE_DESCRIPTION_PATH), "utf8").catch(() => void 0);
225
+ const parsed = text === void 0 ? void 0 : parseSiteDescription(text);
226
+ const description = parsed?.ok ? parsed.description : void 0;
227
+ await writeSiteHeaders(outDir, {
228
+ base: `${await exportBasePath(resolve(root))}/`,
229
+ ...description?.admin && { admin: description.admin },
230
+ ...description?.broker && { broker: description.broker }
231
+ });
232
+ }
233
+ /**
180
234
  * For a site with an admin address (its config's `admin`, which the exported
181
235
  * `goodfellow-site.json` carries), puts the guard first in every page of the
182
236
  * export and adds Cloudflare Pages' header rules, as `goodfellow build` does.
@@ -275,7 +329,7 @@ async function writePageLanguages(outDir, root = process.cwd()) {
275
329
  const dir = resolve(root);
276
330
  const { settings } = await loadSiteContent(builtSiteSource(dir));
277
331
  if (!hasLanguages(settings)) return 0;
278
- const base = outDir ?? join(dir, ".next/server/app");
332
+ const base = outDir ?? join(await nextDistDir(dir), "server/app");
279
333
  const files = (await readdir(base, { recursive: true })).map((file) => file.split("\\").join("/")).filter((file) => file.endsWith(".html") && !/^(admin|_next|pagefind)\//.test(file));
280
334
  let changed = 0;
281
335
  for (const file of files) {
@@ -295,7 +349,7 @@ async function writePageLanguages(outDir, root = process.cwd()) {
295
349
  * and keeps their HTML in `.next/server/app/`. The admin panel and Next.js's own pages are left out.
296
350
  */
297
351
  async function builtPages(root = process.cwd()) {
298
- const next = join(resolve(root), ".next");
352
+ const next = await nextDistDir(resolve(root));
299
353
  const manifest = JSON.parse(await readFile(join(next, "prerender-manifest.json"), "utf8"));
300
354
  const trailingSlash = JSON.parse(await readFile(join(next, "required-server-files.json"), "utf8")).config?.trailingSlash === true;
301
355
  const pages = [];
@@ -353,10 +407,14 @@ async function checkPages(pages, root = process.cwd()) {
353
407
  }));
354
408
  }
355
409
  /**
356
- * Runs one of a plugin's tasks for the admin panel, as `goodfellow task`
357
- * does for other sites, with the site's config loaded in Node, and writes its
358
- * answer sealed to the request's key, even when the task fails. Secrets are
359
- * taken out of the environment before the config loads.
410
+ * Runs one of a plugin's tasks for the admin panel in one job, as
411
+ * `goodfellow task` does for other sites, with the site's config loaded in
412
+ * Node, and writes its answer sealed to the request's key, even when the task
413
+ * fails: for sites' task setups from before 0.9. The config loads in a process
414
+ * of its own, without the job's secrets in its environment, but where it can
415
+ * read its parent's, so the starters now write the task down with
416
+ * `goodfellow-next task --describe` in a job with no secrets and run it with
417
+ * `@goodfellow-cms/core`'s `goodfellow-task`.
360
418
  */
361
419
  async function writeTaskResult(root, request, out = TASK_RESULT_FILE) {
362
420
  const secrets = taskSecretsFrom(process.env);
@@ -364,7 +422,10 @@ async function writeTaskResult(root, request, out = TASK_RESULT_FILE) {
364
422
  if (problem) throw new Error(problem);
365
423
  let answer;
366
424
  try {
367
- const plugin = ((await loadSiteConfig(resolve(root))).plugins ?? []).find((candidate) => pluginId(candidate.name) === request.plugin);
425
+ const plugin = (await sitePluginsApart(resolve(root), {
426
+ module: import.meta.url,
427
+ export: "loadTaskConfig"
428
+ })).find((candidate) => pluginId(candidate.name) === request.plugin);
368
429
  answer = plugin ? await runPluginTask({
369
430
  root: resolve(root),
370
431
  plugin,
@@ -385,6 +446,10 @@ async function writeTaskResult(root, request, out = TASK_RESULT_FILE) {
385
446
  await writeFile(resolve(root, out), `${JSON.stringify(sealed)}\n`);
386
447
  return sealed;
387
448
  }
449
+ /** The site's config, loaded in Node, for `writeTaskResult()`'s process that loads it apart from the job's secrets. */
450
+ function loadTaskConfig(root) {
451
+ return loadSiteConfig(root);
452
+ }
388
453
  /**
389
454
  * Renders a page with a block from its own file as well as the site's config's,
390
455
  * for `goodfellow-next blocks add`'s check that a block it made renders: both
@@ -410,4 +475,4 @@ function blockPageRender(root) {
410
475
  };
411
476
  }
412
477
  //#endregion
413
- export { EDITOR_PAGES, NEXT_SITE_FILES, blockPageRender, buildFileFolders, builtPages, checkPages, exportedPages, fixSegmentFiles, guardExport, writeBuildRecordFile, writeCalendarFiles, writeContentApi, writeEditorPolicy, writeFeedFiles, writeFontFiles, writePageLanguages, writePluginFiles, writeRedirectFiles, writeTaskResult };
478
+ export { EDITOR_PAGES, NEXT_SITE_FILES, blockPageRender, buildFileFolders, builtPages, checkPages, exportTrailingSlash, exportedPages, fixSegmentFiles, guardExport, loadTaskConfig, nextDistDir, writeBuildRecordFile, writeCalendarFiles, writeContentApi, writeEditorPolicy, writeExportHeaders, writeFeedFiles, writeFontFiles, writeImageFiles, writePageLanguages, writePluginFiles, writeRedirectFiles, writeTaskResult };
@@ -0,0 +1,17 @@
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
+ /**
11
+ * Where `withGoodfellow()` records the folder `next build` writes into (the
12
+ * config's `distDir`, `.next` unless it says otherwise), relative to the site,
13
+ * for `goodfellow-next finish`, which runs after it in a process of its own.
14
+ */
15
+ const DIST_DIR_RECORD = "node_modules/.goodfellow/next-dist-dir";
16
+ //#endregion
17
+ export { IMAGE_SIZES_ENV as n, IMAGE_SIZES_MARK as r, DIST_DIR_RECORD as t };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AdminSite, GoodfellowConfig, Page, SiteContent } from "@goodfellow-cms/core";
1
+ import { AdminSite, GoodfellowConfig, ImageSize, Page, SiteContent } from "@goodfellow-cms/core";
2
2
  import { ReactNode } from "react";
3
3
  import { Metadata, MetadataRoute } from "next";
4
4
  //#region src/index.d.ts
@@ -24,8 +24,11 @@ export interface PageProps {
24
24
  * content edits show on reload in development.
25
25
  */
26
26
  export declare const loadSite: (root?: string) => Promise<SiteContent>;
27
- /** A page's title, description and link previews, as Next.js metadata. */
28
- export declare function pageMetadata(content: SiteContent, page: Page, basePath?: string): Metadata;
27
+ /**
28
+ * A page's title, description, search engine settings and link previews, as
29
+ * Next.js metadata, with the shared picture's size where `media` has it.
30
+ */
31
+ export declare function pageMetadata(content: SiteContent, page: Page, basePath?: string, media?: Readonly<Record<string, ImageSize>>): Metadata;
29
32
  /**
30
33
  * Renders the site's pages in a Next.js App Router site, as Server Components.
31
34
  * Blocks' links to the site's pages use `next/link`, and their images
@@ -75,7 +78,10 @@ export declare function goodfellowPages(config: GoodfellowConfig, options?: Good
75
78
  * edit its content without its code, such as the Claude connector.
76
79
  */
77
80
  siteDescription(): Response;
78
- /** For `app/robots.ts`: lets search engines in, and points them at the sitemap once the site's address is set. */
81
+ /**
82
+ * For `app/robots.ts`: lets search engines in, asks AI training's crawlers to stay out where Site Settings
83
+ * says so, and points them at the sitemap once the site's address is set (`robotsRules()`).
84
+ */
79
85
  robots(): Promise<MetadataRoute.Robots>;
80
86
  };
81
87
  //#endregion
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
- import { ByLanguage, CurrentMenuLinks, NextImage, NextLink, PageLanguage } from "./components.js";
2
- import { absoluteUrl, adminSiteForCopy, colorSchemeMeta, colorSchemeScript, contentForPage, demoContent, describedForCopy, feedLinks, findRedirect, fontPreloads, googleFontsUrl, hasLanguages, languageMap, loadSiteContent, normalizePagePath, pageHead, shownContent, siteCopyFrom, siteLanguages, sitePages, sitemapPages, storedMediaSizes, themeToCss, todayIn, withBase } from "@goodfellow-cms/core";
1
+ import { ByLanguage, CurrentMenuLinks, GoatCounterNavigation, NextImage, NextLink, PageLanguage, SearchResultLinks } from "./components.js";
2
+ import "./image-sizes-mark-BOE7ObB9.js";
3
+ import { BLOCK_SCRIPTS, DARK_MODE_ATTRIBUTE, IMAGE_WIDTHS, TRAILING_SLASH_VARIABLE, absoluteUrl, adminSiteForCopy, colorSchemeMeta, colorSchemeScript, consentScript, contentForPage, customFontPreloads, demoContent, describedForCopy, feedLinks, findRedirect, fontPreloads, googleFontsUrl, hasLanguages, heldCode, heldStats, languageMap, loadSiteContent, normalizePagePath, pageHead, robotsRules, shownContent, siteCopyFrom, siteLanguages, sitePages, sitemapPages, storedMediaSizes, themeToCss, todayIn, withBase, withSelfHostedFonts } from "@goodfellow-cms/core";
3
4
  import { BUILD_DATES_ENV, builtSiteSource, fileSystemSource, fontStandIns, readMediaSizes, readStylesheet, reportDatesTo } from "@goodfellow-cms/core/node";
4
- import { BodyCode, HeadCode, PageBody, PageContent, PageFooter, PageHeader, adminSite, applyPageEntry, createPuckConfigs, describeSite, prepareLayout, preparePage } from "@goodfellow-cms/react";
5
+ import { BodyCode, HeadCode, PageBody, PageContent, PageFooter, PageHeader, StatsCode, adminSite, applyPageEntry, createPuckConfigs, describeSite, prepareLayout, preparePage } from "@goodfellow-cms/react";
5
6
  import { notFound, redirect } from "next/navigation";
6
7
  import { cache } from "react";
7
8
  import { preload } from "react-dom";
@@ -19,12 +20,31 @@ 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(builtSiteSource(root))));
44
+ const loadSite = cache(async (root = process.cwd()) => shownContent(await loadSiteContent(builtSiteSource(root, {
45
+ ...process.env,
46
+ [TRAILING_SLASH_VARIABLE]: process.env.GOODFELLOW_TRAILING_SLASH
47
+ }))));
28
48
  function pathOf(segments) {
29
49
  try {
30
50
  return normalizePagePath(`/${(segments ?? []).map((segment) => decodeURIComponent(segment)).join("/")}`);
@@ -39,20 +59,49 @@ function exportedPages(content, blocks) {
39
59
  function findPage(content, blocks, path) {
40
60
  return exportedPages(content, blocks).find((page) => page.path === path);
41
61
  }
62
+ /** Whether the site asks visitors before loading other sites' content. */
63
+ function asks(content) {
64
+ return content.settings.consent !== void 0;
65
+ }
66
+ /** The consent script, after the footer's notice, on a site that asks. */
67
+ function ConsentAsk({ content }) {
68
+ if (!asks(content)) return null;
69
+ return /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: consentScript() } });
70
+ }
71
+ /**
72
+ * What the built-in blocks need in the browser (copying code, the Dark mode
73
+ * switch, search), each a small script that works on what navigations add.
74
+ * `goodfellow build` adds each only to pages that use it; a layout can't know
75
+ * the page, so it has them all, but the Dark mode switch's only where the
76
+ * site has dark mode, since it shows nothing otherwise.
77
+ */
78
+ function BlockScripts({ content }) {
79
+ const scripts = BLOCK_SCRIPTS.filter(({ attribute }) => attribute !== DARK_MODE_ATTRIBUTE || content.settings.theme.darkMode);
80
+ return /* @__PURE__ */ jsx(Fragment, { children: scripts.map(({ attribute, script }) => /* @__PURE__ */ jsx("script", { dangerouslySetInnerHTML: { __html: script() } }, attribute)) });
81
+ }
42
82
  function toAbsolute(siteUrl, url) {
43
83
  if (!url || !siteUrl || !url.startsWith("/") || url.startsWith("//")) return url;
44
84
  return absoluteUrl(siteUrl, url);
45
85
  }
46
- /** A page's title, description and link previews, as Next.js metadata. */
47
- function pageMetadata(content, page, basePath = configuredBasePath()) {
86
+ /**
87
+ * A page's title, description, search engine settings and link previews, as
88
+ * Next.js metadata, with the shared picture's size where `media` has it.
89
+ */
90
+ function pageMetadata(content, page, basePath = configuredBasePath(), media = {}) {
48
91
  const head = pageHead(content, page);
49
92
  const socialImage = toAbsolute(content.settings.url, head.socialImage);
93
+ const socialSize = head.socialImage ? media[head.socialImage] : void 0;
50
94
  const feeds = feedLinks(contentForPage(content, page));
51
95
  return {
52
96
  ...content.settings.url && { metadataBase: new URL(content.settings.url) },
53
97
  title: { absolute: head.title },
54
98
  ...head.description && { description: head.description },
55
- ...head.noIndex && { robots: { index: false } },
99
+ robots: {
100
+ ...head.noIndex && { index: false },
101
+ ...head.noFollow && { follow: false },
102
+ ...!head.noIndex && head.noSnippet && { nosnippet: true },
103
+ ...!head.noIndex && { "max-image-preview": "large" }
104
+ },
56
105
  ...(head.canonicalUrl || feeds.length > 0) && { alternates: {
57
106
  ...head.canonicalUrl && { canonical: head.canonicalUrl },
58
107
  ...head.alternates && { languages: Object.fromEntries(head.alternates.map(({ language, href }) => [language, href])) },
@@ -63,12 +112,25 @@ function pageMetadata(content, page, basePath = configuredBasePath()) {
63
112
  } },
64
113
  ...head.favicon && { icons: { icon: withBase(head.favicon, basePath) } },
65
114
  openGraph: {
66
- type: "website",
115
+ ...head.type === "article" ? {
116
+ type: "article",
117
+ ...head.publishedTime && { publishedTime: head.publishedTime }
118
+ } : { type: "website" },
119
+ siteName: head.siteName,
120
+ locale: head.locale,
67
121
  title: head.title,
68
122
  ...head.description && { description: head.description },
69
123
  ...head.canonicalUrl && { url: head.canonicalUrl },
70
- ...socialImage && /^https?:\/\//.test(socialImage) && { images: [socialImage] }
71
- }
124
+ ...socialImage && /^https?:\/\//.test(socialImage) && { images: [{
125
+ url: socialImage,
126
+ ...socialSize && {
127
+ width: socialSize.width,
128
+ height: socialSize.height
129
+ },
130
+ ...head.socialImageAlt && { alt: head.socialImageAlt }
131
+ }] }
132
+ },
133
+ twitter: { card: head.card }
72
134
  };
73
135
  }
74
136
  /**
@@ -90,17 +152,29 @@ function goodfellowPages(config, options = {}) {
90
152
  media: {
91
153
  ...await readMediaSizes(root),
92
154
  ...storedMediaSizes((await site()).mediaStorage)
93
- }
155
+ },
156
+ ...process.env["GOODFELLOW_IMAGE_SIZES"] && { imageWidths: IMAGE_WIDTHS }
94
157
  });
95
158
  /** The site's theme and fonts, which React moves into the document's head. themeToCss escapes its output. */
96
159
  async function Theme({ content }) {
97
- const fontsUrl = googleFontsUrl(content.settings.theme);
98
- const [fontFiles, standIns] = await Promise.all([fontPreloads(content.settings.theme, readStylesheet), fontStandIns(content.settings.theme)]);
99
- for (const href of fontFiles) preload(href, {
160
+ const googleUrl = googleFontsUrl(content.settings.theme);
161
+ const hosted = googleUrl ? hostedFonts(googleUrl) : void 0;
162
+ const fontsUrl = googleUrl && hosted ? withSelfHostedFonts(googleUrl, hosted, basePath()) : googleUrl;
163
+ const [fontFiles, standIns] = await Promise.all([fontPreloads(content.settings.theme, readStylesheet), fontStandIns(content.settings.theme, { root })]);
164
+ for (const file of customFontPreloads(content.settings.theme)) preload(withBase(file, basePath()), {
100
165
  as: "font",
101
166
  type: "font/woff2",
102
167
  crossOrigin: ""
103
168
  });
169
+ for (const file of fontFiles) {
170
+ const href = hosted ? withSelfHostedFonts(file, hosted, basePath()) : file;
171
+ if (hosted && href === file) continue;
172
+ preload(href, {
173
+ as: "font",
174
+ type: "font/woff2",
175
+ crossOrigin: ""
176
+ });
177
+ }
104
178
  const colorScheme = colorSchemeMeta(content.settings.theme);
105
179
  const schemeScript = colorSchemeScript(content.settings.theme);
106
180
  return /* @__PURE__ */ jsxs(Fragment, { children: [
@@ -112,7 +186,7 @@ function goodfellowPages(config, options = {}) {
112
186
  /* @__PURE__ */ jsx("style", {
113
187
  href: "goodfellow-theme",
114
188
  precedence: "default",
115
- children: themeToCss(content.settings.theme, standIns)
189
+ children: themeToCss(content.settings.theme, standIns, { base: basePath() })
116
190
  }),
117
191
  fontsUrl && /* @__PURE__ */ jsx("link", {
118
192
  rel: "stylesheet",
@@ -129,7 +203,8 @@ function goodfellowPages(config, options = {}) {
129
203
  async generateMetadata({ params }) {
130
204
  const content = await site();
131
205
  const page = findPage(content, config.blocks, pathOf((await params).path));
132
- return page ? pageMetadata(content, applyPageEntry(configs, content, page), basePath()) : {};
206
+ if (!page) return {};
207
+ return pageMetadata(content, applyPageEntry(configs, content, page), basePath(), (await renderOptions()).media);
133
208
  },
134
209
  /**
135
210
  * For `app/admin/page.tsx`: what the admin panel's page knows about the
@@ -197,12 +272,27 @@ function goodfellowPages(config, options = {}) {
197
272
  }
198
273
  return /* @__PURE__ */ jsxs(Fragment, { children: [
199
274
  /* @__PURE__ */ jsx(Theme, { content }),
200
- /* @__PURE__ */ jsx(HeadCode, { code: content.code?.head }),
275
+ /* @__PURE__ */ jsx(HeadCode, {
276
+ code: content.code?.head,
277
+ held: heldCode(content.settings.consent, "head"),
278
+ siteUrl: content.settings.url
279
+ }),
280
+ /* @__PURE__ */ jsx(StatsCode, {
281
+ stats: content.settings.stats,
282
+ held: heldStats(content.settings.consent, content.settings.stats)
283
+ }),
201
284
  header,
202
285
  children,
203
286
  footer,
204
287
  /* @__PURE__ */ jsx(CurrentMenuLinks, { basePath: basePath() }),
205
- /* @__PURE__ */ jsx(BodyCode, { code: content.code?.body })
288
+ content.settings.stats?.service === "goatcounter" && process.env.NODE_ENV !== "development" && /* @__PURE__ */ jsx(GoatCounterNavigation, {}),
289
+ /* @__PURE__ */ jsx(SearchResultLinks, { basePath: basePath() }),
290
+ /* @__PURE__ */ jsx(BodyCode, {
291
+ code: content.code?.body,
292
+ held: heldCode(content.settings.consent, "body")
293
+ }),
294
+ /* @__PURE__ */ jsx(ConsentAsk, { content }),
295
+ /* @__PURE__ */ jsx(BlockScripts, { content })
206
296
  ] });
207
297
  },
208
298
  /** The page at the route's address, inside `Layout`'s header and footer. */
@@ -237,7 +327,11 @@ function goodfellowPages(config, options = {}) {
237
327
  return /* @__PURE__ */ jsxs(Fragment, { children: [
238
328
  /* @__PURE__ */ jsx("title", { children: pageHead(content, page).title }),
239
329
  /* @__PURE__ */ jsx(Theme, { content }),
240
- /* @__PURE__ */ jsx(HeadCode, { code: content.code?.head }),
330
+ /* @__PURE__ */ jsx(HeadCode, {
331
+ code: content.code?.head,
332
+ held: heldCode(content.settings.consent, "head"),
333
+ siteUrl: content.settings.url
334
+ }),
241
335
  /* @__PURE__ */ jsx(PageBody, {
242
336
  site: prepared.site,
243
337
  pageConfig: prepared.pageConfig,
@@ -246,7 +340,13 @@ function goodfellowPages(config, options = {}) {
246
340
  header: prepared.header,
247
341
  footer: prepared.footer
248
342
  }),
249
- /* @__PURE__ */ jsx(BodyCode, { code: content.code?.body })
343
+ /* @__PURE__ */ jsx(BodyCode, {
344
+ code: content.code?.body,
345
+ held: heldCode(content.settings.consent, "body")
346
+ }),
347
+ /* @__PURE__ */ jsx(ConsentAsk, { content }),
348
+ /* @__PURE__ */ jsx(BlockScripts, { content }),
349
+ /* @__PURE__ */ jsx(SearchResultLinks, { basePath: basePath() })
250
350
  ] });
251
351
  },
252
352
  /** For `app/sitemap.ts`: every page but "Page not found" and pages hidden from search engines, once the site's address is set. */
@@ -273,15 +373,15 @@ function goodfellowPages(config, options = {}) {
273
373
  siteDescription() {
274
374
  return Response.json(describedForCopy(describeSite(config), siteCopyFrom(process.env)));
275
375
  },
276
- /** For `app/robots.ts`: lets search engines in, and points them at the sitemap once the site's address is set. */
376
+ /**
377
+ * For `app/robots.ts`: lets search engines in, asks AI training's crawlers to stay out where Site Settings
378
+ * says so, and points them at the sitemap once the site's address is set (`robotsRules()`).
379
+ */
277
380
  async robots() {
278
- const url = (await site()).settings.url;
381
+ const { settings } = await site();
279
382
  return {
280
- rules: {
281
- userAgent: "*",
282
- allow: "/"
283
- },
284
- ...url && { sitemap: absoluteUrl(url, "/sitemap.xml") }
383
+ rules: robotsRules(settings),
384
+ ...settings.url && { sitemap: absoluteUrl(settings.url, "/sitemap.xml") }
285
385
  };
286
386
  }
287
387
  };
@@ -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";
@@ -125,7 +125,8 @@ async function buildPluginFrame(plugin, { root, dir, watch }) {
125
125
  scripts: ["plugin.js"],
126
126
  stylesheets: ["plugin.css"],
127
127
  licenses: LICENSES_FILE,
128
- editor: Boolean(plugin.editor)
128
+ editor: Boolean(plugin.editor),
129
+ ...plugin.editor && { media: await mediaStorageOrigins(root) }
129
130
  }));
130
131
  };
131
132
  const options = {
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
  }