@goodfellow-cms/next 0.4.0 → 0.6.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.js CHANGED
@@ -1,7 +1,11 @@
1
- import { allPages, calendarFiles, loadSiteContent, redirectFiles } from "@goodfellow-cms/core";
2
- import { fileSystemSource } from "@goodfellow-cms/core/node";
3
- import { dirname, join, relative, resolve } from "node:path";
1
+ import { i as loadSiteConfig, n as buildPluginFrames, r as configHasPlugins } from "./plugins-eLJalBij.js";
2
+ 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
5
  import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
6
+ import { dirname, join, relative, resolve } from "node:path";
7
+ import { createHash } from "node:crypto";
8
+ import { checkBuiltPages } from "@goodfellow-cms/core/accessibility";
5
9
  //#region src/export.ts
6
10
  /**
7
11
  * Writes the calendar files of the site's collections of events into the
@@ -9,7 +13,20 @@ import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs
9
13
  * how many it wrote.
10
14
  */
11
15
  async function writeCalendarFiles(outDir, root = process.cwd()) {
12
- const files = calendarFiles(await loadSiteContent(fileSystemSource(resolve(root))));
16
+ const files = calendarFiles(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
17
+ for (const file of files) {
18
+ const target = join(outDir, file.path.slice(1));
19
+ await mkdir(dirname(target), { recursive: true });
20
+ await writeFile(target, file.content);
21
+ }
22
+ return files.length;
23
+ }
24
+ /**
25
+ * Writes the feeds of the site's collections into the export, as `goodfellow
26
+ * build` does. Returns how many it wrote: none without the site's address.
27
+ */
28
+ async function writeFeedFiles(outDir, root = process.cwd()) {
29
+ const files = feedFiles(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
13
30
  for (const file of files) {
14
31
  const target = join(outDir, file.path.slice(1));
15
32
  await mkdir(dirname(target), { recursive: true });
@@ -17,6 +34,15 @@ async function writeCalendarFiles(outDir, root = process.cwd()) {
17
34
  }
18
35
  return files.length;
19
36
  }
37
+ /**
38
+ * The folders the calendar files and feeds go in: `calendars/` and `feeds/`,
39
+ * and on a site in several languages, the same under each other language's
40
+ * code, such as `es/feeds/`.
41
+ */
42
+ async function buildFileFolders(root = process.cwd()) {
43
+ const { settings } = await loadSiteContent(fileSystemSource(resolve(root)));
44
+ return siteLanguages(settings).flatMap((language) => [CALENDARS_DIR, FEEDS_DIR].map((dir) => `${language.prefix.slice(1)}${language.prefix ? "/" : ""}${dir}`));
45
+ }
20
46
  /** The `basePath` the export was built with, which Next.js records in `.next/routes-manifest.json`. */
21
47
  async function exportBasePath(root) {
22
48
  try {
@@ -26,31 +52,123 @@ async function exportBasePath(root) {
26
52
  return "";
27
53
  }
28
54
  }
55
+ /**
56
+ * Plugins' parts that builds make: each plugin's admin frame, beside the
57
+ * editor's frames at `admin/plugins/<id>/`, and its build steps, each in a
58
+ * process of its own held to what the plugin declared. Into a static export
59
+ * (`outDir`), or a full-stack site's `public/`, where `next start` serves
60
+ * them. The config is loaded outside Next.js only when it lists plugins.
61
+ */
62
+ async function writePluginFiles(outDir, root = process.cwd()) {
63
+ const site = resolve(root);
64
+ if (!await configHasPlugins(site)) return {
65
+ frames: [],
66
+ steps: []
67
+ };
68
+ const config = await loadSiteConfig(site);
69
+ const base = `${await exportBasePath(site)}/`;
70
+ const dir = resolve(site, outDir);
71
+ return {
72
+ frames: await buildPluginFrames(config.plugins, {
73
+ root: site,
74
+ outDir: dir
75
+ }),
76
+ steps: (await runPluginBuildSteps({
77
+ root: site,
78
+ outDir: dir,
79
+ base,
80
+ plugins: config.plugins ?? [],
81
+ onStep: (step) => console.log(`Running ${step.title}, from ${step.plugin}…`)
82
+ })).map((step) => `${step.plugin}: ${step.title}`)
83
+ };
84
+ }
29
85
  async function exists(path) {
30
86
  return access(path).then(() => true, () => false);
31
87
  }
32
88
  /**
89
+ * Copies the site's Google Fonts into the export and points its pages at
90
+ * them, as `goodfellow build` does (`selfHostFonts()`), with the export's base
91
+ * path, and says where search engines read `robots.txt` for a site in a
92
+ * subfolder (`rootRobotsUrl()`).
93
+ */
94
+ async function writeFontFiles(outDir, root = process.cwd()) {
95
+ const { settings } = await loadSiteContent(fileSystemSource(resolve(root)));
96
+ const fonts = await selfHostFonts(outDir, settings.theme, { base: await exportBasePath(root) });
97
+ const robotsElsewhere = rootRobotsUrl(settings.url);
98
+ return {
99
+ ...fonts && { fonts },
100
+ ...robotsElsewhere && { robotsElsewhere },
101
+ ...settings.url && { sitemap: absoluteUrl(settings.url, "/sitemap.xml") }
102
+ };
103
+ }
104
+ /**
105
+ * Writes the build record (`goodfellow-build.json`) into the export, as
106
+ * `goodfellow build` does, from the dates the processes that rendered its
107
+ * pages wrote into `.next/` (see `withGoodfellow()`). Returns when the site
108
+ * stops being current, if it does.
109
+ */
110
+ async function writeBuildRecordFile(outDir, root = process.cwd()) {
111
+ const dir = resolve(root);
112
+ const content = await loadSiteContent(fileSystemSource(dir));
113
+ const date = await reportedDate(join(dir, ".next", BUILD_DATES_DIR));
114
+ return (await writeBuildRecord(outDir, {
115
+ root: dir,
116
+ content,
117
+ now: /* @__PURE__ */ new Date(),
118
+ ...date && { date }
119
+ })).currentUntil;
120
+ }
121
+ /**
33
122
  * Writes a page at each of the site's old addresses that sends visitors on, as
34
- * `goodfellow build` does. Pages win: an address the export has a page at keeps
35
- * it. Returns how many it wrote.
123
+ * `goodfellow build` does, and the same redirects in `_redirects` for
124
+ * Cloudflare Pages, which sends them itself. Pages win: an address the export
125
+ * has a page at keeps it.
36
126
  */
37
127
  async function writeRedirectFiles(outDir, root = process.cwd()) {
38
128
  const dir = resolve(root);
39
- const content = await loadSiteContent(fileSystemSource(dir));
129
+ const content = shownContent(await loadSiteContent(fileSystemSource(dir)));
130
+ const base = await exportBasePath(dir);
40
131
  const files = redirectFiles(content, {
41
132
  taken: allPages(content).map((page) => page.path),
42
- base: await exportBasePath(dir)
133
+ base
43
134
  });
44
- let written = 0;
135
+ const written = [];
45
136
  for (const file of files) {
46
137
  const target = join(outDir, file.path.slice(1));
47
138
  const page = join(outDir, `${file.path.replace(/\/index\.html$/, "")}.html`);
48
139
  if (await exists(target) || await exists(page)) continue;
49
140
  await mkdir(dirname(target), { recursive: true });
50
141
  await writeFile(target, file.content);
51
- written++;
142
+ written.push(file);
52
143
  }
53
- return written;
144
+ const cloudflare = cloudflareRedirects(written, base);
145
+ const redirects = join(outDir, CLOUDFLARE_REDIRECTS_PATH.slice(1));
146
+ if (cloudflare.file && !await exists(redirects)) await writeFile(redirects, cloudflare.file.content);
147
+ return {
148
+ pages: written.length,
149
+ cloudflareLeft: cloudflare.left
150
+ };
151
+ }
152
+ /**
153
+ * For a site with an admin address (its config's `admin`, which the exported
154
+ * `goodfellow-site.json` carries), puts the guard first in every page of the
155
+ * export and adds Cloudflare Pages' header rules, as `goodfellow build` does.
156
+ * Run it last, once every page is written. Returns how many pages it guarded,
157
+ * or `undefined` for a site without an admin address.
158
+ */
159
+ async function guardExport(outDir, root = process.cwd()) {
160
+ const text = await readFile(join(outDir, SITE_DESCRIPTION_PATH), "utf8").catch(() => void 0);
161
+ const parsed = text === void 0 ? void 0 : parseSiteDescription(text);
162
+ const admin = parsed?.ok ? parsed.description.admin : void 0;
163
+ if (!admin) return void 0;
164
+ const dir = resolve(root);
165
+ const content = await loadSiteContent(fileSystemSource(dir));
166
+ return guardBuiltSite(outDir, {
167
+ admin,
168
+ ...parsed?.ok && parsed.description.broker && { broker: parsed.description.broker },
169
+ siteUrl: content.settings.url,
170
+ base: `${await exportBasePath(dir)}/`
171
+ });
54
172
  }
55
173
  /**
56
174
  * Puts a static export's prefetch files where the browser asks for them.
@@ -88,6 +206,61 @@ async function fixSegmentFiles(outDir) {
88
206
  await visit(outDir);
89
207
  return moved;
90
208
  }
209
+ /** Where the editor's frames' page is in an export, and in a full-stack site's build. */
210
+ const EDITOR_PAGES = ["admin/editor/index.html", "admin/editor.html"];
211
+ function scriptHash(text) {
212
+ return `sha256-${createHash("sha256").update(text).digest("base64")}`;
213
+ }
214
+ /**
215
+ * Gives the editor's frames' page (`app/admin/editor/`) its Content Security
216
+ * Policy and first script, which removes WebRTC, as the first things in its
217
+ * head, so they apply before anything Next.js put there. The policy runs only
218
+ * the site's own scripts, Next.js's inline ones by their hashes, and fetches
219
+ * nothing (`editorPolicy()`). Returns whether it found the page.
220
+ */
221
+ async function writeEditorPolicy(dir) {
222
+ let found = false;
223
+ for (const name of EDITOR_PAGES) {
224
+ const file = join(dir, name);
225
+ const html = await readFile(file, "utf8").catch(() => void 0);
226
+ if (html === void 0 || html.includes("http-equiv=\"Content-Security-Policy\"")) continue;
227
+ const inline = [...html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g)].map((match) => match[1] ?? "");
228
+ const head = `<meta http-equiv="Content-Security-Policy" content="${editorPolicy({
229
+ scriptHashes: [EDITOR_PRELUDE, ...inline].map(scriptHash),
230
+ blobScripts: true
231
+ })}"/><script>${EDITOR_PRELUDE}<\/script>`;
232
+ await writeFile(file, html.replace(/<head([^>]*)>/, (open) => `${open}${head}`));
233
+ found = true;
234
+ }
235
+ return found;
236
+ }
237
+ /** Says a page's language on its `<html>`, in place of the root layout's. */
238
+ function withPageLanguage(html, language) {
239
+ return html.replace(/<html\b([^>]*?)\slang="[^"]*"/, `<html$1 lang="${language}"`);
240
+ }
241
+ /**
242
+ * On a site in several languages, puts each page's language on its `<html>`,
243
+ * which Next.js's root layout, shared by every page, says is the main one:
244
+ * in a static export's pages (`outDir`), or, without one, in the pages a
245
+ * full-stack site's `next build` rendered ahead. Returns how many it changed.
246
+ */
247
+ async function writePageLanguages(outDir, root = process.cwd()) {
248
+ const dir = resolve(root);
249
+ const { settings } = await loadSiteContent(fileSystemSource(dir));
250
+ if (!hasLanguages(settings)) return 0;
251
+ const base = outDir ?? join(dir, ".next/server/app");
252
+ const files = (await readdir(base, { recursive: true })).map((file) => file.split("\\").join("/")).filter((file) => file.endsWith(".html") && !/^(admin|_next|pagefind)\//.test(file));
253
+ let changed = 0;
254
+ for (const file of files) {
255
+ const path = `/${file.replace(/(^|\/)index\.html$/, "").replace(/\.html$/, "")}`.replace(/\/$/, "") || "/";
256
+ const html = await readFile(join(base, file), "utf8");
257
+ const fixed = withPageLanguage(html, pathLanguage(settings, path));
258
+ if (fixed === html) continue;
259
+ await writeFile(join(base, file), fixed);
260
+ changed++;
261
+ }
262
+ return changed;
263
+ }
91
264
  /**
92
265
  * The pages `next build` rendered ahead for a full-stack site (`withGoodfellow(config,
93
266
  * { server: true })`), at their addresses on the site, without its base path, as the
@@ -111,5 +284,79 @@ async function builtPages(root = process.cwd()) {
111
284
  }
112
285
  return pages;
113
286
  }
287
+ /**
288
+ * The pages a static export has, at their addresses on the site, without its
289
+ * base path: `about.html` and `about/index.html` are `/about`. The admin panel
290
+ * and Next.js's own files are left out.
291
+ */
292
+ async function exportedPages(outDir) {
293
+ const files = (await readdir(outDir, { recursive: true })).map((file) => file.split("\\").join("/")).filter((file) => file.endsWith(".html") && !/^(admin|_next|pagefind)\//.test(file) && !file.startsWith("_not-found") && file !== "404/index.html");
294
+ const pages = [];
295
+ for (const file of files) {
296
+ const url = `/${file.replace(/(^|\/)index\.html$/, "").replace(/\.html$/, "")}`.replace(/\/$/, "") || "/";
297
+ pages.push({
298
+ url,
299
+ html: await readFile(join(outDir, file), "utf8")
300
+ });
301
+ }
302
+ return pages;
303
+ }
304
+ /** The content file a page comes from: its own, or, for pages blocks add such as a list's second page, its page's. */
305
+ function contentFile(pages, path) {
306
+ const own = pages.find((page) => page.path === path);
307
+ if (own) return own.file;
308
+ return pages.filter((page) => page.path !== "/" && path.startsWith(`${page.path}/`)).sort((a, b) => b.path.length - a.path.length)[0]?.file;
309
+ }
310
+ /**
311
+ * What automated checks find on the pages Next.js built, as `goodfellow build`
312
+ * reports them: images without alt text, headings that skip a level, links
313
+ * that don't say where they go, and axe-core's checks (`checkBuiltPages()`).
314
+ * Pages that aren't the site's, such as Next.js's own, are left out.
315
+ */
316
+ async function checkPages(pages, root = process.cwd()) {
317
+ const known = allPages(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
318
+ return checkBuiltPages(pages.flatMap((page) => {
319
+ const path = page.url.replace(/(.)\/$/, "$1");
320
+ const file = contentFile(known, path);
321
+ return file ? [{
322
+ path,
323
+ file,
324
+ html: page.html
325
+ }] : [];
326
+ }));
327
+ }
328
+ /**
329
+ * Runs one of a plugin's tasks for the admin panel, as `goodfellow task`
330
+ * 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.
333
+ */
334
+ async function writeTaskResult(root, request, out = TASK_RESULT_FILE) {
335
+ const secrets = taskSecretsFrom(process.env);
336
+ const problem = taskRequestProblem(request);
337
+ if (problem) throw new Error(problem);
338
+ let answer;
339
+ try {
340
+ const plugin = ((await loadSiteConfig(resolve(root))).plugins ?? []).find((candidate) => pluginId(candidate.name) === request.plugin);
341
+ answer = plugin ? await runPluginTask({
342
+ root: resolve(root),
343
+ plugin,
344
+ task: request.task,
345
+ input: request.input,
346
+ secrets
347
+ }) : {
348
+ ok: false,
349
+ error: `The site has no plugin ${request.plugin}.`
350
+ };
351
+ } catch (error) {
352
+ answer = {
353
+ ok: false,
354
+ error: `The site couldn't run the task: ${error instanceof Error ? error.message : error}`
355
+ };
356
+ }
357
+ const sealed = await sealTaskAnswer(answer, request.key);
358
+ await writeFile(resolve(root, out), `${JSON.stringify(sealed)}\n`);
359
+ return sealed;
360
+ }
114
361
  //#endregion
115
- export { builtPages, fixSegmentFiles, writeCalendarFiles, writeRedirectFiles };
362
+ export { EDITOR_PAGES, NEXT_SITE_FILES, buildFileFolders, builtPages, checkPages, exportedPages, fixSegmentFiles, guardExport, writeBuildRecordFile, writeCalendarFiles, writeEditorPolicy, writeFeedFiles, writeFontFiles, writePageLanguages, writePluginFiles, writeRedirectFiles, writeTaskResult };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { GoodfellowConfig, Page, SiteContent } from "@goodfellow-cms/core";
1
+ import { AdminSite, GoodfellowConfig, 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
@@ -18,8 +18,10 @@ export interface PageProps {
18
18
  }>;
19
19
  }
20
20
  /**
21
- * Reads the site's content from disk. Cached for the length of one request, so
22
- * a page and its metadata read it once, and content edits show on reload in development.
21
+ * Reads the site's content from disk, as visitors see it now: without pages
22
+ * and items scheduled for later, or hidden again (`shownContent()`). Cached
23
+ * for the length of one request, so a page and its metadata read it once, and
24
+ * content edits show on reload in development.
23
25
  */
24
26
  export declare const loadSite: (root?: string) => Promise<SiteContent>;
25
27
  /** A page's title, description and link previews, as Next.js metadata. */
@@ -38,6 +40,12 @@ export declare function goodfellowPages(config: GoodfellowConfig, options?: Good
38
40
  path: string[];
39
41
  }>>;
40
42
  generateMetadata({ params }: PageProps): Promise<Metadata>;
43
+ /**
44
+ * For `app/admin/page.tsx`: what the admin panel's page knows about the
45
+ * site, which never loads its config. The site's blocks run in the
46
+ * editor's frames, at `app/admin/editor/page.tsx`.
47
+ */
48
+ admin(): AdminSite;
41
49
  /** For `app/admin/layout.tsx`: the admin panel's title, kept out of search engines, with the site's icon. */
42
50
  adminMetadata(): Promise<Metadata>;
43
51
  /**
@@ -54,7 +62,7 @@ export declare function goodfellowPages(config: GoodfellowConfig, options?: Good
54
62
  * with the header and footer, since Next.js shows it outside the `(site)` layout.
55
63
  */
56
64
  NotFound(): Promise<import("react").JSX.Element>;
57
- /** For `app/sitemap.ts`: every page but "Page not found", once the site's address is set. */
65
+ /** For `app/sitemap.ts`: every page but "Page not found" and pages hidden from search engines, once the site's address is set. */
58
66
  sitemap(): Promise<MetadataRoute.Sitemap>;
59
67
  /**
60
68
  * For `app/admin/demo-content.json/route.ts`: in a demo (the config's `demo`), the copy of
package/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
- import { CurrentMenuLinks, NextImage, NextLink } from "./components.js";
2
- import { absoluteUrl, colorSchemeMeta, colorSchemeScript, demoContent, findRedirect, fontPreloads, getPageHead, googleFontsUrl, loadSiteContent, normalizePagePath, sitePages, themeToCss, todayIn, withBase } from "@goodfellow-cms/core";
3
- import { fileSystemSource, fontStandIns, readMediaSizes, readStylesheet } from "@goodfellow-cms/core/node";
4
- import { BodyCode, HeadCode, PageBody, PageContent, PageFooter, PageHeader, applyPageEntry, createPuckConfigs, describeSite, prepareLayout, preparePage } from "@goodfellow-cms/react";
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";
4
+ import { BodyCode, HeadCode, PageBody, PageContent, PageFooter, PageHeader, adminSite, applyPageEntry, createPuckConfigs, describeSite, prepareLayout, preparePage } from "@goodfellow-cms/react";
5
5
  import { notFound, redirect } from "next/navigation";
6
6
  import { cache } from "react";
7
7
  import { preload } from "react-dom";
8
8
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
9
9
  //#region src/index.tsx
10
+ const buildDates = process.env[BUILD_DATES_ENV];
11
+ if (buildDates) reportDatesTo(buildDates);
10
12
  /** Next.js's own link and image components, for blocks' `<SiteLink>` and `<SiteImage>`. */
11
13
  const components = {
12
14
  Link: NextLink,
@@ -17,10 +19,12 @@ function configuredBasePath() {
17
19
  return process.env.GOODFELLOW_BASE_PATH ?? "";
18
20
  }
19
21
  /**
20
- * Reads the site's content from disk. Cached for the length of one request, so
21
- * a page and its metadata read it once, and content edits show on reload in development.
22
+ * Reads the site's content from disk, as visitors see it now: without pages
23
+ * and items scheduled for later, or hidden again (`shownContent()`). Cached
24
+ * for the length of one request, so a page and its metadata read it once, and
25
+ * content edits show on reload in development.
22
26
  */
23
- const loadSite = cache((root = process.cwd()) => loadSiteContent(fileSystemSource(root)));
27
+ const loadSite = cache(async (root = process.cwd()) => shownContent(await loadSiteContent(fileSystemSource(root))));
24
28
  function pathOf(segments) {
25
29
  try {
26
30
  return normalizePagePath(`/${(segments ?? []).map((segment) => decodeURIComponent(segment)).join("/")}`);
@@ -41,14 +45,22 @@ function toAbsolute(siteUrl, url) {
41
45
  }
42
46
  /** A page's title, description and link previews, as Next.js metadata. */
43
47
  function pageMetadata(content, page, basePath = configuredBasePath()) {
44
- const head = getPageHead(content.settings, page);
48
+ const head = pageHead(content, page);
45
49
  const socialImage = toAbsolute(content.settings.url, head.socialImage);
50
+ const feeds = feedLinks(contentForPage(content, page));
46
51
  return {
47
52
  ...content.settings.url && { metadataBase: new URL(content.settings.url) },
48
53
  title: { absolute: head.title },
49
54
  ...head.description && { description: head.description },
50
55
  ...head.noIndex && { robots: { index: false } },
51
- ...head.canonicalUrl && { alternates: { canonical: head.canonicalUrl } },
56
+ ...(head.canonicalUrl || feeds.length > 0) && { alternates: {
57
+ ...head.canonicalUrl && { canonical: head.canonicalUrl },
58
+ ...head.alternates && { languages: Object.fromEntries(head.alternates.map(({ language, href }) => [language, href])) },
59
+ ...feeds.length > 0 && { types: { "application/rss+xml": feeds.map((feed) => ({
60
+ url: withBase(feed.href, basePath),
61
+ title: feed.title
62
+ })) } }
63
+ } },
52
64
  ...head.favicon && { icons: { icon: withBase(head.favicon, basePath) } },
53
65
  openGraph: {
54
66
  type: "website",
@@ -75,7 +87,10 @@ function goodfellowPages(config, options = {}) {
75
87
  const renderOptions = async () => ({
76
88
  base: basePath(),
77
89
  components,
78
- media: await readMediaSizes(root)
90
+ media: {
91
+ ...await readMediaSizes(root),
92
+ ...storedMediaSizes((await site()).mediaStorage)
93
+ }
79
94
  });
80
95
  /** The site's theme and fonts, which React moves into the document's head. themeToCss escapes its output. */
81
96
  async function Theme({ content }) {
@@ -99,22 +114,11 @@ function goodfellowPages(config, options = {}) {
99
114
  precedence: "default",
100
115
  children: themeToCss(content.settings.theme, standIns)
101
116
  }),
102
- fontsUrl && /* @__PURE__ */ jsxs(Fragment, { children: [
103
- /* @__PURE__ */ jsx("link", {
104
- rel: "preconnect",
105
- href: "https://fonts.googleapis.com"
106
- }),
107
- /* @__PURE__ */ jsx("link", {
108
- rel: "preconnect",
109
- href: "https://fonts.gstatic.com",
110
- crossOrigin: ""
111
- }),
112
- /* @__PURE__ */ jsx("link", {
113
- rel: "stylesheet",
114
- href: fontsUrl,
115
- precedence: "default"
116
- })
117
- ] })
117
+ fontsUrl && /* @__PURE__ */ jsx("link", {
118
+ rel: "stylesheet",
119
+ href: fontsUrl,
120
+ precedence: "default"
121
+ })
118
122
  ] });
119
123
  }
120
124
  return {
@@ -127,6 +131,14 @@ function goodfellowPages(config, options = {}) {
127
131
  const page = findPage(content, config.blocks, pathOf((await params).path));
128
132
  return page ? pageMetadata(content, applyPageEntry(configs, content, page), basePath()) : {};
129
133
  },
134
+ /**
135
+ * For `app/admin/page.tsx`: what the admin panel's page knows about the
136
+ * site, which never loads its config. The site's blocks run in the
137
+ * editor's frames, at `app/admin/editor/page.tsx`.
138
+ */
139
+ admin() {
140
+ return adminSite(config);
141
+ },
130
142
  /** For `app/admin/layout.tsx`: the admin panel's title, kept out of search engines, with the site's icon. */
131
143
  async adminMetadata() {
132
144
  const favicon = await site().then((content) => content.settings.favicon, () => void 0);
@@ -142,21 +154,53 @@ function goodfellowPages(config, options = {}) {
142
154
  */
143
155
  async Layout({ children }) {
144
156
  const content = await site();
145
- const layout = await prepareLayout(configs, content, await renderOptions());
146
- return /* @__PURE__ */ jsxs(Fragment, { children: [
147
- /* @__PURE__ */ jsx(Theme, { content }),
148
- /* @__PURE__ */ jsx(HeadCode, { code: content.code?.head }),
149
- /* @__PURE__ */ jsx(PageHeader, {
157
+ const options = await renderOptions();
158
+ let header;
159
+ let footer;
160
+ if (hasLanguages(content.settings)) {
161
+ const languageMap$1 = languageMap(content, exportedPages(content, config.blocks));
162
+ const layouts = await Promise.all(siteLanguages(content.settings).map(async (language) => ({
163
+ prefix: language.prefix,
164
+ layout: await prepareLayout(configs, content, {
165
+ ...options,
166
+ languageMap: languageMap$1
167
+ }, language.code)
168
+ })));
169
+ header = /* @__PURE__ */ jsx(ByLanguage, { variants: layouts.map(({ prefix, layout }) => ({
170
+ prefix,
171
+ children: /* @__PURE__ */ jsx(PageHeader, {
172
+ site: layout.site,
173
+ layoutConfig: layout.layoutConfig,
174
+ data: layout.header
175
+ })
176
+ })) });
177
+ footer = /* @__PURE__ */ jsx(ByLanguage, { variants: layouts.map(({ prefix, layout }) => ({
178
+ prefix,
179
+ children: /* @__PURE__ */ jsx(PageFooter, {
180
+ site: layout.site,
181
+ layoutConfig: layout.layoutConfig,
182
+ data: layout.footer
183
+ })
184
+ })) });
185
+ } else {
186
+ const layout = await prepareLayout(configs, content, options);
187
+ header = /* @__PURE__ */ jsx(PageHeader, {
150
188
  site: layout.site,
151
189
  layoutConfig: layout.layoutConfig,
152
190
  data: layout.header
153
- }),
154
- children,
155
- /* @__PURE__ */ jsx(PageFooter, {
191
+ });
192
+ footer = /* @__PURE__ */ jsx(PageFooter, {
156
193
  site: layout.site,
157
194
  layoutConfig: layout.layoutConfig,
158
195
  data: layout.footer
159
- }),
196
+ });
197
+ }
198
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
199
+ /* @__PURE__ */ jsx(Theme, { content }),
200
+ /* @__PURE__ */ jsx(HeadCode, { code: content.code?.head }),
201
+ header,
202
+ children,
203
+ footer,
160
204
  /* @__PURE__ */ jsx(CurrentMenuLinks, { basePath: basePath() }),
161
205
  /* @__PURE__ */ jsx(BodyCode, { code: content.code?.body })
162
206
  ] });
@@ -172,11 +216,14 @@ function goodfellowPages(config, options = {}) {
172
216
  notFound();
173
217
  }
174
218
  const prepared = await preparePage(configs, content, page, await renderOptions());
175
- return /* @__PURE__ */ jsx(PageContent, {
219
+ const pageContent = /* @__PURE__ */ jsx(PageContent, {
176
220
  site: prepared.site,
177
221
  pageConfig: prepared.pageConfig,
178
- page: prepared.data
222
+ page: prepared.data,
223
+ ...prepared.structuredData && { structuredData: prepared.structuredData }
179
224
  });
225
+ if (!hasLanguages(content.settings)) return pageContent;
226
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(PageLanguage, { language: prepared.site.settings.language }), pageContent] });
180
227
  },
181
228
  /**
182
229
  * The site's "Page not found" page (`content/pages/404.json`), for `app/not-found.tsx`,
@@ -188,7 +235,7 @@ function goodfellowPages(config, options = {}) {
188
235
  if (!page) return /* @__PURE__ */ jsx("h1", { children: "Page not found" });
189
236
  const prepared = await preparePage(configs, content, page, await renderOptions());
190
237
  return /* @__PURE__ */ jsxs(Fragment, { children: [
191
- /* @__PURE__ */ jsx("title", { children: getPageHead(content.settings, page).title }),
238
+ /* @__PURE__ */ jsx("title", { children: pageHead(content, page).title }),
192
239
  /* @__PURE__ */ jsx(Theme, { content }),
193
240
  /* @__PURE__ */ jsx(HeadCode, { code: content.code?.head }),
194
241
  /* @__PURE__ */ jsx(PageBody, {
@@ -202,12 +249,13 @@ function goodfellowPages(config, options = {}) {
202
249
  /* @__PURE__ */ jsx(BodyCode, { code: content.code?.body })
203
250
  ] });
204
251
  },
205
- /** For `app/sitemap.ts`: every page but "Page not found", once the site's address is set. */
252
+ /** For `app/sitemap.ts`: every page but "Page not found" and pages hidden from search engines, once the site's address is set. */
206
253
  async sitemap() {
207
254
  const content = await site();
208
- const url = content.settings.url;
209
- if (!url) return [];
210
- return exportedPages(content, config.blocks).filter((page) => page.path !== "/404").map((page) => ({ url: absoluteUrl(url, page.path) }));
255
+ return sitemapPages(content, exportedPages(content, config.blocks)).map(({ url, alternates }) => ({
256
+ url,
257
+ ...alternates && { alternates: { languages: alternates } }
258
+ }));
211
259
  },
212
260
  /**
213
261
  * For `app/admin/demo-content.json/route.ts`: in a demo (the config's `demo`), the copy of