@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/config.js CHANGED
@@ -1,10 +1,35 @@
1
- import { CALENDARS_DIR, allPages, loadSiteContent, normalizeBase, redirectFrom, redirectProblems } from "@goodfellow-cms/core";
2
- import { DEV_API_PREFIX, fileSystemSource, handleDevApi, isLocalOrigin, localFileStore, localRegistryDir } from "@goodfellow-cms/core/node";
1
+ import { CALENDARS_DIR, FEEDS_DIR, allPages, brokerOrigin, configAdmin, configBroker, loadSiteContent, normalizeBase, redirectFrom, redirectProblems, shownContent, vercelAdminHeaders } from "@goodfellow-cms/core";
2
+ import { BUILD_DATES_DIR, BUILD_DATES_ENV, DEV_API_PREFIX, contentRepositoryProblem, fileSystemSource, handleDevApi, isLocalOrigin, localFileStore, localRegistryDir } from "@goodfellow-cms/core/node";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { rm } from "node:fs/promises";
3
5
  import { createServer } from "node:http";
4
- import { resolve } from "node:path";
6
+ import { join, resolve } from "node:path";
5
7
  //#region src/config.ts
8
+ const CONFIG_FILES = [
9
+ "goodfellow.config.tsx",
10
+ "goodfellow.config.ts",
11
+ "goodfellow.config.jsx",
12
+ "goodfellow.config.js"
13
+ ];
14
+ /** The config's admin address, from its line of its own, since Next.js's config can't load the site's. */
15
+ function siteAdmin(root) {
16
+ const file = CONFIG_FILES.map((name) => join(root, name)).find((path) => existsSync(path));
17
+ return file ? configAdmin(readFileSync(file, "utf8")) : void 0;
18
+ }
19
+ /**
20
+ * The admin address's header rules, for hosts that read them from the build,
21
+ * such as Vercel: every page there sandboxed but the admin panel's own, as
22
+ * `goodfellow build`'s `_headers` does on Cloudflare Pages. Next.js adds the base path itself.
23
+ */
24
+ function adminHeaders(root) {
25
+ const admin = siteAdmin(root);
26
+ const file = CONFIG_FILES.map((name) => join(root, name)).find((path) => existsSync(path));
27
+ const broker = brokerOrigin(file ? configBroker(readFileSync(file, "utf8")) : void 0);
28
+ return admin ? vercelAdminHeaders(admin, "/", broker) : [];
29
+ }
6
30
  /** `PHASE_DEVELOPMENT_SERVER` from `next/constants`, which Node can't import by that name. */
7
31
  const PHASE_DEVELOPMENT_SERVER = "phase-development-server";
32
+ const PHASE_PRODUCTION_BUILD = "phase-production-build";
8
33
  const SERVER_KEY = Symbol.for("goodfellow.next.devApi");
9
34
  /**
10
35
  * The admin panel's local backend, which reads and writes the site's files on
@@ -16,7 +41,12 @@ function startDevApi(root) {
16
41
  globals[SERVER_KEY] ??= new Promise((resolvePort, reject) => {
17
42
  const store = localFileStore(root);
18
43
  const registryDir = localRegistryDir(root);
44
+ const editor = import("./dev-editor-DiPKrIAS.js").then(({ startDevEditor }) => startDevEditor(root)).catch((error) => {
45
+ console.error(`The editor's frames can't be served: ${error.message}`);
46
+ return { serve: async () => false };
47
+ });
19
48
  const server = createServer(async (req, res) => {
49
+ if (await (await editor).serve(req, res)) return;
20
50
  if (!await handleDevApi(store, req, res, {
21
51
  trustOrigin: isLocalOrigin,
22
52
  registryDir
@@ -65,7 +95,7 @@ function redirectSource(path) {
65
95
  * Content with a problem gives none, and the build reports the problem.
66
96
  */
