@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/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # @goodfellow-cms/next
2
2
 
3
- Goodfellow pages and the admin panel in a Next.js App Router site, exported as static files. Links use `next/link` and images `next/image`.
3
+ Goodfellow pages and the admin panel in a Next.js App Router site, exported as static files, or with a server of its own that publishes for editors who sign in with the site's own sign-in (`@goodfellow-cms/next/server`). Links use `next/link` and images `next/image`.
4
4
 
5
5
  Start from the Next.js starter: `npm create goodfellow@latest my-site -- --template next`.
6
6
 
7
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.
8
8
 
9
- More in the docs: [Next.js](https://goodfellow-cms.github.io/goodfellow-cms/docs/nextjs).
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
 
11
11
  ## License
12
12
 
@@ -1,48 +1,281 @@
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, 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 due [--since <time>]` says whether a scheduled change has come due, as `goodfellow due` does.
7
+ // `goodfellow-next nightly [--mode <mode>]` says whether tonight's build is needed, as `goodfellow nightly` does.
8
+ // `goodfellow-next content link|copy [folder]` puts a content repository's files in place, for a site whose
9
+ // content is kept in a repository of its own.
10
+ // `goodfellow-next import wordpress <file or address> [--dry-run]` imports a WordPress site, as `goodfellow import` does.
11
+ // `goodfellow-next export wordpress [file]` writes the site as a WordPress export file, as `goodfellow export` does.
12
+ // `goodfellow-next admin add <address>|check|remove` sets the admin address, and `goodfellow-next broker use <address>|remove`
13
+ // the token broker, in goodfellow.config.tsx, as `goodfellow admin` and `goodfellow broker` do.
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { rm } from "node:fs/promises";
16
+ import { relative } from "node:path";
5
17
  import { parseArgs } from "node:util";
6
- import { formatUpdateResult } from "@goodfellow-cms/core";
7
- import { update } from "@goodfellow-cms/core/node";
8
- import { writeSearchIndex } from "@goodfellow-cms/core/search-index";
9
- import { fixSegmentFiles, writeCalendarFiles, writeRedirectFiles } from "@goodfellow-cms/next/export";
18
+ import { buildNotes, CLOUDFLARE_REDIRECTS_LIMIT, configAdmin, formatUpdateResult } from "@goodfellow-cms/core";
19
+ import { reportSiteProblems } from "@goodfellow-cms/core/accessibility";
20
+ import {
21
+ AdminSetupError,
22
+ BrokerSetupError,
23
+ ContentRepositoryError,
24
+ checkSiteAdminAddress,
25
+ contentRepositoryProblem,
26
+ exportWordPress,
27
+ formatAdminResult,
28
+ formatWordPressExport,
29
+ formatWordPressImport,
30
+ importWordPress,
31
+ nightlyCheck,
32
+ reportDue,
33
+ reportNightly,
34
+ scheduleDue,
35
+ setAdminAddress,
36
+ setBrokerAddress,
37
+ setUpContentRepository,
38
+ update,
39
+ } from "@goodfellow-cms/core/node";
40
+ import { writePagesSearchIndex, writeSearchIndex } from "@goodfellow-cms/core/search-index";
41
+ import { WordPressReadError } from "@goodfellow-cms/core/wordpress";
42
+ import {
43
+ buildFileFolders,
44
+ builtPages,
45
+ checkPages,
46
+ exportedPages,
47
+ fixSegmentFiles,
48
+ guardExport,
49
+ NEXT_SITE_FILES,
50
+ writeBuildRecordFile,
51
+ writeCalendarFiles,
52
+ writeEditorPolicy,
53
+ writeFeedFiles,
54
+ writeFontFiles,
55
+ writePageLanguages,
56
+ writeRedirectFiles,
57
+ } from "@goodfellow-cms/next/export";
10
58
 
11
59
  const [command, dir = "out"] = process.argv.slice(2);
60
+ const CONFIG_FILES = ["goodfellow.config.tsx", "goodfellow.config.ts", "goodfellow.config.jsx", "goodfellow.config.js"];
61
+ // A full-stack site (withGoodfellow(config, { server: true })): Next.js serves its pages and sends redirects
62
+ // itself, so the calendar files, feeds and the search index are written into public/, which `next start` and hosts
63
+ // serve as static files, as they are for an export.
64
+ if (command === "finish" && dir === "--server") {
65
+ // Next.js renders a full-stack site's pages as it serves them, so nothing can put a guard first in each.
66
+ const config = CONFIG_FILES.find((file) => existsSync(file));
67
+ if (config && configAdmin(readFileSync(config, "utf8"))) {
68
+ console.error(
69
+ "A full-stack site can't have an admin address: its admin panel signs in with the site's own sign-in.",
70
+ );
71
+ process.exit(1);
72
+ }
73
+ for (const folder of await buildFileFolders()) await rm(`public/${folder}`, { recursive: true, force: true });
74
+ // The editor's frames' page, which next start serves as it was rendered.
75
+ await writeEditorPolicy(".next/server/app");
76
+ const calendars = await writeCalendarFiles("public");
77
+ if (calendars > 0) console.log(`Wrote ${calendars} calendar files, in public/calendars`);
78
+ const feeds = await writeFeedFiles("public");
79
+ if (feeds > 0) console.log(`Wrote ${feeds} feeds, in public/feeds`);
80
+ // Before the pages are read, so the search index and the checks see each page's language.
81
+ const languages = await writePageLanguages(undefined);
82
+ if (languages > 0) console.log(`Marked ${languages} pages with their language`);
83
+ const pages = await builtPages();
84
+ const indexed = await writePagesSearchIndex(pages, "public/pagefind");
85
+ console.log(
86
+ indexed === undefined
87
+ ? "No page has a search block, so there's nothing to index"
88
+ : `Indexed ${indexed} pages for search, in public/pagefind`,
89
+ );
90
+ for (const line of reportSiteProblems(await checkPages(pages))) console.log(line);
91
+ process.exit(0);
92
+ }
12
93
  if (command === "update") {
13
94
  const { values } = parseArgs({
14
95
  args: process.argv.slice(3),
15
- options: { to: { type: "string" }, publish: { type: "boolean" } },
96
+ options: { to: { type: "string" }, publish: { type: "boolean" }, commit: { type: "boolean" } },
16
97
  });
17
- const result = await update({ to: values.to, publish: values.publish });
98
+ // The starter's admin pages, kept up to date where nobody has changed them, on sites with an admin panel.
99
+ const siteFiles = existsSync("app/admin/page.tsx") ? NEXT_SITE_FILES : [];
100
+ const result = await update({ to: values.to, publish: values.publish, commit: values.commit, siteFiles });
18
101
  // For the admin panel, which reads it in the host's log.
19
102
  console.log(formatUpdateResult(result));
20
103
  process.exit(result.state === "failed" ? 1 : 0);
21
104
  }
105
+ if (command === "due") {
106
+ const { values } = parseArgs({ args: process.argv.slice(3), options: { since: { type: "string" } } });
107
+ for (const line of await reportDue(await scheduleDue({ since: values.since }))) console.log(line);
108
+ process.exit(0);
109
+ }
110
+ if (command === "nightly") {
111
+ const { values } = parseArgs({
112
+ args: process.argv.slice(3),
113
+ options: { mode: { type: "string" }, output: { type: "string" } },
114
+ });
115
+ const siteFiles = existsSync("app/admin/page.tsx") ? NEXT_SITE_FILES : [];
116
+ const result = await nightlyCheck({ siteFiles, ...(values.mode && { mode: values.mode }) });
117
+ for (const line of await reportNightly(result, values.output ?? process.env.GITHUB_OUTPUT)) console.log(line);
118
+ process.exit(0);
119
+ }
120
+ if (command === "content") {
121
+ const [action, from] = process.argv.slice(3);
122
+ if (action === "link" || action === "copy") {
123
+ try {
124
+ const folder =
125
+ relative(process.cwd(), await setUpContentRepository(".", { from, copy: action === "copy" })) || ".";
126
+ console.log(
127
+ action === "copy"
128
+ ? `Copied content/ and public/media/ from ${folder}`
129
+ : `Linked content/ and public/media/ to ${folder}, so the site's content is the content repository's`,
130
+ );
131
+ process.exit(0);
132
+ } catch (error) {
133
+ if (!(error instanceof ContentRepositoryError)) throw error;
134
+ console.error(error.message);
135
+ process.exit(1);
136
+ }
137
+ }
138
+ }
139
+ if (command === "admin" || command === "broker") {
140
+ const [action, argument] = process.argv.slice(3);
141
+ try {
142
+ if (command === "admin" && ((action === "add" && argument) || action === "check" || action === "remove")) {
143
+ const result =
144
+ action === "check"
145
+ ? await checkSiteAdminAddress(".")
146
+ : await setAdminAddress(".", action === "add" ? argument : undefined);
147
+ const { lines, ok } = formatAdminResult(action, result, "goodfellow-next");
148
+ for (const line of lines) (ok ? console.log : console.error)(line);
149
+ process.exit(ok ? 0 : 1);
150
+ }
151
+ if (command === "broker" && ((action === "use" && argument) || action === "remove")) {
152
+ const address = await setBrokerAddress(".", action === "use" ? argument : undefined);
153
+ console.log(
154
+ address
155
+ ? `Editors sign in at ${address} once the site has rebuilt with goodfellow.config.tsx.`
156
+ : "Editors sign in in the admin panel itself once the site has rebuilt with goodfellow.config.tsx.",
157
+ );
158
+ process.exit(0);
159
+ }
160
+ } catch (error) {
161
+ if (!(error instanceof AdminSetupError || error instanceof BrokerSetupError)) throw error;
162
+ console.error(error.message);
163
+ process.exit(1);
164
+ }
165
+ }
166
+ if (command === "import") {
167
+ const { positionals, values } = parseArgs({
168
+ args: process.argv.slice(3),
169
+ allowPositionals: true,
170
+ options: { "dry-run": { type: "boolean" } },
171
+ });
172
+ const [kind, from] = positionals;
173
+ const problem = contentRepositoryProblem(".");
174
+ if (kind === "wordpress" && from && !problem) {
175
+ try {
176
+ const result = await importWordPress(".", from, { dryRun: values["dry-run"], log: (line) => console.log(line) });
177
+ console.log(formatWordPressImport(result));
178
+ process.exit(0);
179
+ } catch (error) {
180
+ if (!(error instanceof WordPressReadError)) throw error;
181
+ console.error(error.message);
182
+ process.exit(1);
183
+ }
184
+ }
185
+ if (problem) {
186
+ console.error(problem);
187
+ process.exit(1);
188
+ }
189
+ }
190
+ if (command === "export") {
191
+ const [kind, file] = process.argv.slice(3);
192
+ const problem = contentRepositoryProblem(".");
193
+ if (problem) {
194
+ console.error(problem);
195
+ process.exit(1);
196
+ }
197
+ if (kind === "wordpress") {
198
+ console.log(formatWordPressExport(await exportWordPress(".", file)));
199
+ process.exit(0);
200
+ }
201
+ }
22
202
  if (command !== "finish" && command !== "index") {
23
203
  console.error(
24
204
  [
25
205
  "Usage: goodfellow-next finish [folder]",
206
+ " goodfellow-next finish --server",
26
207
  " goodfellow-next index [folder]",
27
- " goodfellow-next update [--to fixes|automatic|latest|<release>] [--publish]",
208
+ " goodfellow-next update [--to fixes|automatic|latest|<release>] [--publish | --commit]",
209
+ " goodfellow-next due [--since <time>]",
210
+ " goodfellow-next nightly [--mode changed|always|off] [--output <file>]",
211
+ " goodfellow-next content link|copy [folder]",
212
+ " goodfellow-next import wordpress <file or address> [--dry-run]",
213
+ " goodfellow-next export wordpress [file]",
214
+ " goodfellow-next admin add <address> | check | remove",
215
+ " goodfellow-next broker use <address> | remove",
28
216
  "",
29
217
  "finish Gets the exported site in folder (default: out) ready to publish: puts the files",
30
218
  " Next.js uses to load pages ahead where browsers look for them, writes calendar files",
31
- " for collections of events and pages that send visitors on from old addresses, and",
32
- " indexes it for search.",
219
+ " for collections of events, collections' feeds and pages that send visitors on from",
220
+ " old addresses, indexes it for search, and lists what automated accessibility checks",
221
+ " find on its pages. With --server, for a full-stack site, writes the calendar files",
222
+ " and feeds into public/calendars and public/feeds and indexes and checks the pages",
223
+ " next build rendered, with the index in public/pagefind, instead.",
33
224
  "index Only indexes the exported site for its search block.",
34
225
  "update Updates Goodfellow, and blocks from its registry, if the site still builds.",
226
+ " --publish pushes it to the main branch; --commit only commits it, for a job",
227
+ " that runs none of the site's code to publish.",
228
+ "due Says whether a scheduled change has come due since --since (default: the last",
229
+ " hour), so the site needs building again, and sets the step's due output on",
230
+ " GitHub Actions.",
231
+ "nightly Says whether tonight's build is needed: when the site has changed since the live",
232
+ " site was built, what it shows by today's date has, or an update is available",
233
+ " (--mode, or GOODFELLOW_NIGHTLY: changed, the default, always or off), and sets the",
234
+ " step's build and update outputs on GitHub Actions, or adds them to --output.",
235
+ "content For a site whose content is kept in a repository of its own: links content/ and",
236
+ " public/media/ to the content repository in folder (default: content-repository),",
237
+ " or copies them from it, as builds do.",
238
+ "import Brings a WordPress site's pages, posts, categories, tags, menus and media into",
239
+ " this site, from its export file (Tools → Export) or its address. With --dry-run,",
240
+ " says what it would bring across and changes nothing.",
241
+ "export Writes this site as a WordPress export file (default: named after the site),",
242
+ " which WordPress imports with Tools → Import → WordPress.",
243
+ "admin The admin panel's address of its own, such as admin.example.org, in",
244
+ " goodfellow.config.tsx: add names it; check checks it once the site has rebuilt with",
245
+ " it, then starts the admin panel only there; remove stops using it.",
246
+ "broker The site's token broker in goodfellow.config.tsx: use signs editors in there once",
247
+ " it's online; remove stops using it.",
35
248
  ].join("\n"),
36
249
  );
37
250
  process.exit(1);
38
251
  }
252
+ /** What checking the exported pages found, reported once the export is ready. */
253
+ let problems = [];
39
254
  if (command === "finish") {
40
255
  const moved = await fixSegmentFiles(dir);
256
+ await writeEditorPolicy(dir);
41
257
  if (moved > 0) console.log(`Moved ${moved} of Next.js's prefetch files to where browsers look for them`);
42
258
  const calendars = await writeCalendarFiles(dir);
43
259
  if (calendars > 0) console.log(`Wrote ${calendars} calendar files`);
260
+ const feeds = await writeFeedFiles(dir);
261
+ if (feeds > 0) console.log(`Wrote ${feeds} feeds`);
262
+ const languages = await writePageLanguages(dir);
263
+ if (languages > 0) console.log(`Marked ${languages} pages with their language`);
264
+ // Before the redirects' pages, which aren't the site's.
265
+ problems = await checkPages(await exportedPages(dir));
44
266
  const redirects = await writeRedirectFiles(dir);
45
- if (redirects > 0) console.log(`Wrote ${redirects} redirects`);
267
+ if (redirects.pages > 0) console.log(`Wrote ${redirects.pages} redirects`);
268
+ if (redirects.cloudflareLeft > 0) {
269
+ console.log(
270
+ `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.`,
271
+ );
272
+ }
273
+ for (const line of buildNotes(await writeFontFiles(dir))) console.log(line);
274
+ // What the site was built from, and when it stops being current, for the nightly check.
275
+ const currentUntil = await writeBuildRecordFile(dir);
276
+ if (currentUntil) {
277
+ console.log(`What the site shows by today's date changes at ${currentUntil}, when it needs building again`);
278
+ }
46
279
  }
