@goodfellow-cms/next 0.3.0 → 0.5.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,10 @@
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 { r as NEXT_SITE_FILES } from "./site-files-BeLnLxCl.js";
2
+ import { CALENDARS_DIR, CLOUDFLARE_REDIRECTS_PATH, EDITOR_PRELUDE, FEEDS_DIR, SITE_DESCRIPTION_PATH, absoluteUrl, allPages, calendarFiles, cloudflareRedirects, editorPolicy, feedFiles, hasLanguages, loadSiteContent, parseSiteDescription, pathLanguage, redirectFiles, rootRobotsUrl, shownContent, siteLanguages } from "@goodfellow-cms/core";
3
+ import { BUILD_DATES_DIR, fileSystemSource, guardBuiltSite, reportedDate, selfHostFonts, writeBuildRecord } from "@goodfellow-cms/core/node";
4
4
  import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
5
+ import { dirname, join, relative, resolve } from "node:path";
6
+ import { createHash } from "node:crypto";
7
+ import { checkBuiltPages } from "@goodfellow-cms/core/accessibility";
5
8
  //#region src/export.ts
6
9
  /**
7
10
  * Writes the calendar files of the site's collections of events into the
@@ -9,7 +12,20 @@ import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs
9
12
  * how many it wrote.
10
13
  */
