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