47
280
  const indexed = await writeSearchIndex(dir);
48
281
  console.log(
@@ -50,3 +283,8 @@ console.log(
50
283
  ? `No page in ${dir} has a search block, so there's nothing to index`
51
284
  : `Indexed ${indexed} pages for search`,
52
285
  );
286
+ if (command === "finish") {
287
+ const guarded = await guardExport(dir);
288
+ if (guarded !== undefined) console.log(`Started ${guarded} pages with the admin address's guard`);
289
+ }
290
+ for (const line of reportSiteProblems(problems)) console.log(line);
package/dist/admin.d.ts CHANGED
@@ -1,21 +1,38 @@
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. */
12
22
  strings?: Partial<Strings>;
23
+ /**
24
+ * On a full-stack site, the address of its `goodfellowApi()` route, such as
25
+ * `/api/goodfellow`, without the base path. Editors then sign in with the
26
+ * site's own sign-in, and its server publishes for them. `next dev` still
27
+ * saves to the files on disk.
28
+ */
29
+ server?: string;
13
30
  }
14
31
  /**
15
- * The admin panel, for a Next.js site's `app/admin/page.tsx`, which must be a
16
- * Client Component (`"use client"`) that imports the site's config.
17
- * In `next dev` it saves to the files on disk; on the live site, editors sign
18
- * in to the config's git backend.
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.
19
36
  */
