@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/README.md CHANGED
@@ -4,7 +4,7 @@ Goodfellow pages and the admin panel in a Next.js App Router site, exported as s
4
4
 
5
5
  Start from the Next.js starter: `npm create goodfellow@latest my-site -- --template next`.
6
6
 
7
- [Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub or GitLab repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
7
+ [Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub, GitLab or Bitbucket repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
8
8
 
9
9
  More in the docs: [Next.js](https://goodfellow-cms.github.io/goodfellow-cms/docs/nextjs) and [Full-stack Next.js sites](https://goodfellow-cms.github.io/goodfellow-cms/docs/full-stack-nextjs).
10
10
 
@@ -1,43 +1,150 @@
1
1
  #!/usr/bin/env node
2
2
  // After `next build`: `goodfellow-next finish out` fixes the export's prefetch files on Windows,
3
- // writes the calendar files of collections of events and the pages at redirects' old addresses, and the search index for sites with a search block. `index` only writes the index.
3
+ // writes the calendar files of collections of events, collections' feeds, the pages at redirects' old addresses and Cloudflare Pages' _redirects, and the search index for sites with a search block,
4
+ // copies the site's Google Fonts into the export, builds plugins' admin frames and runs their build steps, starts every page with the admin address's guard for a site with one, and reports what automated checks find on the exported pages. `index` only writes the index.
4
5
  // `goodfellow-next update` updates Goodfellow, as `goodfellow update` does for other sites.
6
+ // `goodfellow-next task [--out <file>]` runs one of a plugin's tasks for the admin panel, as `goodfellow task` does.
7
+ // `goodfellow-next due [--since <time>] [--output <file>]` says whether a scheduled change has come due, as `goodfellow due` does.
8
+ // `goodfellow-next nightly [--mode <mode>]` says whether tonight's build is needed, as `goodfellow nightly` does.
5
9
  // `goodfellow-next content link|copy [folder]` puts a content repository's files in place, for a site whose
6
10
  // content is kept in a repository of its own.
11
+ // `goodfellow-next import wordpress <file or address> [--dry-run]` imports a WordPress site, as `goodfellow import` does.
12
+ // `goodfellow-next export wordpress [file]` writes the site as a WordPress export file, as `goodfellow export` does.
13
+ // `goodfellow-next admin add <address>|check|remove` sets the admin address, and `goodfellow-next broker use <address>|remove`
14
+ // the token broker, in goodfellow.config.tsx, as `goodfellow admin` and `goodfellow broker` do.
15
+ import { existsSync, readFileSync } from "node:fs";
7
16
  import { rm } from "node:fs/promises";
8
17
  import { relative } from "node:path";
9
18
  import { parseArgs } from "node:util";
10
- import { formatUpdateResult } from "@goodfellow-cms/core";
11
- import { ContentRepositoryError, setUpContentRepository, update } from "@goodfellow-cms/core/node";
19
+ import { buildNotes, CLOUDFLARE_REDIRECTS_LIMIT, configAdmin, formatUpdateResult } from "@goodfellow-cms/core";
20
+ import { reportSiteProblems } from "@goodfellow-cms/core/accessibility";
21
+ import {
22
+ AdminSetupError,
23
+ BrokerSetupError,
24
+ ContentRepositoryError,
25
+ checkSiteAdminAddress,
26
+ contentRepositoryProblem,
27
+ exportWordPress,
28
+ formatAdminResult,
29
+ formatWordPressExport,
30
+ formatWordPressImport,
31
+ importWordPress,
32
+ nightlyCheck,
33
+ reportDue,
34
+ reportNightly,
35
+ scheduleDue,
36
+ setAdminAddress,
37
+ setBrokerAddress,
38
+ setUpContentRepository,
39
+ update,
40
+ } from "@goodfellow-cms/core/node";
12
41
  import { writePagesSearchIndex, writeSearchIndex } from "@goodfellow-cms/core/search-index";
13
- import { builtPages, fixSegmentFiles, writeCalendarFiles, writeRedirectFiles } from "@goodfellow-cms/next/export";
42
+ import { WordPressReadError } from "@goodfellow-cms/core/wordpress";
43
+ import {
44
+ buildFileFolders,
45
+ builtPages,
46
+ checkPages,
47
+ exportedPages,
48
+ fixSegmentFiles,
49
+ guardExport,
50
+ NEXT_SITE_FILES,
51
+ writeBuildRecordFile,
52
+ writeCalendarFiles,
53
+ writeEditorPolicy,
54
+ writeFeedFiles,
55
+ writeFontFiles,
56
+ writePageLanguages,
57
+ writePluginFiles,
58
+ writeRedirectFiles,
59
+ writeTaskResult,
60
+ } from "@goodfellow-cms/next/export";
14
61
 
15
62
  const [command, dir = "out"] = process.argv.slice(2);
63
+ const CONFIG_FILES = ["goodfellow.config.tsx", "goodfellow.config.ts", "goodfellow.config.jsx", "goodfellow.config.js"];
16
64
  // A full-stack site (withGoodfellow(config, { server: true })): Next.js serves its pages and sends redirects
17
- // itself, so the calendar files and the search index are written into public/, which `next start` and hosts
65
+ // itself, so the calendar files, feeds and the search index are written into public/, which `next start` and hosts
18
66
  // serve as static files, as they are for an export.
19
67
  if (command === "finish" && dir === "--server") {
20
- await rm("public/calendars", { recursive: true, force: true });
68
+ // Next.js renders a full-stack site's pages as it serves them, so nothing can put a guard first in each.
69
+ const config = CONFIG_FILES.find((file) => existsSync(file));
70
+ if (config && configAdmin(readFileSync(config, "utf8"))) {
71
+ console.error(
72
+ "A full-stack site can't have an admin address: its admin panel signs in with the site's own sign-in.",
73
+ );
74
+ process.exit(1);
75
+ }
76
+ for (const folder of await buildFileFolders()) await rm(`public/${folder}`, { recursive: true, force: true });
77
+ // The editor's frames' page, which next start serves as it was rendered.
78
+ await writeEditorPolicy(".next/server/app");
21
79
  const calendars = await writeCalendarFiles("public");
22
80
  if (calendars > 0) console.log(`Wrote ${calendars} calendar files, in public/calendars`);
23
- const indexed = await writePagesSearchIndex(await builtPages(), "public/pagefind");
81
+ const feeds = await writeFeedFiles("public");
82
+ if (feeds > 0) console.log(`Wrote ${feeds} feeds, in public/feeds`);
83
+ // Before the pages are read, so the search index and the checks see each page's language.
84
+ const languages = await writePageLanguages(undefined);
85
+ if (languages > 0) console.log(`Marked ${languages} pages with their language`);
86
+ // Plugins' admin frames and build steps, into public/, which next start serves.
87
+ const plugins = await writePluginFiles("public");
88
+ if (plugins.frames.length > 0)
89
+ console.log(`Wrote the admin frames of ${plugins.frames.join(", ")}, in public/admin/plugins`);
90
+ const pages = await builtPages();
91
+ const indexed = await writePagesSearchIndex(pages, "public/pagefind");
24
92
  console.log(
25
93
  indexed === undefined
26
94
  ? "No page has a search block, so there's nothing to index"
27
95
  : `Indexed ${indexed} pages for search, in public/pagefind`,
28
96
  );
97
+ for (const line of reportSiteProblems(await checkPages(pages))) console.log(line);
29
98
  process.exit(0);
30
99
  }
31
100
  if (command === "update") {
32
101
  const { values } = parseArgs({
33
102
  args: process.argv.slice(3),
34
- options: { to: { type: "string" }, publish: { type: "boolean" } },
103
+ options: { to: { type: "string" }, publish: { type: "boolean" }, commit: { type: "boolean" } },
35
104
  });
36
- const result = await update({ to: values.to, publish: values.publish });
105
+ // The starter's admin pages, kept up to date where nobody has changed them, on sites with an admin panel.
106
+ const siteFiles = existsSync("app/admin/page.tsx") ? NEXT_SITE_FILES : [];
107
+ const result = await update({ to: values.to, publish: values.publish, commit: values.commit, siteFiles });
37
108
  // For the admin panel, which reads it in the host's log.
38
109
  console.log(formatUpdateResult(result));
39
110
  process.exit(result.state === "failed" ? 1 : 0);
40
111
  }
112
+ if (command === "task") {
113
+ const { values } = parseArgs({ args: process.argv.slice(3), options: { out: { type: "string" } } });
114
+ const env = process.env;
115
+ await writeTaskResult(
116
+ ".",
117
+ {
118
+ id: env.GOODFELLOW_TASK_REQUEST ?? "",
119
+ plugin: env.GOODFELLOW_TASK_PLUGIN ?? "",
120
+ task: env.GOODFELLOW_TASK_NAME ?? "",
121
+ input: env.GOODFELLOW_TASK_INPUT ?? "",
122
+ key: env.GOODFELLOW_TASK_KEY ?? "",
123
+ },
124
+ values.out,
125
+ );
126
+ process.exit(0);
127
+ }
128
+ if (command === "due") {
129
+ const { values } = parseArgs({
130
+ args: process.argv.slice(3),
131
+ options: { since: { type: "string" }, output: { type: "string" } },
132
+ });
133
+ const result = await scheduleDue({ since: values.since });
134
+ for (const line of await reportDue(result, process.env, values.output ?? process.env.GITHUB_OUTPUT))
135
+ console.log(line);
136
+ process.exit(0);
137
+ }
138
+ if (command === "nightly") {
139
+ const { values } = parseArgs({
140
+ args: process.argv.slice(3),
141
+ options: { mode: { type: "string" }, output: { type: "string" } },
142
+ });
143
+ const siteFiles = existsSync("app/admin/page.tsx") ? NEXT_SITE_FILES : [];
144
+ const result = await nightlyCheck({ siteFiles, ...(values.mode && { mode: values.mode }) });
145
+ for (const line of await reportNightly(result, values.output ?? process.env.GITHUB_OUTPUT)) console.log(line);
146
+ process.exit(0);
147
+ }
41
148
  if (command === "content") {
42
149
  const [action, from] = process.argv.slice(3);
43
150
  if (action === "link" || action === "copy") {
@@ -57,37 +164,150 @@ if (command === "content") {
57
164
  }
58
165
  }
59
166
  }
167
+ if (command === "admin" || command === "broker") {
168
+ const [action, argument] = process.argv.slice(3);
169
+ try {
170
+ if (command === "admin" && ((action === "add" && argument) || action === "check" || action === "remove")) {
171
+ const result =
172
+ action === "check"
173
+ ? await checkSiteAdminAddress(".")
174
+ : await setAdminAddress(".", action === "add" ? argument : undefined);
175
+ const { lines, ok } = formatAdminResult(action, result, "goodfellow-next");
176
+ for (const line of lines) (ok ? console.log : console.error)(line);
177
+ process.exit(ok ? 0 : 1);
178
+ }
179
+ if (command === "broker" && ((action === "use" && argument) || action === "remove")) {
180
+ const address = await setBrokerAddress(".", action === "use" ? argument : undefined);
181
+ console.log(
182
+ address
183
+ ? `Editors sign in at ${address} once the site has rebuilt with goodfellow.config.tsx.`
184
+ : "Editors sign in in the admin panel itself once the site has rebuilt with goodfellow.config.tsx.",
185
+ );
186
+ process.exit(0);
187
+ }
188
+ } catch (error) {
189
+ if (!(error instanceof AdminSetupError || error instanceof BrokerSetupError)) throw error;
190
+ console.error(error.message);
191
+ process.exit(1);
192
+ }
193
+ }
194
+ if (command === "import") {
195
+ const { positionals, values } = parseArgs({
196
+ args: process.argv.slice(3),
197
+ allowPositionals: true,
198
+ options: { "dry-run": { type: "boolean" } },
199
+ });
200
+ const [kind, from] = positionals;
201
+ const problem = contentRepositoryProblem(".");
202
+ if (kind === "wordpress" && from && !problem) {
203
+ try {
204
+ const result = await importWordPress(".", from, { dryRun: values["dry-run"], log: (line) => console.log(line) });
205
+ console.log(formatWordPressImport(result));
206
+ process.exit(0);
207
+ } catch (error) {
208
+ if (!(error instanceof WordPressReadError)) throw error;
209
+ console.error(error.message);
210
+ process.exit(1);
211
+ }
212
+ }
213
+ if (problem) {
214
+ console.error(problem);
215
+ process.exit(1);
216
+ }
217
+ }
218
+ if (command === "export") {
219
+ const [kind, file] = process.argv.slice(3);
220
+ const problem = contentRepositoryProblem(".");
221
+ if (problem) {
222
+ console.error(problem);
223
+ process.exit(1);
224
+ }
225
+ if (kind === "wordpress") {
226
+ console.log(formatWordPressExport(await exportWordPress(".", file)));
227
+ process.exit(0);
228
+ }
229
+ }
60
230
  if (command !== "finish" && command !== "index") {
61
231
  console.error(
62
232
  [
63
233
  "Usage: goodfellow-next finish [folder]",
64
234
  " goodfellow-next finish --server",
65
235
  " goodfellow-next index [folder]",
66
- " goodfellow-next update [--to fixes|automatic|latest|<release>] [--publish]",
236
+ " goodfellow-next update [--to fixes|automatic|latest|<release>] [--publish | --commit]",
237
+ " goodfellow-next due [--since <time>] [--output <file>]",
238
+ " goodfellow-next nightly [--mode changed|always|off] [--output <file>]",
67
239
  " goodfellow-next content link|copy [folder]",
240
+ " goodfellow-next import wordpress <file or address> [--dry-run]",
241
+ " goodfellow-next export wordpress [file]",
242
+ " goodfellow-next admin add <address> | check | remove",
243
+ " goodfellow-next broker use <address> | remove",
68
244
  "",
69
245
  "finish Gets the exported site in folder (default: out) ready to publish: puts the files",
70
246
  " Next.js uses to load pages ahead where browsers look for them, writes calendar files",
71
- " for collections of events and pages that send visitors on from old addresses, and",
72
- " indexes it for search. With --server, for a full-stack site, writes the calendar",
73
- " files into public/calendars and indexes the pages next build rendered into",
74
- " public/pagefind instead.",
247
+ " for collections of events, collections' feeds and pages that send visitors on from",
248
+ " old addresses, builds plugins' admin frames and runs their build steps, indexes it",
249
+ " for search, and lists what automated accessibility checks",
250
+ " find on its pages. With --server, for a full-stack site, writes the calendar files",
251
+ " and feeds into public/calendars and public/feeds and indexes and checks the pages",
252
+ " next build rendered, with the index in public/pagefind, instead.",
75
253
  "index Only indexes the exported site for its search block.",
76
254
  "update Updates Goodfellow, and blocks from its registry, if the site still builds.",
255
+ " --publish pushes it to the main branch; --commit only commits it, for a job",
256
+ " that runs none of the site's code to publish.",
257
+ "due Says whether a scheduled change has come due since --since (default: the last",
258
+ " hour), so the site needs building again, and sets the step's due output on",
259
+ " GitHub Actions, or adds it to --output.",
260
+ "nightly Says whether tonight's build is needed: when the site has changed since the live",
261
+ " site was built, what it shows by today's date has, or an update is available",
262
+ " (--mode, or GOODFELLOW_NIGHTLY: changed, the default, always or off), and sets the",
263
+ " step's build and update outputs on GitHub Actions, or adds them to --output.",
77
264
  "content For a site whose content is kept in a repository of its own: links content/ and",
78
265
  " public/media/ to the content repository in folder (default: content-repository),",
79
266
  " or copies them from it, as builds do.",
267
+ "import Brings a WordPress site's pages, posts, categories, tags, menus and media into",
268
+ " this site, from its export file (Tools → Export) or its address. With --dry-run,",
269
+ " says what it would bring across and changes nothing.",
270
+ "export Writes this site as a WordPress export file (default: named after the site),",
271
+ " which WordPress imports with Tools → Import → WordPress.",
272
+ "admin The admin panel's address of its own, such as admin.example.org, in",
273
+ " goodfellow.config.tsx: add names it; check checks it once the site has rebuilt with",
274
+ " it, then starts the admin panel only there; remove stops using it.",
275
+ "broker The site's token broker in goodfellow.config.tsx: use signs editors in there once",
276
+ " it's online; remove stops using it.",
80
277
  ].join("\n"),
81
278
  );
82
279
  process.exit(1);
83
280
  }
281
+ /** What checking the exported pages found, reported once the export is ready. */
282
+ let problems = [];
84
283
  if (command === "finish") {
85
284
  const moved = await fixSegmentFiles(dir);
285
+ await writeEditorPolicy(dir);
86
286
  if (moved > 0) console.log(`Moved ${moved} of Next.js's prefetch files to where browsers look for them`);
87
287
  const calendars = await writeCalendarFiles(dir);
88
288
  if (calendars > 0) console.log(`Wrote ${calendars} calendar files`);
289
+ const feeds = await writeFeedFiles(dir);
290
+ if (feeds > 0) console.log(`Wrote ${feeds} feeds`);
291
+ const languages = await writePageLanguages(dir);
292
+ if (languages > 0) console.log(`Marked ${languages} pages with their language`);
293
+ // Before the redirects' pages, which aren't the site's.
294
+ problems = await checkPages(await exportedPages(dir));
89
295
  const redirects = await writeRedirectFiles(dir);
90
- if (redirects > 0) console.log(`Wrote ${redirects} redirects`);
296
+ if (redirects.pages > 0) console.log(`Wrote ${redirects.pages} redirects`);
297
+ if (redirects.cloudflareLeft > 0) {
298
+ console.log(
299
+ `Cloudflare Pages reads only the first ${CLOUDFLARE_REDIRECTS_LIMIT.toLocaleString("en")} lines of _redirects, so ${redirects.cloudflareLeft} old address${redirects.cloudflareLeft === 1 ? " is" : "es are"} left to the pages that send visitors on.`,
300
+ );
301
+ }
302
+ for (const line of buildNotes(await writeFontFiles(dir))) console.log(line);
303
+ // Plugins' admin frames and build steps, once Goodfellow has written the rest, before the index and the guards.
304
+ const plugins = await writePluginFiles(dir);
305
+ if (plugins.frames.length > 0) console.log(`Wrote the admin frames of ${plugins.frames.join(", ")}`);
306
+ // What the site was built from, and when it stops being current, for the nightly check.
307
+ const currentUntil = await writeBuildRecordFile(dir);
308
+ if (currentUntil) {
309
+ console.log(`What the site shows by today's date changes at ${currentUntil}, when it needs building again`);
310
+ }
91
311
  }
92
312
  const indexed = await writeSearchIndex(dir);
93
313
  console.log(
@@ -95,3 +315,8 @@ console.log(
95
315
  ? `No page in ${dir} has a search block, so there's nothing to index`
96
316
  : `Indexed ${indexed} pages for search`,
97
317
  );
318
+ if (command === "finish") {
319
+ const guarded = await guardExport(dir);
320
+ if (guarded !== undefined) console.log(`Started ${guarded} pages with the admin address's guard`);
321
+ }
322
+ for (const line of reportSiteProblems(problems)) console.log(line);
package/dist/admin.d.ts CHANGED
@@ -1,11 +1,21 @@
1
- import { GoodfellowConfig } from "@goodfellow-cms/core";
1
+ import { AdminSite, GoodfellowConfig } from "@goodfellow-cms/core";
2
2
  import { Strings } from "@goodfellow-cms/admin";
3
- import "@puckeditor/core/puck.css";
4
3
  import "@goodfellow-cms/admin/styles.css";
5
4
  //#region src/admin.d.ts
6
5
  export interface GoodfellowAdminProps {
7
- /** The site's `goodfellow.config.tsx`. */
8
- config: GoodfellowConfig;
6
+ /**
7
+ * What the admin panel knows about the site, from `site.admin()` in the
8
+ * server component `app/admin/page.tsx`. The admin panel's page then never
9
+ * loads the site's config: its blocks run in the editor's frames, at `app/admin/editor/`.
10
+ */
11
+ site?: AdminSite;
12
+ /**
13
+ * The site's `goodfellow.config.tsx`, which an `app/admin/page.tsx` from
14
+ * before the editor's frames passes. That page loads every block's code
15
+ * itself, so the admin panel only says how to update it, and never signs in
16
+ * there: `goodfellow-next update` replaces it where it's still the starter's.
17
+ */
18
+ config?: GoodfellowConfig;
9
19
  /** The live site's address, for "View" links. Defaults to the site's base path, such as `/` or `/my-site/`. */
10
20
  siteUrl?: string;
11
21
  /** Replaces any of the admin panel's text, for rewording or translation. */
@@ -19,10 +29,10 @@ export interface GoodfellowAdminProps {
19
29
  server?: string;
20
30
  }
21
31
  /**
22
- * The admin panel, for a Next.js site's `app/admin/page.tsx`, which must be a
23
- * Client Component (`"use client"`) that imports the site's config.
24
- * In `next dev` it saves to the files on disk; on the live site, editors sign
25
- * in to the config's git backend, or on a full-stack site (`server`), with the site's own sign-in.
32
+ * The admin panel, for a Next.js site's `app/admin/page.tsx`, given
33
+ * `site.admin()` there. In `next dev` it saves to the files on disk; on the
34
+ * live site, editors sign in to the config's git backend, or on a full-stack
35
+ * site (`server`), with the site's own sign-in.
26
36
  */
27
- export declare function GoodfellowAdmin({ config, siteUrl, strings, server }: GoodfellowAdminProps): import("react").JSX.Element | null;
37
+ export declare function GoodfellowAdmin({ site, config, strings, ...props }: GoodfellowAdminProps): import("react").JSX.Element;
28
38
  //#endregion
package/dist/admin.js CHANGED
@@ -1,7 +1,9 @@
1
+ "use client";
2
+ import { n as EDITOR_PAGE, t as ADMIN_PAGE } from "./site-files-BeLnLxCl.js";
3
+ import { ADMIN_POLICY, DEV_EDITOR_PREFIX } from "@goodfellow-cms/core";
1
4
  import { useEffect, useState } from "react";
2
- import { jsx } from "react/jsx-runtime";
3
- import { Admin } from "@goodfellow-cms/admin";
4
- import "@puckeditor/core/puck.css";
5
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
6
+ import { Admin, OutdatedAdminPage, reloadFrames } from "@goodfellow-cms/admin";
5
7
  import "@goodfellow-cms/admin/styles.css";
6
8
  //#region src/generated/theme-css.ts
7
9
  var theme_css_default = "/*\n * Maps the site theme (CSS variables written from content/site.json) onto\n * Tailwind, so classes like bg-primary, text-muted-foreground, font-heading and\n * rounded-lg follow the theme. Import after \"tailwindcss\".\n */\n/*\n * Blocks' dark: classes apply only where the site has dark mode\n * (Site Settings), which gives its pages <meta name=\"color-scheme\"\n * content=\"light dark\">: when the visitor's computer is set to dark, or when\n * the admin panel's preview shows the site dark. Light-only sites never\n * use them, even on a computer set to dark.\n */\n@custom-variant dark {\n @media (prefers-color-scheme: dark) {\n &:where(\n :root:has(meta[name=\"color-scheme\"][content~=\"dark\"]):not([data-gf-color-scheme=\"light\"]),\n :root:has(meta[name=\"color-scheme\"][content~=\"dark\"]):not([data-gf-color-scheme=\"light\"]) *\n ) {\n @slot;\n }\n }\n &:where([data-gf-color-scheme=\"dark\"], [data-gf-color-scheme=\"dark\"] *) {\n @slot;\n }\n}\n\n@theme inline {\n --color-background: var(--background);\n --color-foreground: var(--foreground);\n --color-primary: var(--primary);\n --color-primary-foreground: var(--primary-foreground);\n --color-secondary: var(--secondary);\n --color-secondary-foreground: var(--secondary-foreground);\n --color-muted: var(--muted);\n --color-muted-foreground: var(--muted-foreground);\n --color-accent: var(--accent);\n --color-accent-foreground: var(--accent-foreground);\n --color-border: var(--border);\n /* The rest of shadcn/ui's colors, for blocks built with it, follow the ones above unless custom CSS sets them. */\n --color-card: var(--card, var(--background));\n --color-card-foreground: var(--card-foreground, var(--foreground));\n --color-popover: var(--popover, var(--background));\n --color-popover-foreground: var(--popover-foreground, var(--foreground));\n --color-destructive: var(--destructive, #dc2626);\n --color-input: var(--input, var(--border));\n --color-ring: var(--ring, var(--primary));\n --font-sans: var(--gf-font-body);\n --font-heading: var(--gf-font-heading);\n --radius-sm: calc(var(--radius) - 4px);\n --radius-md: calc(var(--radius) - 2px);\n --radius-lg: var(--radius);\n --radius-xl: calc(var(--radius) + 4px);\n}\n\n@layer base {\n *,\n ::before,\n ::after {\n border-color: var(--color-border);\n }\n\n body {\n background-color: var(--color-background);\n color: var(--color-foreground);\n /* Keeps the footer at the bottom of short pages. */\n display: flex;\n flex-direction: column;\n min-height: 100dvh;\n }\n\n .gf-main {\n flex: 1;\n }\n\n /* The credits of the photos on a page, which their licenses ask for. */\n .gf-credits {\n max-width: 72rem;\n margin: 0 auto;\n padding: 1.5rem 1rem;\n font-size: 0.75rem;\n line-height: 1.5;\n color: var(--color-muted-foreground);\n }\n\n .gf-credits a {\n text-decoration: underline;\n }\n\n h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: var(--font-heading);\n }\n}\n";
@@ -24,30 +26,93 @@ async function siteStylesheets(siteUrl) {
24
26
  }
25
27
  /** Set by `withGoodfellow()` from Next.js's `basePath`, such as `/my-site`, or empty. */
26
28
  const BASE_PATH = process.env.GOODFELLOW_BASE_PATH ?? "";
27
- async function setUp(siteUrl) {
28
- const [stylesheets, tailwindBrowser, store] = await Promise.all([
29
+ /**
30
+ * Where the editor's frames are: `app/admin/editor/`, or in `next dev` the
31
+ * bundle the local backend serves, since Next.js refuses its development
32
+ * files to sandboxed frames.
33
+ */
34
+ const EDITOR_URL = process.env.NODE_ENV === "development" ? `${BASE_PATH}${DEV_EDITOR_PREFIX}` : `${BASE_PATH}/admin/editor/`;
35
+ /**
36
+ * In `next dev`, reloads the editor's frames once they're rebuilt, so they
37
+ * show the site's new code, leaving whatever is open on the admin panel's page as it is.
38
+ */
39
+ function useEditorRebuilds() {
40
+ useEffect(() => {
41
+ if (process.env.NODE_ENV !== "development") return;
42
+ let shown;
43
+ const check = async () => {
44
+ try {
45
+ const { version } = await (await fetch(`${EDITOR_URL}version.json`, { cache: "no-store" })).json();
46
+ if (shown !== void 0 && version !== shown) reloadFrames();
47
+ shown = version;
48
+ } catch {}
49
+ };
50
+ check();
51
+ const timer = setInterval(check, 1500);
52
+ return () => clearInterval(timer);
53
+ }, []);
54
+ }
55
+ /**
56
+ * Goodfellow's git hosts, which a site's config names as `site.backend`. A
57
+ * bundler needs every package it may load named here, as the starter's
58
+ * dependencies are.
59
+ */
60
+ const HOSTS = {
61
+ "@goodfellow-cms/bitbucket": () => import("@goodfellow-cms/bitbucket"),
62
+ "@goodfellow-cms/github": () => import("@goodfellow-cms/github"),
63
+ "@goodfellow-cms/gitlab": () => import("@goodfellow-cms/gitlab")
64
+ };
65
+ async function hostFrom(source) {
66
+ const load = Object.hasOwn(HOSTS, source.package) ? HOSTS[source.package] : void 0;
67
+ const make = load && (await load())[source.export];
68
+ if (typeof make !== "function") throw new Error(`The admin panel can't sign in to ${source.package} yet.`);
69
+ return make(source.options);
70
+ }
71
+ async function setUp(siteUrl, backend) {
72
+ const [stylesheets, tailwindBrowser, store, host] = await Promise.all([
29
73
  siteStylesheets(siteUrl),
30
74
  import("./tailwind-browser-Ds-ZH7sO.js").then((module) => module.default),
31
- process.env.NODE_ENV === "development" ? import("@goodfellow-cms/admin/dev").then((module) => module.localStore(`${BASE_PATH}/__goodfellow/api`)) : void 0
75
+ process.env.NODE_ENV === "development" ? import("@goodfellow-cms/admin/dev").then((module) => module.localStore(`${BASE_PATH}/__goodfellow/api`)) : void 0,
76
+ backend ? hostFrom(backend) : void 0
32
77
  ]);
33
78
  return {
34
79
  stylesheets,
35
80
  tailwindBrowserUrl: URL.createObjectURL(new Blob([tailwindBrowser], { type: "text/javascript" })),
36
- ...store && { store }
81
+ ...store && { store },
82
+ ...host && { host }
37
83
  };
38
84
  }
39
85
  /**
40
- * The admin panel, for a Next.js site's `app/admin/page.tsx`, which must be a
41
- * Client Component (`"use client"`) that imports the site's config.
42
- * In `next dev` it saves to the files on disk; on the live site, editors sign
43
- * in to the config's git backend, or on a full-stack site (`server`), with the site's own sign-in.
86
+ * The admin panel, for a Next.js site's `app/admin/page.tsx`, given
87
+ * `site.admin()` there. In `next dev` it saves to the files on disk; on the
88
+ * live site, editors sign in to the config's git backend, or on a full-stack
89
+ * site (`server`), with the site's own sign-in.
44
90
  */
45
- function GoodfellowAdmin({ config, siteUrl = `${BASE_PATH}/`, strings, server }) {
91
+ function GoodfellowAdmin({ site, config, strings, ...props }) {
92
+ if (site) return /* @__PURE__ */ jsx(SiteAdmin, {
93
+ site,
94
+ ...strings && { strings },
95
+ ...props
96
+ });
97
+ if (config) return /* @__PURE__ */ jsx(OutdatedAdminPage, {
98
+ files: [{
99
+ path: "app/admin/page.tsx",
100
+ content: ADMIN_PAGE
101
+ }, {
102
+ path: "app/admin/editor/page.tsx",
103
+ content: EDITOR_PAGE
104
+ }],
105
+ ...strings && { strings }
106
+ });
107
+ throw new Error("GoodfellowAdmin needs `site`, from `site.admin()` in app/admin/page.tsx.");
108
+ }
109
+ function SiteAdmin({ site, siteUrl = `${BASE_PATH}/`, strings, server }) {
46
110
  const [setup, setSetup] = useState(null);
111
+ useEditorRebuilds();
47
112
  useEffect(() => {
48
113
  let current = true;
49
114
  let url;
50
- setUp(siteUrl).then((result) => {
115
+ setUp(siteUrl, site.backend).then((result) => {
51
116
  url = result.tailwindBrowserUrl;
52
117
  if (current) setSetup(result);
53
118
  else URL.revokeObjectURL(url);
@@ -56,10 +121,16 @@ function GoodfellowAdmin({ config, siteUrl = `${BASE_PATH}/`, strings, server })
56
121
  current = false;
57
122
  if (url) URL.revokeObjectURL(url);
58
123
  };
59
- }, [siteUrl]);
60
- if (!setup) return null;
61
- return /* @__PURE__ */ jsx(Admin, {
62
- config,
124
+ }, [siteUrl, site]);
125
+ const policy = /* @__PURE__ */ jsx("meta", {
126
+ httpEquiv: "Content-Security-Policy",
127
+ content: ADMIN_POLICY
128
+ });
129
+ if (!setup) return policy;
130
+ return /* @__PURE__ */ jsxs(Fragment, { children: [policy, /* @__PURE__ */ jsx(Admin, {
131
+ site,
132
+ frames: { url: EDITOR_URL },
133
+ ...setup.host && { host: setup.host },
63
134
  ...setup.store && {
64
135
  store: setup.store,
65
136
  registry: `${BASE_PATH}/__goodfellow/api/registry/{name}.json`
@@ -72,7 +143,7 @@ function GoodfellowAdmin({ config, siteUrl = `${BASE_PATH}/`, strings, server })
72
143
  },
73
144
  siteUrl,
74
145
  ...strings && { strings }
75
- });
146
+ })] });
76
147
  }
77
148
  //#endregion
78
149
  export { GoodfellowAdmin };
@@ -1,5 +1,6 @@
1
1
  import { ImageSize } from "@goodfellow-cms/core";
2
2
  import { SiteImageProps, SiteLinkProps } from "@goodfellow-cms/react";
3
+ import { ReactNode } from "react";
3
4
  //#region src/components.d.ts
4
5
  /** A link to one of the site's pages, with Next.js's client-side navigation. Next.js adds the base path. */
5
6
  export declare function NextLink(props: SiteLinkProps): import("react").JSX.Element;
@@ -14,4 +15,24 @@ export declare function NextImage({ src, alt, width, height, loading, ...props }
14
15
  export declare function CurrentMenuLinks({ basePath }: {
15
16
  basePath?: string;
16
17
  }): null;
18
+ /**
19
+ * On a site in several languages, says the page's language on `<html>`, which
20
+ * the root layout shared by every page can't. `goodfellow-next finish` writes
21
+ * it into exported pages; this keeps it right as visitors move between pages.
22
+ */
23
+ export declare function PageLanguage({ language }: {
24
+ language: string;
25
+ }): null;
26
+ /**
27
+ * On a site in several languages, shows the layout's header or footer in the
28
+ * language of the page at the current address: the layout has one of each,
29
+ * already rendered, for every language. Blocks inside it learn the address
30
+ * from `useCurrentPath()`, such as the Language switch, whose links follow it.
31
+ */
32
+ export declare function ByLanguage({ variants }: {
33
+ variants: Array<{
34
+ prefix: string;
35
+ children: ReactNode;
36
+ }>;
37
+ }): import("react").JSX.Element;
17
38
  //#endregion
@@ -1,4 +1,5 @@
1
1
  "use client";
2
+ import { CurrentPathProvider } from "@goodfellow-cms/react";
2
3
  import { usePathname } from "next/navigation";
3
4
  import { useEffect } from "react";
4
5
  import Image from "next/image";
@@ -47,5 +48,30 @@ function CurrentMenuLinks({ basePath = "" }) {
47
48
  }, [pathname, basePath]);
48
49
  return null;
49
50
  }
51
+ /**
52
+ * On a site in several languages, says the page's language on `<html>`, which
53
+ * the root layout shared by every page can't. `goodfellow-next finish` writes
54
+ * it into exported pages; this keeps it right as visitors move between pages.
55
+ */
56
+ function PageLanguage({ language }) {
57
+ useEffect(() => {
58
+ document.documentElement.lang = language;
59
+ }, [language]);
60
+ return null;
61
+ }
62
+ /**
63
+ * On a site in several languages, shows the layout's header or footer in the
64
+ * language of the page at the current address: the layout has one of each,
65
+ * already rendered, for every language. Blocks inside it learn the address
66
+ * from `useCurrentPath()`, such as the Language switch, whose links follow it.
67
+ */
68
+ function ByLanguage({ variants }) {
69
+ const pathname = usePathname();
70
+ const shown = variants.find((variant) => variant.prefix && (pathname === variant.prefix || pathname.startsWith(`${variant.prefix}/`))) ?? variants.find((variant) => !variant.prefix);
71
+ return /* @__PURE__ */ jsx(CurrentPathProvider, {
72
+ path: pathname,
73
+ children: shown?.children
74
+ });
75
+ }
50
76
  //#endregion
51
- export { CurrentMenuLinks, NextImage, NextLink };
77
+ export { ByLanguage, CurrentMenuLinks, NextImage, NextLink, PageLanguage };
package/dist/config.d.ts CHANGED
@@ -23,6 +23,8 @@ export interface GoodfellowNextOptions {
23
23
  * - Serves images as they are unless the site sets an image loader, since static files can't be optimized on request.
24
24
  * - Adds the site's redirects (Site Settings → Redirects) ahead of the site's own, for hosts that
25
25
  * send them, such as Vercel. Others get the pages `goodfellow-next finish` writes at old addresses.
26
+ * - For a site with an admin address, adds header rules that sandbox every page there but the
27
+ * admin panel's own, for hosts that send headers, such as Vercel.
26
28
  * - With `{ server: true }`, builds a full-stack site for a server instead of as static
27
29
  * files: see `goodfellowApi()` in `@goodfellow-cms/next/server`.
28
30
  * - In `next dev`, runs the admin panel's local backend, so publishing saves to