11
14
  async function writeCalendarFiles(outDir, root = process.cwd()) {
12
- const files = calendarFiles(await loadSiteContent(fileSystemSource(resolve(root))));
15
+ const files = calendarFiles(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
16
+ for (const file of files) {
17
+ const target = join(outDir, file.path.slice(1));
18
+ await mkdir(dirname(target), { recursive: true });
19
+ await writeFile(target, file.content);
20
+ }
21
+ return files.length;
22
+ }
23
+ /**
24
+ * Writes the feeds of the site's collections into the export, as `goodfellow
25
+ * build` does. Returns how many it wrote: none without the site's address.
26
+ */
27
+ async function writeFeedFiles(outDir, root = process.cwd()) {
28
+ const files = feedFiles(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
13
29
  for (const file of files) {
14
30
  const target = join(outDir, file.path.slice(1));
15
31
  await mkdir(dirname(target), { recursive: true });
@@ -17,6 +33,15 @@ async function writeCalendarFiles(outDir, root = process.cwd()) {
17
33
  }
18
34
  return files.length;
19
35
  }
36
+ /**
37
+ * The folders the calendar files and feeds go in: `calendars/` and `feeds/`,
38
+ * and on a site in several languages, the same under each other language's
39
+ * code, such as `es/feeds/`.
40
+ */
41
+ async function buildFileFolders(root = process.cwd()) {
42
+ const { settings } = await loadSiteContent(fileSystemSource(resolve(root)));
43
+ return siteLanguages(settings).flatMap((language) => [CALENDARS_DIR, FEEDS_DIR].map((dir) => `${language.prefix.slice(1)}${language.prefix ? "/" : ""}${dir}`));
44
+ }
20
45
  /** The `basePath` the export was built with, which Next.js records in `.next/routes-manifest.json`. */
21
46
  async function exportBasePath(root) {
22
47
  try {
@@ -30,27 +55,89 @@ async function exists(path) {
30
55
  return access(path).then(() => true, () => false);
31
56
  }
32
57
  /**
58
+ * Copies the site's Google Fonts into the export and points its pages at
59
+ * them, as `goodfellow build` does (`selfHostFonts()`), with the export's base
60
+ * path, and says where search engines read `robots.txt` for a site in a
61
+ * subfolder (`rootRobotsUrl()`).
62
+ */
63
+ async function writeFontFiles(outDir, root = process.cwd()) {
64
+ const { settings } = await loadSiteContent(fileSystemSource(resolve(root)));
65
+ const fonts = await selfHostFonts(outDir, settings.theme, { base: await exportBasePath(root) });
66
+ const robotsElsewhere = rootRobotsUrl(settings.url);
67
+ return {
68
+ ...fonts && { fonts },
69
+ ...robotsElsewhere && { robotsElsewhere },
70
+ ...settings.url && { sitemap: absoluteUrl(settings.url, "/sitemap.xml") }
71
+ };
72
+ }
73
+ /**
74
+ * Writes the build record (`goodfellow-build.json`) into the export, as
75
+ * `goodfellow build` does, from the dates the processes that rendered its
76
+ * pages wrote into `.next/` (see `withGoodfellow()`). Returns when the site
77
+ * stops being current, if it does.
78
+ */
79
+ async function writeBuildRecordFile(outDir, root = process.cwd()) {
80
+ const dir = resolve(root);
81
+ const content = await loadSiteContent(fileSystemSource(dir));
82
+ const date = await reportedDate(join(dir, ".next", BUILD_DATES_DIR));
83
+ return (await writeBuildRecord(outDir, {
84
+ root: dir,
85
+ content,
86
+ now: /* @__PURE__ */ new Date(),
87
+ ...date && { date }
88
+ })).currentUntil;
89
+ }
90
+ /**
33
91
  * 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.
92
+ * `goodfellow build` does, and the same redirects in `_redirects` for
93
+ * Cloudflare Pages, which sends them itself. Pages win: an address the export
94
+ * has a page at keeps it.
36
95
  */
37
96
  async function writeRedirectFiles(outDir, root = process.cwd()) {
38
97
  const dir = resolve(root);
39
- const content = await loadSiteContent(fileSystemSource(dir));
98
+ const content = shownContent(await loadSiteContent(fileSystemSource(dir)));
99
+ const base = await exportBasePath(dir);
40
100
  const files = redirectFiles(content, {
41
101
  taken: allPages(content).map((page) => page.path),
42
- base: await exportBasePath(dir)
102
+ base
43
103
  });
44
- let written = 0;
104
+ const written = [];
45
105
  for (const file of files) {
46
106
  const target = join(outDir, file.path.slice(1));
47
107
  const page = join(outDir, `${file.path.replace(/\/index\.html$/, "")}.html`);
48
108
  if (await exists(target) || await exists(page)) continue;
49
109
  await mkdir(dirname(target), { recursive: true });
50
110
  await writeFile(target, file.content);
51
- written++;
111
+ written.push(file);
52
112
  }
53
- return written;
113
+ const cloudflare = cloudflareRedirects(written, base);
114
+ const redirects = join(outDir, CLOUDFLARE_REDIRECTS_PATH.slice(1));
115
+ if (cloudflare.file && !await exists(redirects)) await writeFile(redirects, cloudflare.file.content);
116
+ return {
117
+ pages: written.length,
118
+ cloudflareLeft: cloudflare.left
119
+ };
120
+ }
121
+ /**
122
+ * For a site with an admin address (its config's `admin`, which the exported
123
+ * `goodfellow-site.json` carries), puts the guard first in every page of the
124
+ * export and adds Cloudflare Pages' header rules, as `goodfellow build` does.
125
+ * Run it last, once every page is written. Returns how many pages it guarded,
126
+ * or `undefined` for a site without an admin address.
127
+ */
128
+ async function guardExport(outDir, root = process.cwd()) {
129
+ const text = await readFile(join(outDir, SITE_DESCRIPTION_PATH), "utf8").catch(() => void 0);
130
+ const parsed = text === void 0 ? void 0 : parseSiteDescription(text);
131
+ const admin = parsed?.ok ? parsed.description.admin : void 0;
132
+ if (!admin) return void 0;
133
+ const dir = resolve(root);
134
+ const content = await loadSiteContent(fileSystemSource(dir));
135
+ return guardBuiltSite(outDir, {
136
+ admin,
137
+ ...parsed?.ok && parsed.description.broker && { broker: parsed.description.broker },
138
+ siteUrl: content.settings.url,
139
+ base: `${await exportBasePath(dir)}/`
140
+ });
54
141
  }
55
142
  /**
56
143
  * Puts a static export's prefetch files where the browser asks for them.
@@ -88,5 +175,124 @@ async function fixSegmentFiles(outDir) {
88
175
  await visit(outDir);
89
176
  return moved;
90
177
  }
178
+ /** Where the editor's frames' page is in an export, and in a full-stack site's build. */
179
+ const EDITOR_PAGES = ["admin/editor/index.html", "admin/editor.html"];
180
+ function scriptHash(text) {
181
+ return `sha256-${createHash("sha256").update(text).digest("base64")}`;
182
+ }
183
+ /**
184
+ * Gives the editor's frames' page (`app/admin/editor/`) its Content Security
185
+ * Policy and first script, which removes WebRTC, as the first things in its
186
+ * head, so they apply before anything Next.js put there. The policy runs only
187
+ * the site's own scripts, Next.js's inline ones by their hashes, and fetches
188
+ * nothing (`editorPolicy()`). Returns whether it found the page.
189
+ */
190
+ async function writeEditorPolicy(dir) {
191
+ let found = false;
192
+ for (const name of EDITOR_PAGES) {
193
+ const file = join(dir, name);
194
+ const html = await readFile(file, "utf8").catch(() => void 0);
195
+ if (html === void 0 || html.includes("http-equiv=\"Content-Security-Policy\"")) continue;
196
+ const inline = [...html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g)].map((match) => match[1] ?? "");
197
+ const head = `<meta http-equiv="Content-Security-Policy" content="${editorPolicy({
198
+ scriptHashes: [EDITOR_PRELUDE, ...inline].map(scriptHash),
199
+ blobScripts: true
200
+ })}"/><script>${EDITOR_PRELUDE}<\/script>`;
201
+ await writeFile(file, html.replace(/<head([^>]*)>/, (open) => `${open}${head}`));
202
+ found = true;
203
+ }
204
+ return found;
205
+ }
206
+ /** Says a page's language on its `<html>`, in place of the root layout's. */
207
+ function withPageLanguage(html, language) {
208
+ return html.replace(/<html\b([^>]*?)\slang="[^"]*"/, `<html$1 lang="${language}"`);
209
+ }
210
+ /**
211
+ * On a site in several languages, puts each page's language on its `<html>`,
212
+ * which Next.js's root layout, shared by every page, says is the main one:
213
+ * in a static export's pages (`outDir`), or, without one, in the pages a
214
+ * full-stack site's `next build` rendered ahead. Returns how many it changed.
215
+ */
216
+ async function writePageLanguages(outDir, root = process.cwd()) {
217
+ const dir = resolve(root);
218
+ const { settings } = await loadSiteContent(fileSystemSource(dir));
219
+ if (!hasLanguages(settings)) return 0;
220
+ const base = outDir ?? join(dir, ".next/server/app");
221
+ const files = (await readdir(base, { recursive: true })).map((file) => file.split("\\").join("/")).filter((file) => file.endsWith(".html") && !/^(admin|_next|pagefind)\//.test(file));
222
+ let changed = 0;
223
+ for (const file of files) {
224
+ const path = `/${file.replace(/(^|\/)index\.html$/, "").replace(/\.html$/, "")}`.replace(/\/$/, "") || "/";
225
+ const html = await readFile(join(base, file), "utf8");
226
+ const fixed = withPageLanguage(html, pathLanguage(settings, path));
227
+ if (fixed === html) continue;
228
+ await writeFile(join(base, file), fixed);
229
+ changed++;
230
+ }
231
+ return changed;
232
+ }
233
+ /**
234
+ * The pages `next build` rendered ahead for a full-stack site (`withGoodfellow(config,
235
+ * { server: true })`), at their addresses on the site, without its base path, as the
236
+ * Search block looks them up. Next.js lists them in `.next/prerender-manifest.json`
237
+ * and keeps their HTML in `.next/server/app/`. The admin panel and Next.js's own pages are left out.
238
+ */
239
+ async function builtPages(root = process.cwd()) {
240
+ const next = join(resolve(root), ".next");
241
+ const manifest = JSON.parse(await readFile(join(next, "prerender-manifest.json"), "utf8"));
242
+ const trailingSlash = JSON.parse(await readFile(join(next, "required-server-files.json"), "utf8")).config?.trailingSlash === true;
243
+ const pages = [];
244
+ for (const route of Object.keys(manifest.routes ?? {})) {
245
+ if (route.startsWith("/_") || route === "/admin" || route.startsWith("/admin/")) continue;
246
+ const file = join(next, "server/app", `${route === "/" ? "/index" : route}.html`);
247
+ if (!await exists(file)) continue;
248
+ const url = route === "/" ? "/" : trailingSlash ? `${route}/` : route;
249
+ pages.push({
250
+ url,
251
+ html: await readFile(file, "utf8")
252
+ });
253
+ }
254
+ return pages;
255
+ }
256
+ /**
257
+ * The pages a static export has, at their addresses on the site, without its
258
+ * base path: `about.html` and `about/index.html` are `/about`. The admin panel
259
+ * and Next.js's own files are left out.
260
+ */
261
+ async function exportedPages(outDir) {
262
+ 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");
263
+ const pages = [];
264
+ for (const file of files) {
265
+ const url = `/${file.replace(/(^|\/)index\.html$/, "").replace(/\.html$/, "")}`.replace(/\/$/, "") || "/";
266
+ pages.push({
267
+ url,
268
+ html: await readFile(join(outDir, file), "utf8")
269
+ });
270
+ }
271
+ return pages;
272
+ }
273
+ /** The content file a page comes from: its own, or, for pages blocks add such as a list's second page, its page's. */
274
+ function contentFile(pages, path) {
275
+ const own = pages.find((page) => page.path === path);
276
+ if (own) return own.file;
277
+ return pages.filter((page) => page.path !== "/" && path.startsWith(`${page.path}/`)).sort((a, b) => b.path.length - a.path.length)[0]?.file;
278
+ }
279
+ /**
280
+ * What automated checks find on the pages Next.js built, as `goodfellow build`
281
+ * reports them: images without alt text, headings that skip a level, links
282
+ * that don't say where they go, and axe-core's checks (`checkBuiltPages()`).
283
+ * Pages that aren't the site's, such as Next.js's own, are left out.
284
+ */
285
+ async function checkPages(pages, root = process.cwd()) {
286
+ const known = allPages(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
287
+ return checkBuiltPages(pages.flatMap((page) => {
288
+ const path = page.url.replace(/(.)\/$/, "$1");
289
+ const file = contentFile(known, path);
290
+ return file ? [{
291
+ path,
292
+ file,
293
+ html: page.html
294
+ }] : [];
295
+ }));
296
+ }
91
297
  //#endregion
92
- export { fixSegmentFiles, writeCalendarFiles, writeRedirectFiles };
298
+ export { EDITOR_PAGES, NEXT_SITE_FILES, buildFileFolders, builtPages, checkPages, exportedPages, fixSegmentFiles, guardExport, writeBuildRecordFile, writeCalendarFiles, writeEditorPolicy, writeFeedFiles, writeFontFiles, writePageLanguages, writeRedirectFiles };
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, 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",
@@ -99,22 +111,11 @@ function goodfellowPages(config, options = {}) {
99
111
  precedence: "default",
100
112
  children: themeToCss(content.settings.theme, standIns)
101
113
  }),
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
- ] })
114
+ fontsUrl && /* @__PURE__ */ jsx("link", {
115
+ rel: "stylesheet",
116
+ href: fontsUrl,
117
+ precedence: "default"
118
+ })
118
119
  ] });