20
- export declare function GoodfellowAdmin({ config, siteUrl, strings }: GoodfellowAdminProps): import("react").JSX.Element | null;
37
+ export declare function GoodfellowAdmin({ site, config, strings, ...props }: GoodfellowAdminProps): import("react").JSX.Element;
21
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,92 @@ 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/github": () => import("@goodfellow-cms/github"),
62
+ "@goodfellow-cms/gitlab": () => import("@goodfellow-cms/gitlab")
63
+ };
64
+ async function hostFrom(source) {
65
+ const load = Object.hasOwn(HOSTS, source.package) ? HOSTS[source.package] : void 0;
66
+ const make = load && (await load())[source.export];
67
+ if (typeof make !== "function") throw new Error(`The admin panel can't sign in to ${source.package} yet.`);
68
+ return make(source.options);
69
+ }
70
+ async function setUp(siteUrl, backend) {
71
+ const [stylesheets, tailwindBrowser, store, host] = await Promise.all([
29
72
  siteStylesheets(siteUrl),
30
73
  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
74
+ process.env.NODE_ENV === "development" ? import("@goodfellow-cms/admin/dev").then((module) => module.localStore(`${BASE_PATH}/__goodfellow/api`)) : void 0,
75
+ backend ? hostFrom(backend) : void 0
32
76
  ]);
33
77
  return {
34
78
  stylesheets,
35
79
  tailwindBrowserUrl: URL.createObjectURL(new Blob([tailwindBrowser], { type: "text/javascript" })),
36
- ...store && { store }
80
+ ...store && { store },
81
+ ...host && { host }
37
82
  };
38
83
  }