67
97
  async function siteRedirects(root) {
68
- const content = await loadSiteContent(fileSystemSource(root)).catch(() => void 0);
98
+ const content = await loadSiteContent(fileSystemSource(root)).then(shownContent, () => void 0);
69
99
  if (!content?.redirects) return [];
70
100
  const taken = new Set(allPages(content).map((page) => page.path));
71
101
  const problems = redirectProblems(content.redirects);
@@ -91,6 +121,10 @@ async function siteRedirects(root) {
91
121
  * - Serves images as they are unless the site sets an image loader, since static files can't be optimized on request.
92
122
  * - Adds the site's redirects (Site Settings → Redirects) ahead of the site's own, for hosts that
93
123
  * send them, such as Vercel. Others get the pages `goodfellow-next finish` writes at old addresses.
124
+ * - For a site with an admin address, adds header rules that sandbox every page there but the
125
+ * admin panel's own, for hosts that send headers, such as Vercel.
126
+ * - With `{ server: true }`, builds a full-stack site for a server instead of as static
127
+ * files: see `goodfellowApi()` in `@goodfellow-cms/next/server`.
94
128
  * - In `next dev`, runs the admin panel's local backend, so publishing saves to
95
129
  * the files on disk. It's never part of a build.
96
130
  * - In `next dev`, turns off React's debug channel (`experimental.reactDebugChannel`)
@@ -101,6 +135,8 @@ function withGoodfellow(nextConfig = {}, options = {}) {
101
135
  const root = resolve(options.root ?? process.cwd());
102
136
  const basePath = nextBasePath(nextConfig.basePath ?? process.env.GOODFELLOW_BASE);
103
137
  return async (phase) => {
138
+ const problem = contentRepositoryProblem(root);
139
+ if (problem) throw new Error(problem);
104
140
  const config = {
105
141
  trailingSlash: true,
106
142
  ...nextConfig,
@@ -111,27 +147,65 @@ function withGoodfellow(nextConfig = {}, options = {}) {
111
147
  GOODFELLOW_BASE_PATH: basePath
112
148
  }
113
149
  };
150
+ if (phase === PHASE_PRODUCTION_BUILD) {
151
+ const dates = join(root, nextConfig.distDir ?? ".next", BUILD_DATES_DIR);
152
+ await rm(dates, {
153
+ recursive: true,
154
+ force: true
155
+ });
156
+ process.env[BUILD_DATES_ENV] = dates;
157
+ }
158
+ if (phase !== PHASE_DEVELOPMENT_SERVER && options.server) {
159
+ if (siteAdmin(root)) throw new Error("A full-stack site can't have an admin address: its admin panel signs in with the site's own sign-in.");
160
+ return {
161
+ ...config,
162
+ images: nextConfig.images,
163
+ outputFileTracingIncludes: {
164
+ ...nextConfig.outputFileTracingIncludes,
165
+ "/**": [
166
+ ...nextConfig.outputFileTracingIncludes?.["/**"] ?? [],
167
+ "./content/**/*",
168
+ "./public/media/**/*"
169
+ ]
170
+ },
171
+ redirects: async () => [...await siteRedirects(root), ...await nextConfig.redirects?.() ?? []]
172
+ };
173
+ }
114
174
  if (phase !== PHASE_DEVELOPMENT_SERVER) return {
115
175
  output: "export",
116
176
  ...config,
117
- redirects: async () => [...await siteRedirects(root), ...await nextConfig.redirects?.() ?? []]
177
+ redirects: async () => [...await siteRedirects(root), ...await nextConfig.redirects?.() ?? []],
178
+ ...siteAdmin(root) && { headers: async () => [...adminHeaders(root), ...await nextConfig.headers?.() ?? []] }
118
179
  };
119
180
  const port = await startDevApi(root);
120
181
  const rewrite = {
121
182
  source: `${DEV_API_PREFIX}/:path*`,
122
183
  destination: `http://127.0.0.1:${port}${DEV_API_PREFIX}/:path*`
123
184
  };
124
- const calendars = {
125
- source: `/${CALENDARS_DIR}/:file(.+\\.ics)`,
126
- destination: `http://127.0.0.1:${port}${DEV_API_PREFIX}/${CALENDARS_DIR}/:file`
127
- };
185
+ const buildFiles = [{
186
+ dir: CALENDARS_DIR,
187
+ file: ":file(.+\\.ics)"
188
+ }, {
189
+ dir: FEEDS_DIR,
190
+ file: ":file(.+\\.rss|.+\\.atom)"
191
+ }].flatMap(({ dir, file }) => [{
192
+ source: `/${dir}/${file}`,
193
+ destination: `http://127.0.0.1:${port}${DEV_API_PREFIX}/${dir}/:file`
194
+ }, {
195
+ source: `/:language([a-z0-9-]+)/${dir}/${file}`,
196
+ destination: `http://127.0.0.1:${port}${DEV_API_PREFIX}/:language/${dir}/:file`
197
+ }]);
128
198
  return {
129
199
  ...config,
130
200
  experimental: {
131
201
  reactDebugChannel: false,
132
202
  ...nextConfig.experimental
133
203
  },
134
- rewrites: async () => withRewrite(withRewrite(await nextConfig.rewrites?.(), calendars), rewrite)
204
+ rewrites: async () => {
205
+ let rewrites = await nextConfig.rewrites?.();
206
+ for (const next of [...buildFiles, rewrite]) rewrites = withRewrite(rewrites, next);
207
+ return rewrites ?? [];
208
+ }
135
209
  };
136
210
  };
137
211
  }
@@ -0,0 +1,124 @@
1
+ import { DEV_EDITOR_PREFIX, EDITOR_PRELUDE, editorPage } from "@goodfellow-cms/core";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { join, resolve, sep } from "node:path";
5
+ //#region src/dev-editor.ts
6
+ const CONFIG_FILES = [
7
+ "goodfellow.config.tsx",
8
+ "goodfellow.config.ts",
9
+ "goodfellow.config.jsx",
10
+ "goodfellow.config.js"
11
+ ];
12
+ const ENTRY = "\0goodfellow:next-editor";
13
+ const TAILWIND_BROWSER = "tailwind-browser.js";
14
+ /** How long the editor's frames stay as built before the admin panel reloads them. */
15
+ const REBUILT_DELAY = 300;
16
+ const TYPES = {
17
+ html: "text/html; charset=utf-8",
18
+ js: "text/javascript; charset=utf-8",
19
+ css: "text/css; charset=utf-8",
20
+ map: "application/json",
21
+ json: "application/json"
22
+ };
23
+ async function startDevEditor(root) {
24
+ const configFile = CONFIG_FILES.map((name) => join(root, name)).find((file) => existsSync(file));
25
+ if (!configFile) throw new Error(`No goodfellow.config.tsx in ${root}.`);
26
+ const outDir = join(root, "node_modules/.goodfellow/next-editor");
27
+ await mkdir(outDir, { recursive: true });
28
+ const [{ watch }, { default: tailwindBrowser }] = await Promise.all([import("rolldown"), import("./tailwind-browser-Ds-ZH7sO.js")]);
29
+ await writeFile(join(outDir, TAILWIND_BROWSER), tailwindBrowser);
30
+ const stylesheets = /* @__PURE__ */ new Set();
31
+ let version = 0;
32
+ let built = () => {};
33
+ const ready = new Promise((resolveReady) => {
34
+ built = resolveReady;
35
+ });
36
+ let counting;
37
+ const writePage = async () => {
38
+ const css = await Promise.all([...stylesheets].map((file) => readFile(file, "utf8")));
39
+ await writeFile(join(outDir, "editor.css"), css.join("\n"));
40
+ await writeFile(join(outDir, "index.html"), editorPage({
41
+ scripts: ["editor.js"],
42
+ stylesheets: ["editor.css"]
43
+ }));
44
+ counting = setTimeout(() => {
45
+ version += 1;
46
+ writeFile(join(outDir, "version.json"), JSON.stringify({ version }));
47
+ }, version === 0 ? 0 : REBUILT_DELAY);
48
+ };
49
+ watch({
50
+ input: { editor: ENTRY },
51
+ cwd: root,
52
+ platform: "browser",
53
+ tsconfig: existsSync(join(root, "tsconfig.json")) ? join(root, "tsconfig.json") : false,
54
+ transform: {
55
+ jsx: { runtime: "automatic" },
56
+ define: { "process.env.NODE_ENV": JSON.stringify("development") }
57
+ },
58
+ plugins: [{
59
+ name: "goodfellow:next-editor",
60
+ resolveId: (id) => id === ENTRY ? id : void 0,
61
+ load(id) {
62
+ if (id === ENTRY) return [
63
+ `import config from ${JSON.stringify(configFile)};`,
64
+ `import { mountEditorFrame } from "@goodfellow-cms/admin/editor";`,
65
+ `import "@puckeditor/core/puck.css";`,
66
+ `import "@goodfellow-cms/admin/styles.css";`,
67
+ `mountEditorFrame(config, undefined, { tailwindBrowserUrl: ${JSON.stringify(TAILWIND_BROWSER)} });`
68
+ ].join("\n");
69
+ if (id.endsWith(".css")) {
70
+ stylesheets.add(id);
71
+ return {
72
+ code: "",
73
+ moduleType: "js"
74
+ };
75
+ }
76
+ }
77
+ }],
78
+ onLog(level, log, handler) {
79
+ if (log.code === "MODULE_LEVEL_DIRECTIVE") return;
80
+ handler(level, log);
81
+ },
82
+ output: {
83
+ dir: outDir,
84
+ format: "iife",
85
+ codeSplitting: false,
86
+ entryFileNames: "editor.js",
87
+ banner: EDITOR_PRELUDE,
88
+ sourcemap: true
89
+ }
90
+ }).on("event", (event) => {
91
+ if (event.code === "BUNDLE_START") clearTimeout(counting);
92
+ else if (event.code === "BUNDLE_END") {
93
+ event.result.close();
94
+ writePage().then(built, (error) => {
95
+ console.error(`The editor's frames couldn't be written: ${error.message}`);
96
+ built();
97
+ });
98
+ } else if (event.code === "ERROR") {
99
+ event.result?.close();
100
+ console.error(`The editor's frames couldn't be built: ${event.error.message}`);
101
+ built();
102
+ }
103
+ });
104
+ return { async serve(req, res) {
105
+ const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
106
+ const page = pathname === DEV_EDITOR_PREFIX.slice(0, -1);
107
+ if (!(page || pathname.startsWith(DEV_EDITOR_PREFIX)) || req.method !== "GET" && req.method !== "HEAD") return false;
108
+ await ready;
109
+ const name = page ? "index.html" : pathname.slice(DEV_EDITOR_PREFIX.length) || "index.html";
110
+ const file = resolve(outDir, name);
111
+ if (!file.startsWith(`${outDir}${sep}`)) return false;
112
+ try {
113
+ const data = await readFile(file);
114
+ res.setHeader("Content-Type", TYPES[file.slice(file.lastIndexOf(".") + 1)] ?? "application/octet-stream");
115
+ res.setHeader("Cache-Control", "no-store");
116
+ res.end(req.method === "HEAD" ? void 0 : data);
117
+ } catch {
118
+ res.writeHead(404).end();
119
+ }
120
+ return true;
121
+ } };
122
+ }
123
+ //#endregion
124
+ export { startDevEditor };
@@ -0,0 +1,14 @@
1
+ import { GoodfellowConfig } from "@goodfellow-cms/core";
2
+ import "@goodfellow-cms/admin/styles.css";
3
+ import "@puckeditor/core/puck.css";
4
+ //#region src/editor.d.ts
5
+ /**
6
+ * The editor's frames, for a Next.js site's `app/admin/editor/page.tsx`,
7
+ * which must be a Client Component (`"use client"`) that imports the site's
8
+ * config: Puck, the config and every block, sandboxed away from the admin
9
+ * panel's page, which signs in and publishes.
10
+ */
11
+ export declare function GoodfellowEditor({ config }: {
12
+ config: GoodfellowConfig;
13
+ }): import("react").JSX.Element;
14
+ //#endregion
package/dist/editor.js ADDED
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { useEffect, useRef } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+ import "@goodfellow-cms/admin/styles.css";
5
+ import "@puckeditor/core/puck.css";
6
+ //#region src/editor.tsx
7
+ /** Started once a page: React runs effects twice in development, and a second start would connect again. */
8
+ let started = false;
9
+ /**
10
+ * The editor's frames, for a Next.js site's `app/admin/editor/page.tsx`,
11
+ * which must be a Client Component (`"use client"`) that imports the site's
12
+ * config: Puck, the config and every block, sandboxed away from the admin
13
+ * panel's page, which signs in and publishes.
14
+ */
15
+ function GoodfellowEditor({ config }) {
16
+ const element = useRef(null);
17
+ useEffect(() => {
18
+ const container = element.current;
19
+ if (!container || started) return;
20
+ started = true;
21
+ Promise.all([import("@goodfellow-cms/admin/editor"), import("./tailwind-browser-Ds-ZH7sO.js").then((module) => module.default)]).then(([{ mountEditorFrame }, tailwindBrowser]) => {
22
+ const tailwindBrowserUrl = URL.createObjectURL(new Blob([tailwindBrowser], { type: "text/javascript" }));
23
+ mountEditorFrame(config, container, { tailwindBrowserUrl });
24
+ });
25
+ }, [config]);
26
+ return /* @__PURE__ */ jsx("div", {
27
+ ref: element,
28
+ id: "goodfellow-frame"
29
+ });
30
+ }
31
+ //#endregion
32
+ export { GoodfellowEditor };
package/dist/export.d.ts CHANGED
@@ -1,3 +1,13 @@
1
+ import { SelfHostedResult, SiteProblem } from "@goodfellow-cms/core";
2
+ import { SiteFile } from "@goodfellow-cms/core/node";
3
+ //#region src/site-files.d.ts
4
+ /**
5
+ * The Next.js starter's files that `goodfellow-next update` keeps up to date
6
+ * where nobody has changed them, as the starter now has them. A test checks
7
+ * they match `templates/next`.
8
+ */
9
+ export declare const NEXT_SITE_FILES: readonly SiteFile[];
10
+ //#endregion
1
11
  //#region src/export.d.ts
2
12
  /**
3
13
  * Writes the calendar files of the site's collections of events into the
@@ -5,12 +15,59 @@
5
15
  * how many it wrote.
6
16
  */
7
17
  export declare function writeCalendarFiles(outDir: string, root?: string): Promise<number>;
18
+ /**
19
+ * Writes the feeds of the site's collections into the export, as `goodfellow
20
+ * build` does. Returns how many it wrote: none without the site's address.
21
+ */
22
+ export declare function writeFeedFiles(outDir: string, root?: string): Promise<number>;
23
+ /**
24
+ * The folders the calendar files and feeds go in: `calendars/` and `feeds/`,
25
+ * and on a site in several languages, the same under each other language's
26
+ * code, such as `es/feeds/`.
27
+ */
28
+ export declare function buildFileFolders(root?: string): Promise<string[]>;
29
+ /** What `writeFontFiles()` did, for `buildNotes()`. */
30
+ export interface FontsWritten {
31
+ fonts?: SelfHostedResult;
32
+ robotsElsewhere?: string;
33
+ sitemap?: string;
34
+ }
35
+ /**
36
+ * Copies the site's Google Fonts into the export and points its pages at
37
+ * them, as `goodfellow build` does (`selfHostFonts()`), with the export's base
38
+ * path, and says where search engines read `robots.txt` for a site in a
39
+ * subfolder (`rootRobotsUrl()`).
40
+ */
41
+ export declare function writeFontFiles(outDir: string, root?: string): Promise<FontsWritten>;
42
+ /**
43
+ * Writes the build record (`goodfellow-build.json`) into the export, as
44
+ * `goodfellow build` does, from the dates the processes that rendered its
45
+ * pages wrote into `.next/` (see `withGoodfellow()`). Returns when the site
46
+ * stops being current, if it does.
47
+ */
48
+ export declare function writeBuildRecordFile(outDir: string, root?: string): Promise<string | undefined>;
49
+ /** What `writeRedirectFiles()` wrote. */
50
+ export interface RedirectsWritten {
51
+ /** How many pages at old addresses. */
52
+ pages: number;
53
+ /** How many redirects didn't fit in Cloudflare Pages' `_redirects`, and are left to their pages. */
54
+ cloudflareLeft: number;
55
+ }
8
56
  /**
9
57
  * Writes a page at each of the site's old addresses that sends visitors on, as
10
- * `goodfellow build` does. Pages win: an address the export has a page at keeps
11
- * it. Returns how many it wrote.
58
+ * `goodfellow build` does, and the same redirects in `_redirects` for
59
+ * Cloudflare Pages, which sends them itself. Pages win: an address the export
60
+ * has a page at keeps it.
61
+ */
62
+ export declare function writeRedirectFiles(outDir: string, root?: string): Promise<RedirectsWritten>;
63
+ /**
64
+ * For a site with an admin address (its config's `admin`, which the exported
65
+ * `goodfellow-site.json` carries), puts the guard first in every page of the
66
+ * export and adds Cloudflare Pages' header rules, as `goodfellow build` does.
67
+ * Run it last, once every page is written. Returns how many pages it guarded,
68
+ * or `undefined` for a site without an admin address.
12
69
  */
13
- export declare function writeRedirectFiles(outDir: string, root?: string): Promise<number>;
70
+ export declare function guardExport(outDir: string, root?: string): Promise<number | undefined>;
14
71
  /**
15
72
  * Puts a static export's prefetch files where the browser asks for them.
16
73
  * Next.js names each after its segment path with `/` turned into `.`
@@ -19,4 +76,46 @@ export declare function writeRedirectFiles(outDir: string, root?: string): Promi
19
76
  * prefetch is "not found". Returns how many files it moved; none elsewhere.
20
77
  */
21
78
  export declare function fixSegmentFiles(outDir: string): Promise<number>;
79
+ /** Where the editor's frames' page is in an export, and in a full-stack site's build. */
80
+ export declare const EDITOR_PAGES: string[];
81
+ /**
82
+ * Gives the editor's frames' page (`app/admin/editor/`) its Content Security
83
+ * Policy and first script, which removes WebRTC, as the first things in its
84
+ * head, so they apply before anything Next.js put there. The policy runs only
85
+ * the site's own scripts, Next.js's inline ones by their hashes, and fetches
86
+ * nothing (`editorPolicy()`). Returns whether it found the page.
87
+ */
88
+ export declare function writeEditorPolicy(dir: string): Promise<boolean>;
89
+ /**
90
+ * On a site in several languages, puts each page's language on its `<html>`,
91
+ * which Next.js's root layout, shared by every page, says is the main one:
92
+ * in a static export's pages (`outDir`), or, without one, in the pages a
93
+ * full-stack site's `next build` rendered ahead. Returns how many it changed.
94
+ */
95
+ export declare function writePageLanguages(outDir: string | undefined, root?: string): Promise<number>;
96
+ /** A page `next build` rendered ahead: its address on the site, and its HTML. */
97
+ export interface BuiltPage {
98
+ url: string;
99
+ html: string;
100
+ }
101
+ /**
102
+ * The pages `next build` rendered ahead for a full-stack site (`withGoodfellow(config,
103
+ * { server: true })`), at their addresses on the site, without its base path, as the
104
+ * Search block looks them up. Next.js lists them in `.next/prerender-manifest.json`
105
+ * and keeps their HTML in `.next/server/app/`. The admin panel and Next.js's own pages are left out.
106
+ */
107
+ export declare function builtPages(root?: string): Promise<BuiltPage[]>;
108
+ /**
109
+ * The pages a static export has, at their addresses on the site, without its
110
+ * base path: `about.html` and `about/index.html` are `/about`. The admin panel
111
+ * and Next.js's own files are left out.
112
+ */
113
+ export declare function exportedPages(outDir: string): Promise<BuiltPage[]>;
114
+ /**
115
+ * What automated checks find on the pages Next.js built, as `goodfellow build`
116
+ * reports them: images without alt text, headings that skip a level, links
117
+ * that don't say where they go, and axe-core's checks (`checkBuiltPages()`).
118
+ * Pages that aren't the site's, such as Next.js's own, are left out.
119
+ */
120
+ export declare function checkPages(pages: readonly BuiltPage[], root?: string): Promise<SiteProblem[]>;
22
121
  //#endregion