119
120
  }
120
121
  return {
@@ -127,6 +128,14 @@ function goodfellowPages(config, options = {}) {
127
128
  const page = findPage(content, config.blocks, pathOf((await params).path));
128
129
  return page ? pageMetadata(content, applyPageEntry(configs, content, page), basePath()) : {};
129
130
  },
131
+ /**
132
+ * For `app/admin/page.tsx`: what the admin panel's page knows about the
133
+ * site, which never loads its config. The site's blocks run in the
134
+ * editor's frames, at `app/admin/editor/page.tsx`.
135
+ */
136
+ admin() {
137
+ return adminSite(config);
138
+ },
130
139
  /** For `app/admin/layout.tsx`: the admin panel's title, kept out of search engines, with the site's icon. */
131
140
  async adminMetadata() {
132
141
  const favicon = await site().then((content) => content.settings.favicon, () => void 0);
@@ -142,21 +151,53 @@ function goodfellowPages(config, options = {}) {
142
151
  */
143
152
  async Layout({ children }) {
144
153
  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, {
154
+ const options = await renderOptions();
155
+ let header;
156
+ let footer;
157
+ if (hasLanguages(content.settings)) {
158
+ const languageMap$1 = languageMap(content, exportedPages(content, config.blocks));
159
+ const layouts = await Promise.all(siteLanguages(content.settings).map(async (language) => ({
160
+ prefix: language.prefix,
161
+ layout: await prepareLayout(configs, content, {
162
+ ...options,
163
+ languageMap: languageMap$1
164
+ }, language.code)
165
+ })));
166
+ header = /* @__PURE__ */ jsx(ByLanguage, { variants: layouts.map(({ prefix, layout }) => ({
167
+ prefix,
168
+ children: /* @__PURE__ */ jsx(PageHeader, {
169
+ site: layout.site,
170
+ layoutConfig: layout.layoutConfig,
171
+ data: layout.header
172
+ })
173
+ })) });
174
+ footer = /* @__PURE__ */ jsx(ByLanguage, { variants: layouts.map(({ prefix, layout }) => ({
175
+ prefix,
176
+ children: /* @__PURE__ */ jsx(PageFooter, {
177
+ site: layout.site,
178
+ layoutConfig: layout.layoutConfig,
179
+ data: layout.footer
180
+ })
181
+ })) });
182
+ } else {
183
+ const layout = await prepareLayout(configs, content, options);
184
+ header = /* @__PURE__ */ jsx(PageHeader, {
150
185
  site: layout.site,
151
186
  layoutConfig: layout.layoutConfig,
152
187
  data: layout.header
153
- }),
154
- children,
155
- /* @__PURE__ */ jsx(PageFooter, {
188
+ });
189
+ footer = /* @__PURE__ */ jsx(PageFooter, {
156
190
  site: layout.site,
157
191
  layoutConfig: layout.layoutConfig,
158
192
  data: layout.footer
159
- }),
193
+ });
194
+ }
195
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
196
+ /* @__PURE__ */ jsx(Theme, { content }),
197
+ /* @__PURE__ */ jsx(HeadCode, { code: content.code?.head }),
198
+ header,
199
+ children,
200
+ footer,
160
201
  /* @__PURE__ */ jsx(CurrentMenuLinks, { basePath: basePath() }),
161
202
  /* @__PURE__ */ jsx(BodyCode, { code: content.code?.body })
162
203
  ] });