39
84
  /**
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.
85
+ * The admin panel, for a Next.js site's `app/admin/page.tsx`, given
86
+ * `site.admin()` there. In `next dev` it saves to the files on disk; on the
87
+ * live site, editors sign in to the config's git backend, or on a full-stack
88
+ * site (`server`), with the site's own sign-in.
44
89
  */
45
- function GoodfellowAdmin({ config, siteUrl = `${BASE_PATH}/`, strings }) {
90
+ function GoodfellowAdmin({ site, config, strings, ...props }) {
91
+ if (site) return /* @__PURE__ */ jsx(SiteAdmin, {
92
+ site,
93
+ ...strings && { strings },
94
+ ...props
95
+ });
96
+ if (config) return /* @__PURE__ */ jsx(OutdatedAdminPage, {
97
+ files: [{
98
+ path: "app/admin/page.tsx",
99
+ content: ADMIN_PAGE
100
+ }, {
101
+ path: "app/admin/editor/page.tsx",
102
+ content: EDITOR_PAGE
103
+ }],
104
+ ...strings && { strings }
105
+ });
106
+ throw new Error("GoodfellowAdmin needs `site`, from `site.admin()` in app/admin/page.tsx.");
107
+ }
108
+ function SiteAdmin({ site, siteUrl = `${BASE_PATH}/`, strings, server }) {
46
109
  const [setup, setSetup] = useState(null);
110
+ useEditorRebuilds();
47
111
  useEffect(() => {
48
112
  let current = true;
49
113
  let url;
50
- setUp(siteUrl).then((result) => {
114
+ setUp(siteUrl, site.backend).then((result) => {
51
115
  url = result.tailwindBrowserUrl;
52
116
  if (current) setSetup(result);
53
117
  else URL.revokeObjectURL(url);
@@ -56,14 +120,21 @@ function GoodfellowAdmin({ config, siteUrl = `${BASE_PATH}/`, strings }) {
56
120
  current = false;
57
121
  if (url) URL.revokeObjectURL(url);
58
122
  };
59
- }, [siteUrl]);
60
- if (!setup) return null;
61
- return /* @__PURE__ */ jsx(Admin, {
62
- config,
123
+ }, [siteUrl, site]);
124
+ const policy = /* @__PURE__ */ jsx("meta", {
125
+ httpEquiv: "Content-Security-Policy",
126
+ content: ADMIN_POLICY
127
+ });
128
+ if (!setup) return policy;
129
+ return /* @__PURE__ */ jsxs(Fragment, { children: [policy, /* @__PURE__ */ jsx(Admin, {
130
+ site,
131
+ frames: { url: EDITOR_URL },
132
+ ...setup.host && { host: setup.host },
63
133
  ...setup.store && {
64
134
  store: setup.store,
65
135
  registry: `${BASE_PATH}/__goodfellow/api/registry/{name}.json`
66
136
  },
137
+ ...server && { server: `${BASE_PATH}${server}` },
67
138
  preview: {
68
139
  stylesheets: setup.stylesheets,
69
140
  themeCss: theme_css_default,
@@ -71,7 +142,7 @@ function GoodfellowAdmin({ config, siteUrl = `${BASE_PATH}/`, strings }) {
71
142
  },
72
143
  siteUrl,
73
144
  ...strings && { strings }
74
- });
145
+ })] });
75
146
  }
76
147
  //#endregion
77
148
  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
@@ -3,6 +3,13 @@ import { NextConfig } from "next";
3
3
  export interface GoodfellowNextOptions {
4
4
  /** The site's folder, holding `content/` and `public/media/`. Defaults to the folder Next.js runs in. */
5
5
  root?: string;
6
+ /**
7
+ * For a full-stack site, with a server of its own: builds it for `next start`
8
+ * or a host that runs Next.js, rather than as static files, and includes
9
+ * `content/` and `public/media/` with its server, which reads them for pages
10
+ * built on request and calendar files.
11
+ */
12
+ server?: boolean;
6
13
  }
7
14
  /**
8
15
  * Sets up Next.js for a Goodfellow site, in `next.config.ts`:
@@ -16,6 +23,10 @@ export interface GoodfellowNextOptions {
16
23
  * - Serves images as they are unless the site sets an image loader, since static files can't be optimized on request.
17
24
  * - Adds the site's redirects (Site Settings → Redirects) ahead of the site's own, for hosts that
18
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.
28
+ * - With `{ server: true }`, builds a full-stack site for a server instead of as static
29
+ * files: see `goodfellowApi()` in `@goodfellow-cms/next/server`.
19
30
  * - In `next dev`, runs the admin panel's local backend, so publishing saves to
20
31
  * the files on disk. It's never part of a build.
21
32
  * - In `next dev`, turns off React's debug channel (`experimental.reactDebugChannel`)