@goodfellow-cms/next 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/goodfellow-next.js +240 -15
- package/dist/admin.d.ts +19 -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 +2 -0
- package/dist/config.js +87 -24
- package/dist/dev-editor-EIZQe3qO.js +170 -0
- package/dist/editor.d.ts +14 -0
- package/dist/editor.js +32 -0
- package/dist/export.d.ts +112 -3
- package/dist/export.js +259 -12
- package/dist/index.d.ts +12 -4
- package/dist/index.js +90 -42
- package/dist/plugins-eLJalBij.js +191 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +2 -1
- package/dist/site-files-BeLnLxCl.js +50 -0
- package/package.json +19 -12
package/dist/export.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { i as loadSiteConfig, n as buildPluginFrames, r as configHasPlugins } from "./plugins-eLJalBij.js";
|
|
2
|
+
import { r as NEXT_SITE_FILES } from "./site-files-BeLnLxCl.js";
|
|
3
|
+
import { CALENDARS_DIR, CLOUDFLARE_REDIRECTS_PATH, EDITOR_PRELUDE, FEEDS_DIR, SITE_DESCRIPTION_PATH, TASK_RESULT_FILE, absoluteUrl, allPages, calendarFiles, cloudflareRedirects, editorPolicy, feedFiles, hasLanguages, loadSiteContent, parseSiteDescription, pathLanguage, pluginId, redirectFiles, rootRobotsUrl, sealTaskAnswer, shownContent, siteLanguages, taskRequestProblem } from "@goodfellow-cms/core";
|
|
4
|
+
import { BUILD_DATES_DIR, fileSystemSource, guardBuiltSite, reportedDate, runPluginBuildSteps, runPluginTask, selfHostFonts, taskSecretsFrom, writeBuildRecord } from "@goodfellow-cms/core/node";
|
|
4
5
|
import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { checkBuiltPages } from "@goodfellow-cms/core/accessibility";
|
|
5
9
|
//#region src/export.ts
|
|
6
10
|
/**
|
|
7
11
|
* Writes the calendar files of the site's collections of events into the
|
|
@@ -9,7 +13,20 @@ import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs
|
|
|
9
13
|
* how many it wrote.
|
|
10
14
|
*/
|
|
11
15
|
async function writeCalendarFiles(outDir, root = process.cwd()) {
|
|
12
|
-
const files = calendarFiles(await loadSiteContent(fileSystemSource(resolve(root))));
|
|
16
|
+
const files = calendarFiles(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
|
|
17
|
+
for (const file of files) {
|
|
18
|
+
const target = join(outDir, file.path.slice(1));
|
|
19
|
+
await mkdir(dirname(target), { recursive: true });
|
|
20
|
+
await writeFile(target, file.content);
|
|
21
|
+
}
|
|
22
|
+
return files.length;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Writes the feeds of the site's collections into the export, as `goodfellow
|
|
26
|
+
* build` does. Returns how many it wrote: none without the site's address.
|
|
27
|
+
*/
|
|
28
|
+
async function writeFeedFiles(outDir, root = process.cwd()) {
|
|
29
|
+
const files = feedFiles(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
|
|
13
30
|
for (const file of files) {
|
|
14
31
|
const target = join(outDir, file.path.slice(1));
|
|
15
32
|
await mkdir(dirname(target), { recursive: true });
|
|
@@ -17,6 +34,15 @@ async function writeCalendarFiles(outDir, root = process.cwd()) {
|
|
|
17
34
|
}
|
|
18
35
|
return files.length;
|
|
19
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* The folders the calendar files and feeds go in: `calendars/` and `feeds/`,
|
|
39
|
+
* and on a site in several languages, the same under each other language's
|
|
40
|
+
* code, such as `es/feeds/`.
|
|
41
|
+
*/
|
|
42
|
+
async function buildFileFolders(root = process.cwd()) {
|
|
43
|
+
const { settings } = await loadSiteContent(fileSystemSource(resolve(root)));
|
|
44
|
+
return siteLanguages(settings).flatMap((language) => [CALENDARS_DIR, FEEDS_DIR].map((dir) => `${language.prefix.slice(1)}${language.prefix ? "/" : ""}${dir}`));
|
|
45
|
+
}
|
|
20
46
|
/** The `basePath` the export was built with, which Next.js records in `.next/routes-manifest.json`. */
|
|
21
47
|
async function exportBasePath(root) {
|
|
22
48
|
try {
|
|
@@ -26,31 +52,123 @@ async function exportBasePath(root) {
|
|
|
26
52
|
return "";
|
|
27
53
|
}
|
|
28
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Plugins' parts that builds make: each plugin's admin frame, beside the
|
|
57
|
+
* editor's frames at `admin/plugins/<id>/`, and its build steps, each in a
|
|
58
|
+
* process of its own held to what the plugin declared. Into a static export
|
|
59
|
+
* (`outDir`), or a full-stack site's `public/`, where `next start` serves
|
|
60
|
+
* them. The config is loaded outside Next.js only when it lists plugins.
|
|
61
|
+
*/
|
|
62
|
+
async function writePluginFiles(outDir, root = process.cwd()) {
|
|
63
|
+
const site = resolve(root);
|
|
64
|
+
if (!await configHasPlugins(site)) return {
|
|
65
|
+
frames: [],
|
|
66
|
+
steps: []
|
|
67
|
+
};
|
|
68
|
+
const config = await loadSiteConfig(site);
|
|
69
|
+
const base = `${await exportBasePath(site)}/`;
|
|
70
|
+
const dir = resolve(site, outDir);
|
|
71
|
+
return {
|
|
72
|
+
frames: await buildPluginFrames(config.plugins, {
|
|
73
|
+
root: site,
|
|
74
|
+
outDir: dir
|
|
75
|
+
}),
|
|
76
|
+
steps: (await runPluginBuildSteps({
|
|
77
|
+
root: site,
|
|
78
|
+
outDir: dir,
|
|
79
|
+
base,
|
|
80
|
+
plugins: config.plugins ?? [],
|
|
81
|
+
onStep: (step) => console.log(`Running ${step.title}, from ${step.plugin}…`)
|
|
82
|
+
})).map((step) => `${step.plugin}: ${step.title}`)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
29
85
|
async function exists(path) {
|
|
30
86
|
return access(path).then(() => true, () => false);
|
|
31
87
|
}
|
|
32
88
|
/**
|
|
89
|
+
* Copies the site's Google Fonts into the export and points its pages at
|
|
90
|
+
* them, as `goodfellow build` does (`selfHostFonts()`), with the export's base
|
|
91
|
+
* path, and says where search engines read `robots.txt` for a site in a
|
|
92
|
+
* subfolder (`rootRobotsUrl()`).
|
|
93
|
+
*/
|
|
94
|
+
async function writeFontFiles(outDir, root = process.cwd()) {
|
|
95
|
+
const { settings } = await loadSiteContent(fileSystemSource(resolve(root)));
|
|
96
|
+
const fonts = await selfHostFonts(outDir, settings.theme, { base: await exportBasePath(root) });
|
|
97
|
+
const robotsElsewhere = rootRobotsUrl(settings.url);
|
|
98
|
+
return {
|
|
99
|
+
...fonts && { fonts },
|
|
100
|
+
...robotsElsewhere && { robotsElsewhere },
|
|
101
|
+
...settings.url && { sitemap: absoluteUrl(settings.url, "/sitemap.xml") }
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Writes the build record (`goodfellow-build.json`) into the export, as
|
|
106
|
+
* `goodfellow build` does, from the dates the processes that rendered its
|
|
107
|
+
* pages wrote into `.next/` (see `withGoodfellow()`). Returns when the site
|
|
108
|
+
* stops being current, if it does.
|
|
109
|
+
*/
|
|
110
|
+
async function writeBuildRecordFile(outDir, root = process.cwd()) {
|
|
111
|
+
const dir = resolve(root);
|
|
112
|
+
const content = await loadSiteContent(fileSystemSource(dir));
|
|
113
|
+
const date = await reportedDate(join(dir, ".next", BUILD_DATES_DIR));
|
|
114
|
+
return (await writeBuildRecord(outDir, {
|
|
115
|
+
root: dir,
|
|
116
|
+
content,
|
|
117
|
+
now: /* @__PURE__ */ new Date(),
|
|
118
|
+
...date && { date }
|
|
119
|
+
})).currentUntil;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
33
122
|
* Writes a page at each of the site's old addresses that sends visitors on, as
|
|
34
|
-
* `goodfellow build` does
|
|
35
|
-
*
|
|
123
|
+
* `goodfellow build` does, and the same redirects in `_redirects` for
|
|
124
|
+
* Cloudflare Pages, which sends them itself. Pages win: an address the export
|
|
125
|
+
* has a page at keeps it.
|
|
36
126
|
*/
|
|
37
127
|
async function writeRedirectFiles(outDir, root = process.cwd()) {
|
|
38
128
|
const dir = resolve(root);
|
|
39
|
-
const content = await loadSiteContent(fileSystemSource(dir));
|
|
129
|
+
const content = shownContent(await loadSiteContent(fileSystemSource(dir)));
|
|
130
|
+
const base = await exportBasePath(dir);
|
|
40
131
|
const files = redirectFiles(content, {
|
|
41
132
|
taken: allPages(content).map((page) => page.path),
|
|
42
|
-
base
|
|
133
|
+
base
|
|
43
134
|
});
|
|
44
|
-
|
|
135
|
+
const written = [];
|
|
45
136
|
for (const file of files) {
|
|
46
137
|
const target = join(outDir, file.path.slice(1));
|
|
47
138
|
const page = join(outDir, `${file.path.replace(/\/index\.html$/, "")}.html`);
|
|
48
139
|
if (await exists(target) || await exists(page)) continue;
|
|
49
140
|
await mkdir(dirname(target), { recursive: true });
|
|
50
141
|
await writeFile(target, file.content);
|
|
51
|
-
written
|
|
142
|
+
written.push(file);
|
|
52
143
|
}
|
|
53
|
-
|
|
144
|
+
const cloudflare = cloudflareRedirects(written, base);
|
|
145
|
+
const redirects = join(outDir, CLOUDFLARE_REDIRECTS_PATH.slice(1));
|
|
146
|
+
if (cloudflare.file && !await exists(redirects)) await writeFile(redirects, cloudflare.file.content);
|
|
147
|
+
return {
|
|
148
|
+
pages: written.length,
|
|
149
|
+
cloudflareLeft: cloudflare.left
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* For a site with an admin address (its config's `admin`, which the exported
|
|
154
|
+
* `goodfellow-site.json` carries), puts the guard first in every page of the
|
|
155
|
+
* export and adds Cloudflare Pages' header rules, as `goodfellow build` does.
|
|
156
|
+
* Run it last, once every page is written. Returns how many pages it guarded,
|
|
157
|
+
* or `undefined` for a site without an admin address.
|
|
158
|
+
*/
|
|
159
|
+
async function guardExport(outDir, root = process.cwd()) {
|
|
160
|
+
const text = await readFile(join(outDir, SITE_DESCRIPTION_PATH), "utf8").catch(() => void 0);
|
|
161
|
+
const parsed = text === void 0 ? void 0 : parseSiteDescription(text);
|
|
162
|
+
const admin = parsed?.ok ? parsed.description.admin : void 0;
|
|
163
|
+
if (!admin) return void 0;
|
|
164
|
+
const dir = resolve(root);
|
|
165
|
+
const content = await loadSiteContent(fileSystemSource(dir));
|
|
166
|
+
return guardBuiltSite(outDir, {
|
|
167
|
+
admin,
|
|
168
|
+
...parsed?.ok && parsed.description.broker && { broker: parsed.description.broker },
|
|
169
|
+
siteUrl: content.settings.url,
|
|
170
|
+
base: `${await exportBasePath(dir)}/`
|
|
171
|
+
});
|
|
54
172
|
}
|
|
55
173
|
/**
|
|
56
174
|
* Puts a static export's prefetch files where the browser asks for them.
|
|
@@ -88,6 +206,61 @@ async function fixSegmentFiles(outDir) {
|
|
|
88
206
|
await visit(outDir);
|
|
89
207
|
return moved;
|
|
90
208
|
}
|
|
209
|
+
/** Where the editor's frames' page is in an export, and in a full-stack site's build. */
|
|
210
|
+
const EDITOR_PAGES = ["admin/editor/index.html", "admin/editor.html"];
|
|
211
|
+
function scriptHash(text) {
|
|
212
|
+
return `sha256-${createHash("sha256").update(text).digest("base64")}`;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Gives the editor's frames' page (`app/admin/editor/`) its Content Security
|
|
216
|
+
* Policy and first script, which removes WebRTC, as the first things in its
|
|
217
|
+
* head, so they apply before anything Next.js put there. The policy runs only
|
|
218
|
+
* the site's own scripts, Next.js's inline ones by their hashes, and fetches
|
|
219
|
+
* nothing (`editorPolicy()`). Returns whether it found the page.
|
|
220
|
+
*/
|
|
221
|
+
async function writeEditorPolicy(dir) {
|
|
222
|
+
let found = false;
|
|
223
|
+
for (const name of EDITOR_PAGES) {
|
|
224
|
+
const file = join(dir, name);
|
|
225
|
+
const html = await readFile(file, "utf8").catch(() => void 0);
|
|
226
|
+
if (html === void 0 || html.includes("http-equiv=\"Content-Security-Policy\"")) continue;
|
|
227
|
+
const inline = [...html.matchAll(/<script(?![^>]*\ssrc=)[^>]*>([\s\S]*?)<\/script>/g)].map((match) => match[1] ?? "");
|
|
228
|
+
const head = `<meta http-equiv="Content-Security-Policy" content="${editorPolicy({
|
|
229
|
+
scriptHashes: [EDITOR_PRELUDE, ...inline].map(scriptHash),
|
|
230
|
+
blobScripts: true
|
|
231
|
+
})}"/><script>${EDITOR_PRELUDE}<\/script>`;
|
|
232
|
+
await writeFile(file, html.replace(/<head([^>]*)>/, (open) => `${open}${head}`));
|
|
233
|
+
found = true;
|
|
234
|
+
}
|
|
235
|
+
return found;
|
|
236
|
+
}
|
|
237
|
+
/** Says a page's language on its `<html>`, in place of the root layout's. */
|
|
238
|
+
function withPageLanguage(html, language) {
|
|
239
|
+
return html.replace(/<html\b([^>]*?)\slang="[^"]*"/, `<html$1 lang="${language}"`);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* On a site in several languages, puts each page's language on its `<html>`,
|
|
243
|
+
* which Next.js's root layout, shared by every page, says is the main one:
|
|
244
|
+
* in a static export's pages (`outDir`), or, without one, in the pages a
|
|
245
|
+
* full-stack site's `next build` rendered ahead. Returns how many it changed.
|
|
246
|
+
*/
|
|
247
|
+
async function writePageLanguages(outDir, root = process.cwd()) {
|
|
248
|
+
const dir = resolve(root);
|
|
249
|
+
const { settings } = await loadSiteContent(fileSystemSource(dir));
|
|
250
|
+
if (!hasLanguages(settings)) return 0;
|
|
251
|
+
const base = outDir ?? join(dir, ".next/server/app");
|
|
252
|
+
const files = (await readdir(base, { recursive: true })).map((file) => file.split("\\").join("/")).filter((file) => file.endsWith(".html") && !/^(admin|_next|pagefind)\//.test(file));
|
|
253
|
+
let changed = 0;
|
|
254
|
+
for (const file of files) {
|
|
255
|
+
const path = `/${file.replace(/(^|\/)index\.html$/, "").replace(/\.html$/, "")}`.replace(/\/$/, "") || "/";
|
|
256
|
+
const html = await readFile(join(base, file), "utf8");
|
|
257
|
+
const fixed = withPageLanguage(html, pathLanguage(settings, path));
|
|
258
|
+
if (fixed === html) continue;
|
|
259
|
+
await writeFile(join(base, file), fixed);
|
|
260
|
+
changed++;
|
|
261
|
+
}
|
|
262
|
+
return changed;
|
|
263
|
+
}
|
|
91
264
|
/**
|
|
92
265
|
* The pages `next build` rendered ahead for a full-stack site (`withGoodfellow(config,
|
|
93
266
|
* { server: true })`), at their addresses on the site, without its base path, as the
|
|
@@ -111,5 +284,79 @@ async function builtPages(root = process.cwd()) {
|
|
|
111
284
|
}
|
|
112
285
|
return pages;
|
|
113
286
|
}
|
|
287
|
+
/**
|
|
288
|
+
* The pages a static export has, at their addresses on the site, without its
|
|
289
|
+
* base path: `about.html` and `about/index.html` are `/about`. The admin panel
|
|
290
|
+
* and Next.js's own files are left out.
|
|
291
|
+
*/
|
|
292
|
+
async function exportedPages(outDir) {
|
|
293
|
+
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");
|
|
294
|
+
const pages = [];
|
|
295
|
+
for (const file of files) {
|
|
296
|
+
const url = `/${file.replace(/(^|\/)index\.html$/, "").replace(/\.html$/, "")}`.replace(/\/$/, "") || "/";
|
|
297
|
+
pages.push({
|
|
298
|
+
url,
|
|
299
|
+
html: await readFile(join(outDir, file), "utf8")
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
return pages;
|
|
303
|
+
}
|
|
304
|
+
/** The content file a page comes from: its own, or, for pages blocks add such as a list's second page, its page's. */
|
|
305
|
+
function contentFile(pages, path) {
|
|
306
|
+
const own = pages.find((page) => page.path === path);
|
|
307
|
+
if (own) return own.file;
|
|
308
|
+
return pages.filter((page) => page.path !== "/" && path.startsWith(`${page.path}/`)).sort((a, b) => b.path.length - a.path.length)[0]?.file;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* What automated checks find on the pages Next.js built, as `goodfellow build`
|
|
312
|
+
* reports them: images without alt text, headings that skip a level, links
|
|
313
|
+
* that don't say where they go, and axe-core's checks (`checkBuiltPages()`).
|
|
314
|
+
* Pages that aren't the site's, such as Next.js's own, are left out.
|
|
315
|
+
*/
|
|
316
|
+
async function checkPages(pages, root = process.cwd()) {
|
|
317
|
+
const known = allPages(shownContent(await loadSiteContent(fileSystemSource(resolve(root)))));
|
|
318
|
+
return checkBuiltPages(pages.flatMap((page) => {
|
|
319
|
+
const path = page.url.replace(/(.)\/$/, "$1");
|
|
320
|
+
const file = contentFile(known, path);
|
|
321
|
+
return file ? [{
|
|
322
|
+
path,
|
|
323
|
+
file,
|
|
324
|
+
html: page.html
|
|
325
|
+
}] : [];
|
|
326
|
+
}));
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Runs one of a plugin's tasks for the admin panel, as `goodfellow task`
|
|
330
|
+
* does for other sites, with the site's config loaded in Node, and writes its
|
|
331
|
+
* answer sealed to the request's key, even when the task fails. Secrets are
|
|
332
|
+
* taken out of the environment before the config loads.
|
|
333
|
+
*/
|
|
334
|
+
async function writeTaskResult(root, request, out = TASK_RESULT_FILE) {
|
|
335
|
+
const secrets = taskSecretsFrom(process.env);
|
|
336
|
+
const problem = taskRequestProblem(request);
|
|
337
|
+
if (problem) throw new Error(problem);
|
|
338
|
+
let answer;
|
|
339
|
+
try {
|
|
340
|
+
const plugin = ((await loadSiteConfig(resolve(root))).plugins ?? []).find((candidate) => pluginId(candidate.name) === request.plugin);
|
|
341
|
+
answer = plugin ? await runPluginTask({
|
|
342
|
+
root: resolve(root),
|
|
343
|
+
plugin,
|
|
344
|
+
task: request.task,
|
|
345
|
+
input: request.input,
|
|
346
|
+
secrets
|
|
347
|
+
}) : {
|
|
348
|
+
ok: false,
|
|
349
|
+
error: `The site has no plugin ${request.plugin}.`
|
|
350
|
+
};
|
|
351
|
+
} catch (error) {
|
|
352
|
+
answer = {
|
|
353
|
+
ok: false,
|
|
354
|
+
error: `The site couldn't run the task: ${error instanceof Error ? error.message : error}`
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const sealed = await sealTaskAnswer(answer, request.key);
|
|
358
|
+
await writeFile(resolve(root, out), `${JSON.stringify(sealed)}\n`);
|
|
359
|
+
return sealed;
|
|
360
|
+
}
|
|
114
361
|
//#endregion
|
|
115
|
-
export { builtPages, fixSegmentFiles, writeCalendarFiles, writeRedirectFiles };
|
|
362
|
+
export { EDITOR_PAGES, NEXT_SITE_FILES, buildFileFolders, builtPages, checkPages, exportedPages, fixSegmentFiles, guardExport, writeBuildRecordFile, writeCalendarFiles, writeEditorPolicy, writeFeedFiles, writeFontFiles, writePageLanguages, writePluginFiles, writeRedirectFiles, writeTaskResult };
|
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, storedMediaSizes, 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",
|
|
@@ -75,7 +87,10 @@ function goodfellowPages(config, options = {}) {
|
|
|
75
87
|
const renderOptions = async () => ({
|
|
76
88
|
base: basePath(),
|
|
77
89
|
components,
|
|
78
|
-
media:
|
|
90
|
+
media: {
|
|
91
|
+
...await readMediaSizes(root),
|
|
92
|
+
...storedMediaSizes((await site()).mediaStorage)
|
|
93
|
+
}
|
|
79
94
|
});
|
|
80
95
|
/** The site's theme and fonts, which React moves into the document's head. themeToCss escapes its output. */
|
|
81
96
|
async function Theme({ content }) {
|
|
@@ -99,22 +114,11 @@ function goodfellowPages(config, options = {}) {
|
|
|
99
114
|
precedence: "default",
|
|
100
115
|
children: themeToCss(content.settings.theme, standIns)
|
|
101
116
|
}),
|
|
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
|
-
] })
|
|
117
|
+
fontsUrl && /* @__PURE__ */ jsx("link", {
|
|
118
|
+
rel: "stylesheet",
|
|
119
|
+
href: fontsUrl,
|
|
120
|
+
precedence: "default"
|
|
121
|
+
})
|
|
118
122
|
] });
|
|
119
123
|
}
|
|
120
124
|
return {
|
|
@@ -127,6 +131,14 @@ function goodfellowPages(config, options = {}) {
|
|
|
127
131
|
const page = findPage(content, config.blocks, pathOf((await params).path));
|
|
128
132
|
return page ? pageMetadata(content, applyPageEntry(configs, content, page), basePath()) : {};
|
|
129
133
|
},
|
|
134
|
+
/**
|
|
135
|
+
* For `app/admin/page.tsx`: what the admin panel's page knows about the
|
|
136
|
+
* site, which never loads its config. The site's blocks run in the
|
|
137
|
+
* editor's frames, at `app/admin/editor/page.tsx`.
|
|
138
|
+
*/
|
|
139
|
+
admin() {
|
|
140
|
+
return adminSite(config);
|
|
141
|
+
},
|
|
130
142
|
/** For `app/admin/layout.tsx`: the admin panel's title, kept out of search engines, with the site's icon. */
|
|
131
143
|
async adminMetadata() {
|
|
132
144
|
const favicon = await site().then((content) => content.settings.favicon, () => void 0);
|
|
@@ -142,21 +154,53 @@ function goodfellowPages(config, options = {}) {
|
|
|
142
154
|
*/
|
|
143
155
|
async Layout({ children }) {
|
|
144
156
|
const content = await site();
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
const options = await renderOptions();
|
|
158
|
+
let header;
|
|
159
|
+
let footer;
|
|
160
|
+
if (hasLanguages(content.settings)) {
|
|
161
|
+
const languageMap$1 = languageMap(content, exportedPages(content, config.blocks));
|
|
162
|
+
const layouts = await Promise.all(siteLanguages(content.settings).map(async (language) => ({
|
|
163
|
+
prefix: language.prefix,
|
|
164
|
+
layout: await prepareLayout(configs, content, {
|
|
165
|
+
...options,
|
|
166
|
+
languageMap: languageMap$1
|
|
167
|
+
}, language.code)
|
|
168
|
+
})));
|
|
169
|
+
header = /* @__PURE__ */ jsx(ByLanguage, { variants: layouts.map(({ prefix, layout }) => ({
|
|
170
|
+
prefix,
|
|
171
|
+
children: /* @__PURE__ */ jsx(PageHeader, {
|
|
172
|
+
site: layout.site,
|
|
173
|
+
layoutConfig: layout.layoutConfig,
|
|
174
|
+
data: layout.header
|
|
175
|
+
})
|
|
176
|
+
})) });
|
|
177
|
+
footer = /* @__PURE__ */ jsx(ByLanguage, { variants: layouts.map(({ prefix, layout }) => ({
|
|
178
|
+
prefix,
|
|
179
|
+
children: /* @__PURE__ */ jsx(PageFooter, {
|
|
180
|
+
site: layout.site,
|
|
181
|
+
layoutConfig: layout.layoutConfig,
|
|
182
|
+
data: layout.footer
|
|
183
|
+
})
|
|
184
|
+
})) });
|
|
185
|
+
} else {
|
|
186
|
+
const layout = await prepareLayout(configs, content, options);
|
|
187
|
+
header = /* @__PURE__ */ jsx(PageHeader, {
|
|
150
188
|
site: layout.site,
|
|
151
189
|
layoutConfig: layout.layoutConfig,
|
|
152
190
|
data: layout.header
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
/* @__PURE__ */ jsx(PageFooter, {
|
|
191
|
+
});
|
|
192
|
+
footer = /* @__PURE__ */ jsx(PageFooter, {
|
|
156
193
|
site: layout.site,
|
|
157
194
|
layoutConfig: layout.layoutConfig,
|
|
158
195
|
data: layout.footer
|
|
159
|
-
})
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
199
|
+
/* @__PURE__ */ jsx(Theme, { content }),
|
|
200
|
+
/* @__PURE__ */ jsx(HeadCode, { code: content.code?.head }),
|
|
201
|
+
header,
|
|
202
|
+
children,
|
|
203
|
+
footer,
|
|
160
204
|
/* @__PURE__ */ jsx(CurrentMenuLinks, { basePath: basePath() }),
|
|
161
205
|
/* @__PURE__ */ jsx(BodyCode, { code: content.code?.body })
|
|
162
206
|
] });
|
|
@@ -172,11 +216,14 @@ function goodfellowPages(config, options = {}) {
|
|
|
172
216
|
notFound();
|
|
173
217
|
}
|
|
174
218
|
const prepared = await preparePage(configs, content, page, await renderOptions());
|
|
175
|
-
|
|
219
|
+
const pageContent = /* @__PURE__ */ jsx(PageContent, {
|
|
176
220
|
site: prepared.site,
|
|
177
221
|
pageConfig: prepared.pageConfig,
|
|
178
|
-
page: prepared.data
|
|
222
|
+
page: prepared.data,
|
|
223
|
+
...prepared.structuredData && { structuredData: prepared.structuredData }
|
|
179
224
|
});
|
|
225
|
+
if (!hasLanguages(content.settings)) return pageContent;
|
|
226
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(PageLanguage, { language: prepared.site.settings.language }), pageContent] });
|
|
180
227
|
},
|
|
181
228
|
/**
|
|
182
229
|
* The site's "Page not found" page (`content/pages/404.json`), for `app/not-found.tsx`,
|
|
@@ -188,7 +235,7 @@ function goodfellowPages(config, options = {}) {
|
|
|
188
235
|
if (!page) return /* @__PURE__ */ jsx("h1", { children: "Page not found" });
|
|
189
236
|
const prepared = await preparePage(configs, content, page, await renderOptions());
|
|
190
237
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
191
|
-
/* @__PURE__ */ jsx("title", { children:
|
|
238
|
+
/* @__PURE__ */ jsx("title", { children: pageHead(content, page).title }),
|
|
192
239
|
/* @__PURE__ */ jsx(Theme, { content }),
|
|
193
240
|
/* @__PURE__ */ jsx(HeadCode, { code: content.code?.head }),
|
|
194
241
|
/* @__PURE__ */ jsx(PageBody, {
|
|
@@ -202,12 +249,13 @@ function goodfellowPages(config, options = {}) {
|
|
|
202
249
|
/* @__PURE__ */ jsx(BodyCode, { code: content.code?.body })
|
|
203
250
|
] });
|
|
204
251
|
},
|
|
205
|
-
/** For `app/sitemap.ts`: every page but "Page not found", once the site's address is set. */
|
|
252
|
+
/** For `app/sitemap.ts`: every page but "Page not found" and pages hidden from search engines, once the site's address is set. */
|
|
206
253
|
async sitemap() {
|
|
207
254
|
const content = await site();
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
255
|
+
return sitemapPages(content, exportedPages(content, config.blocks)).map(({ url, alternates }) => ({
|
|
256
|
+
url,
|
|
257
|
+
...alternates && { alternates: { languages: alternates } }
|
|
258
|
+
}));
|
|
211
259
|
},
|
|
212
260
|
/**
|
|
213
261
|
* For `app/admin/demo-content.json/route.ts`: in a demo (the config's `demo`), the copy of
|