@@ -172,11 +213,14 @@ function goodfellowPages(config, options = {}) {
172
213
  notFound();
173
214
  }
174
215
  const prepared = await preparePage(configs, content, page, await renderOptions());
175
- return /* @__PURE__ */ jsx(PageContent, {
216
+ const pageContent = /* @__PURE__ */ jsx(PageContent, {
176
217
  site: prepared.site,
177
218
  pageConfig: prepared.pageConfig,
178
- page: prepared.data
219
+ page: prepared.data,
220
+ ...prepared.structuredData && { structuredData: prepared.structuredData }
179
221
  });
222
+ if (!hasLanguages(content.settings)) return pageContent;
223
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(PageLanguage, { language: prepared.site.settings.language }), pageContent] });
180
224
  },
181
225
  /**
182
226
  * The site's "Page not found" page (`content/pages/404.json`), for `app/not-found.tsx`,
@@ -188,7 +232,7 @@ function goodfellowPages(config, options = {}) {
188
232
  if (!page) return /* @__PURE__ */ jsx("h1", { children: "Page not found" });
189
233
  const prepared = await preparePage(configs, content, page, await renderOptions());
190
234
  return /* @__PURE__ */ jsxs(Fragment, { children: [
191
- /* @__PURE__ */ jsx("title", { children: getPageHead(content.settings, page).title }),
235
+ /* @__PURE__ */ jsx("title", { children: pageHead(content, page).title }),
192
236
  /* @__PURE__ */ jsx(Theme, { content }),
193
237
  /* @__PURE__ */ jsx(HeadCode, { code: content.code?.head }),
194
238
  /* @__PURE__ */ jsx(PageBody, {
@@ -202,12 +246,13 @@ function goodfellowPages(config, options = {}) {
202
246
  /* @__PURE__ */ jsx(BodyCode, { code: content.code?.body })
203
247
  ] });
204
248
  },
205
- /** For `app/sitemap.ts`: every page but "Page not found", once the site's address is set. */
249
+ /** For `app/sitemap.ts`: every page but "Page not found" and pages hidden from search engines, once the site's address is set. */
206
250
  async sitemap() {
207
251
  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) }));
252
+ return sitemapPages(content, exportedPages(content, config.blocks)).map(({ url, alternates }) => ({
253
+ url,
254
+ ...alternates && { alternates: { languages: alternates } }
255
+ }));
211
256
  },
212
257
  /**
213
258
  * For `app/admin/demo-content.json/route.ts`: in a demo (the config's `demo`), the copy of
@@ -0,0 +1,57 @@
1
+ import { GoodfellowConfig } from "@goodfellow-cms/core";
2
+ import { AppApiOptions } from "@goodfellow-cms/core/node";
3
+ import { ServerKeys } from "@goodfellow-cms/ai";
4
+ //#region src/server.d.ts
5
+ /** The environment variable the server's token is read from, unless `goodfellowApi({ token })` gives one. */
6
+ export declare const GIT_TOKEN_VARIABLE = "GOODFELLOW_GIT_TOKEN";
7
+ export interface GoodfellowApiOptions {
8
+ /** The site's `goodfellow.config.tsx`, whose `backend` says where the site is stored. */
9
+ config: GoodfellowConfig;
10
+ /**
11
+ * A token that can read and publish to the site's repository, such as a
12
+ * fine-grained GitHub token or a GitLab project access token. It never
13
+ * reaches a browser. Defaults to the `GOODFELLOW_GIT_TOKEN` environment variable.
14
+ */
15
+ token?: string | (() => Promise<string>);
16
+ /**
17
+ * Who's signed in, from the site's own sign-in (such as Better Auth's
18
+ * `auth.api.getSession({ headers: request.headers })`): their name, email
19
+ * address and role, or `null` if nobody is, or `false` if they may not edit.
20
+ */
21
+ authorize: AppApiOptions["authorize"];
22
+ /** Where the admin panel sends people to sign in, such as `/sign-in?next=/admin/`. */
23
+ signIn: string;
24
+ /** Where the admin panel's Sign out button goes, if the site has an address for it. */
25
+ signOut?: string;
26
+ /**
27
+ * Keys for AI services, such as `{ anthropic: process.env.ANTHROPIC_API_KEY }`,
28
+ * so the AI assistant's requests go through the server and editors need no
29
+ * keys of their own. Services without a key are left out.
30
+ */
31
+ ai?: ServerKeys;
32
+ /** Told about failures editors only hear about as "something went wrong". Defaults to `console.error`. */
33
+ onError?: (error: unknown) => void;
34
+ }
35
+ /** The route parameters of a catch-all route named `[...path]`. */
36
+ interface RouteContext {
37
+ params: Promise<{
38
+ path?: string[];
39
+ }>;
40
+ }
41
+ /**
42
+ * The admin panel's API on a full-stack Next.js site, for
43
+ * `app/api/goodfellow/[...path]/route.ts`:
44
+ *
45
+ * ```ts
46
+ * export const { GET, POST } = goodfellowApi({ config, authorize, signIn: "/sign-in" });
47
+ * ```
48
+ *
49
+ * The admin panel (`<GoodfellowAdmin server="/api/goodfellow" />`) reads and
50
+ * publishes through it, and the server reaches the repository with its own
51
+ * token, naming each editor as the author of what they publish.
52
+ */
53
+ export declare function goodfellowApi(options: GoodfellowApiOptions): {
54
+ GET: (request: Request, context: RouteContext) => Promise<Response>;
55
+ POST: (request: Request, context: RouteContext) => Promise<Response>;
56
+ };
57
+ //#endregion
package/dist/server.js ADDED
@@ -0,0 +1,45 @@
1
+ import { appApi } from "@goodfellow-cms/core/node";
2
+ import { aiServer } from "@goodfellow-cms/ai";
3
+ //#region src/server.ts
4
+ /** The environment variable the server's token is read from, unless `goodfellowApi({ token })` gives one. */
5
+ const GIT_TOKEN_VARIABLE = "GOODFELLOW_GIT_TOKEN";
6
+ /**
7
+ * The admin panel's API on a full-stack Next.js site, for
8
+ * `app/api/goodfellow/[...path]/route.ts`:
9
+ *
10
+ * ```ts
11
+ * export const { GET, POST } = goodfellowApi({ config, authorize, signIn: "/sign-in" });
12
+ * ```
13
+ *
14
+ * The admin panel (`<GoodfellowAdmin server="/api/goodfellow" />`) reads and
15
+ * publishes through it, and the server reaches the repository with its own
16
+ * token, naming each editor as the author of what they publish.
17
+ */
18
+ function goodfellowApi(options) {
19
+ let api;
20
+ function handler() {
21
+ if (api) return api;
22
+ const { backend } = options.config;
23
+ if (!backend?.server) throw new Error("goodfellowApi() needs the config's `backend`, such as github({ repo }) or gitlab({ project }).");
24
+ const token = options.token || process.env["GOODFELLOW_GIT_TOKEN"] || (() => {
25
+ throw new Error(`goodfellowApi() needs a token: set GOODFELLOW_GIT_TOKEN, or pass \`token\`.`);
26
+ })();
27
+ const ai = options.ai && aiServer(options.ai);
28
+ api = appApi({
29
+ git: backend.server({ token }),
30
+ authorize: options.authorize,
31
+ signIn: options.signIn,
32
+ ...options.signOut && { signOut: options.signOut },
33
+ ...ai && ai.services.length > 0 && { ai },
34
+ ...options.onError && { onError: options.onError }
35
+ });
36
+ return api;
37
+ }
38
+ const route = async (request, context) => handler()(request, ((await context.params).path ?? []).join("/"));
39
+ return {
40
+ GET: route,
41
+ POST: route
42
+ };
43
+ }
44
+ //#endregion
45
+ export { GIT_TOKEN_VARIABLE, goodfellowApi };