@goodfellow-cms/next 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +12 -2
- package/bin/goodfellow-next.js +52 -0
- package/dist/admin.d.ts +21 -0
- package/dist/admin.js +77 -0
- package/dist/components.d.ts +17 -0
- package/dist/components.js +51 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.js +139 -0
- package/dist/export.d.ts +22 -0
- package/dist/export.js +92 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +218 -0
- package/dist/tailwind-browser-Ds-ZH7sO.js +4 -0
- package/package.json +84 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Goodfellow contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @goodfellow-cms/next
|
|
2
2
|
|
|
3
|
-
|
|
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`.
|
|
4
|
+
|
|
5
|
+
Start from the Next.js starter: `npm create goodfellow@latest my-site -- --template next`.
|
|
6
|
+
|
|
7
|
+
[Goodfellow](https://github.com/goodfellow-cms/goodfellow-cms) is a website builder for people who aren't developers, with no server or database: pages are files in your own GitHub or GitLab repository, edited in a visual editor built on [Puck](https://puckeditor.com). To start a new site, run `npm create goodfellow@latest my-site`. See the [documentation](https://goodfellow-cms.github.io/goodfellow-cms/) for everything else.
|
|
8
|
+
|
|
9
|
+
More in the docs: [Next.js](https://goodfellow-cms.github.io/goodfellow-cms/docs/nextjs).
|
|
10
|
+
|
|
11
|
+
## License
|
|
12
|
+
|
|
13
|
+
MIT
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
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.
|
|
4
|
+
// `goodfellow-next update` updates Goodfellow, as `goodfellow update` does for other sites.
|
|
5
|
+
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";
|
|
10
|
+
|
|
11
|
+
const [command, dir = "out"] = process.argv.slice(2);
|
|
12
|
+
if (command === "update") {
|
|
13
|
+
const { values } = parseArgs({
|
|
14
|
+
args: process.argv.slice(3),
|
|
15
|
+
options: { to: { type: "string" }, publish: { type: "boolean" } },
|
|
16
|
+
});
|
|
17
|
+
const result = await update({ to: values.to, publish: values.publish });
|
|
18
|
+
// For the admin panel, which reads it in the host's log.
|
|
19
|
+
console.log(formatUpdateResult(result));
|
|
20
|
+
process.exit(result.state === "failed" ? 1 : 0);
|
|
21
|
+
}
|
|
22
|
+
if (command !== "finish" && command !== "index") {
|
|
23
|
+
console.error(
|
|
24
|
+
[
|
|
25
|
+
"Usage: goodfellow-next finish [folder]",
|
|
26
|
+
" goodfellow-next index [folder]",
|
|
27
|
+
" goodfellow-next update [--to fixes|automatic|latest|<release>] [--publish]",
|
|
28
|
+
"",
|
|
29
|
+
"finish Gets the exported site in folder (default: out) ready to publish: puts the files",
|
|
30
|
+
" 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.",
|
|
33
|
+
"index Only indexes the exported site for its search block.",
|
|
34
|
+
"update Updates Goodfellow, and blocks from its registry, if the site still builds.",
|
|
35
|
+
].join("\n"),
|
|
36
|
+
);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
if (command === "finish") {
|
|
40
|
+
const moved = await fixSegmentFiles(dir);
|
|
41
|
+
if (moved > 0) console.log(`Moved ${moved} of Next.js's prefetch files to where browsers look for them`);
|
|
42
|
+
const calendars = await writeCalendarFiles(dir);
|
|
43
|
+
if (calendars > 0) console.log(`Wrote ${calendars} calendar files`);
|
|
44
|
+
const redirects = await writeRedirectFiles(dir);
|
|
45
|
+
if (redirects > 0) console.log(`Wrote ${redirects} redirects`);
|
|
46
|
+
}
|
|
47
|
+
const indexed = await writeSearchIndex(dir);
|
|
48
|
+
console.log(
|
|
49
|
+
indexed === undefined
|
|
50
|
+
? `No page in ${dir} has a search block, so there's nothing to index`
|
|
51
|
+
: `Indexed ${indexed} pages for search`,
|
|
52
|
+
);
|
package/dist/admin.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { GoodfellowConfig } from "@goodfellow-cms/core";
|
|
2
|
+
import { Strings } from "@goodfellow-cms/admin";
|
|
3
|
+
import "@puckeditor/core/puck.css";
|
|
4
|
+
import "@goodfellow-cms/admin/styles.css";
|
|
5
|
+
//#region src/admin.d.ts
|
|
6
|
+
export interface GoodfellowAdminProps {
|
|
7
|
+
/** The site's `goodfellow.config.tsx`. */
|
|
8
|
+
config: GoodfellowConfig;
|
|
9
|
+
/** The live site's address, for "View" links. Defaults to the site's base path, such as `/` or `/my-site/`. */
|
|
10
|
+
siteUrl?: string;
|
|
11
|
+
/** Replaces any of the admin panel's text, for rewording or translation. */
|
|
12
|
+
strings?: Partial<Strings>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
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.
|
|
19
|
+
*/
|
|
20
|
+
export declare function GoodfellowAdmin({ config, siteUrl, strings }: GoodfellowAdminProps): import("react").JSX.Element | null;
|
|
21
|
+
//#endregion
|
package/dist/admin.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
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 "@goodfellow-cms/admin/styles.css";
|
|
6
|
+
//#region src/generated/theme-css.ts
|
|
7
|
+
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@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 h1,\n h2,\n h3,\n h4,\n h5,\n h6 {\n font-family: var(--font-heading);\n }\n}\n";
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/admin.tsx
|
|
10
|
+
/**
|
|
11
|
+
* The site's own stylesheets, as Next.js serves them, for previews to look like
|
|
12
|
+
* the site. Next.js names them as it builds them, so they're read from the
|
|
13
|
+
* site's home page rather than known in advance.
|
|
14
|
+
*/
|
|
15
|
+
async function siteStylesheets(siteUrl) {
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(siteUrl, { headers: { accept: "text/html" } });
|
|
18
|
+
const html = await response.text();
|
|
19
|
+
const doc = new DOMParser().parseFromString(html, "text/html");
|
|
20
|
+
return Array.from(doc.querySelectorAll("link[rel=\"stylesheet\"][href]")).map((link) => new URL(link.getAttribute("href") ?? "", response.url).href).filter((href) => !href.startsWith("https://fonts.googleapis.com/"));
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Set by `withGoodfellow()` from Next.js's `basePath`, such as `/my-site`, or empty. */
|
|
26
|
+
const BASE_PATH = process.env.GOODFELLOW_BASE_PATH ?? "";
|
|
27
|
+
async function setUp(siteUrl) {
|
|
28
|
+
const [stylesheets, tailwindBrowser, store] = await Promise.all([
|
|
29
|
+
siteStylesheets(siteUrl),
|
|
30
|
+
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
|
|
32
|
+
]);
|
|
33
|
+
return {
|
|
34
|
+
stylesheets,
|
|
35
|
+
tailwindBrowserUrl: URL.createObjectURL(new Blob([tailwindBrowser], { type: "text/javascript" })),
|
|
36
|
+
...store && { store }
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
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.
|
|
44
|
+
*/
|
|
45
|
+
function GoodfellowAdmin({ config, siteUrl = `${BASE_PATH}/`, strings }) {
|
|
46
|
+
const [setup, setSetup] = useState(null);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
let current = true;
|
|
49
|
+
let url;
|
|
50
|
+
setUp(siteUrl).then((result) => {
|
|
51
|
+
url = result.tailwindBrowserUrl;
|
|
52
|
+
if (current) setSetup(result);
|
|
53
|
+
else URL.revokeObjectURL(url);
|
|
54
|
+
});
|
|
55
|
+
return () => {
|
|
56
|
+
current = false;
|
|
57
|
+
if (url) URL.revokeObjectURL(url);
|
|
58
|
+
};
|
|
59
|
+
}, [siteUrl]);
|
|
60
|
+
if (!setup) return null;
|
|
61
|
+
return /* @__PURE__ */ jsx(Admin, {
|
|
62
|
+
config,
|
|
63
|
+
...setup.store && {
|
|
64
|
+
store: setup.store,
|
|
65
|
+
registry: `${BASE_PATH}/__goodfellow/api/registry/{name}.json`
|
|
66
|
+
},
|
|
67
|
+
preview: {
|
|
68
|
+
stylesheets: setup.stylesheets,
|
|
69
|
+
themeCss: theme_css_default,
|
|
70
|
+
tailwindBrowserUrl: setup.tailwindBrowserUrl
|
|
71
|
+
},
|
|
72
|
+
siteUrl,
|
|
73
|
+
...strings && { strings }
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
export { GoodfellowAdmin };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ImageSize } from "@goodfellow-cms/core";
|
|
2
|
+
import { SiteImageProps, SiteLinkProps } from "@goodfellow-cms/react";
|
|
3
|
+
//#region src/components.d.ts
|
|
4
|
+
/** A link to one of the site's pages, with Next.js's client-side navigation. Next.js adds the base path. */
|
|
5
|
+
export declare function NextLink(props: SiteLinkProps): import("react").JSX.Element;
|
|
6
|
+
/** One of the site's images, through Next.js's image component. Its address already has the base path. */
|
|
7
|
+
export declare function NextImage({ src, alt, width, height, loading, ...props }: SiteImageProps & ImageSize): import("react").JSX.Element;
|
|
8
|
+
/**
|
|
9
|
+
* Marks the current page's links in the site's menus (`data-current`, and
|
|
10
|
+
* `aria-current` for the page itself), as Menu does when it knows the page. The
|
|
11
|
+
* layout's header and footer are shared by every page, so they can't know it;
|
|
12
|
+
* this runs in the browser after every navigation instead.
|
|
13
|
+
*/
|
|
14
|
+
export declare function CurrentMenuLinks({ basePath }: {
|
|
15
|
+
basePath?: string;
|
|
16
|
+
}): null;
|
|
17
|
+
//#endregion
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { usePathname } from "next/navigation";
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import Image from "next/image";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { jsx } from "react/jsx-runtime";
|
|
7
|
+
//#region src/components.tsx
|
|
8
|
+
/** A link to one of the site's pages, with Next.js's client-side navigation. Next.js adds the base path. */
|
|
9
|
+
function NextLink(props) {
|
|
10
|
+
return /* @__PURE__ */ jsx(Link, { ...props });
|
|
11
|
+
}
|
|
12
|
+
/** One of the site's images, through Next.js's image component. Its address already has the base path. */
|
|
13
|
+
function NextImage({ src, alt, width, height, loading = "eager", ...props }) {
|
|
14
|
+
return /* @__PURE__ */ jsx(Image, {
|
|
15
|
+
...props,
|
|
16
|
+
src,
|
|
17
|
+
alt,
|
|
18
|
+
width,
|
|
19
|
+
height,
|
|
20
|
+
loading
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** A path without its trailing slash, but for the site's home page: `/news/` → `/news`. */
|
|
24
|
+
function trimSlash(path) {
|
|
25
|
+
return path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Marks the current page's links in the site's menus (`data-current`, and
|
|
29
|
+
* `aria-current` for the page itself), as Menu does when it knows the page. The
|
|
30
|
+
* layout's header and footer are shared by every page, so they can't know it;
|
|
31
|
+
* this runs in the browser after every navigation instead.
|
|
32
|
+
*/
|
|
33
|
+
function CurrentMenuLinks({ basePath = "" }) {
|
|
34
|
+
const pathname = usePathname();
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const here = trimSlash(location.pathname);
|
|
37
|
+
const home = trimSlash(`${basePath}/`);
|
|
38
|
+
for (const link of document.querySelectorAll("[data-gf-menu] a[href]")) {
|
|
39
|
+
const url = new URL(link.href, location.href);
|
|
40
|
+
const target = trimSlash(url.pathname);
|
|
41
|
+
const local = url.origin === location.origin;
|
|
42
|
+
const exact = local && target === here;
|
|
43
|
+
link.toggleAttribute("data-current", exact || local && target !== home && here.startsWith(`${target}/`));
|
|
44
|
+
if (exact) link.setAttribute("aria-current", "page");
|
|
45
|
+
else link.removeAttribute("aria-current");
|
|
46
|
+
}
|
|
47
|
+
}, [pathname, basePath]);
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
//#endregion
|
|
51
|
+
export { CurrentMenuLinks, NextImage, NextLink };
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NextConfig } from "next";
|
|
2
|
+
//#region src/config.d.ts
|
|
3
|
+
export interface GoodfellowNextOptions {
|
|
4
|
+
/** The site's folder, holding `content/` and `public/media/`. Defaults to the folder Next.js runs in. */
|
|
5
|
+
root?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Sets up Next.js for a Goodfellow site, in `next.config.ts`:
|
|
9
|
+
* `export default withGoodfellow({ ... })`.
|
|
10
|
+
*
|
|
11
|
+
* - Builds the site as static files (`output: "export"`), so any static host can serve it,
|
|
12
|
+
* with each page in its own folder (`trailingSlash: true`), as `goodfellow build` does.
|
|
13
|
+
* `next dev` serves pages as usual, so an unknown address shows the "not found" page.
|
|
14
|
+
* - Serves the site from `basePath`, or else the `GOODFELLOW_BASE` environment
|
|
15
|
+
* variable, as `goodfellow build` does (GitHub Pages serves most sites from `/repository-name/`).
|
|
16
|
+
* - Serves images as they are unless the site sets an image loader, since static files can't be optimized on request.
|
|
17
|
+
* - Adds the site's redirects (Site Settings → Redirects) ahead of the site's own, for hosts that
|
|
18
|
+
* send them, such as Vercel. Others get the pages `goodfellow-next finish` writes at old addresses.
|
|
19
|
+
* - In `next dev`, runs the admin panel's local backend, so publishing saves to
|
|
20
|
+
* the files on disk. It's never part of a build.
|
|
21
|
+
* - In `next dev`, turns off React's debug channel (`experimental.reactDebugChannel`)
|
|
22
|
+
* unless the site sets it: with the header and footer in a layout, it made the
|
|
23
|
+
* browser's memory grow to gigabytes on every page, until pages crashed.
|
|
24
|
+
*/
|
|
25
|
+
export declare function withGoodfellow(nextConfig?: NextConfig, options?: GoodfellowNextOptions): (phase: string) => Promise<NextConfig>;
|
|
26
|
+
//#endregion
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
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";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
//#region src/config.ts
|
|
6
|
+
/** `PHASE_DEVELOPMENT_SERVER` from `next/constants`, which Node can't import by that name. */
|
|
7
|
+
const PHASE_DEVELOPMENT_SERVER = "phase-development-server";
|
|
8
|
+
const SERVER_KEY = Symbol.for("goodfellow.next.devApi");
|
|
9
|
+
/**
|
|
10
|
+
* The admin panel's local backend, which reads and writes the site's files on
|
|
11
|
+
* disk. It runs beside `next dev`, on this computer only, and Next.js passes
|
|
12
|
+
* the admin panel's requests to it. Started once however often Next.js loads its config.
|
|
13
|
+
*/
|
|
14
|
+
function startDevApi(root) {
|
|
15
|
+
const globals = globalThis;
|
|
16
|
+
globals[SERVER_KEY] ??= new Promise((resolvePort, reject) => {
|
|
17
|
+
const store = localFileStore(root);
|
|
18
|
+
const registryDir = localRegistryDir(root);
|
|
19
|
+
const server = createServer(async (req, res) => {
|
|
20
|
+
if (!await handleDevApi(store, req, res, {
|
|
21
|
+
trustOrigin: isLocalOrigin,
|
|
22
|
+
registryDir
|
|
23
|
+
})) res.writeHead(404).end();
|
|
24
|
+
});
|
|
25
|
+
server.on("error", reject);
|
|
26
|
+
server.listen(0, "127.0.0.1", () => resolvePort(server.address().port));
|
|
27
|
+
server.unref();
|
|
28
|
+
});
|
|
29
|
+
return globals[SERVER_KEY];
|
|
30
|
+
}
|
|
31
|
+
/** Adds a rewrite ahead of the site's own, whichever form the site's rewrites take. */
|
|
32
|
+
function withRewrite(rewrites, rewrite) {
|
|
33
|
+
if (!rewrites) return [rewrite];
|
|
34
|
+
if (Array.isArray(rewrites)) return [rewrite, ...rewrites];
|
|
35
|
+
return {
|
|
36
|
+
...rewrites,
|
|
37
|
+
beforeFiles: [rewrite, ...rewrites.beforeFiles ?? []]
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** Next.js's form of a base path: `/my-site`, or empty for the root. */
|
|
41
|
+
function nextBasePath(base) {
|
|
42
|
+
return normalizeBase(base).slice(0, -1);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Next.js only optimizes images with a server or a custom loader. Without a
|
|
46
|
+
* loader, `next/image` serves images as they are, still with their sizes set.
|
|
47
|
+
*/
|
|
48
|
+
function imageSettings(images) {
|
|
49
|
+
if (images?.loader && images.loader !== "default") return images;
|
|
50
|
+
if (images?.loaderFile) return images;
|
|
51
|
+
return {
|
|
52
|
+
unoptimized: true,
|
|
53
|
+
...images
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** An address as Next.js matches a redirect's source, with the characters its patterns use escaped. */
|
|
57
|
+
function redirectSource(path) {
|
|
58
|
+
return encodeURI(path).replace(/[()[\]{}+*?:!]/g, "\\$&");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The site's redirects (`content/redirects.json`) for hosts that send them
|
|
62
|
+
* themselves, such as Vercel, which reads them from the build. Redirects from
|
|
63
|
+
* an address a page or a collection's item has are left out, since pages win,
|
|
64
|
+
* as they do over the pages `goodfellow-next finish` writes at old addresses.
|
|
65
|
+
* Content with a problem gives none, and the build reports the problem.
|
|
66
|
+
*/
|
|
67
|
+
async function siteRedirects(root) {
|
|
68
|
+
const content = await loadSiteContent(fileSystemSource(root)).catch(() => void 0);
|
|
69
|
+
if (!content?.redirects) return [];
|
|
70
|
+
const taken = new Set(allPages(content).map((page) => page.path));
|
|
71
|
+
const problems = redirectProblems(content.redirects);
|
|
72
|
+
return content.redirects.flatMap((redirect, index) => {
|
|
73
|
+
const from = redirectFrom(redirect.from);
|
|
74
|
+
if (problems[index] || !from.ok || taken.has(from.path)) return [];
|
|
75
|
+
return [{
|
|
76
|
+
source: redirectSource(from.path),
|
|
77
|
+
destination: redirect.to.trim(),
|
|
78
|
+
permanent: true
|
|
79
|
+
}];
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Sets up Next.js for a Goodfellow site, in `next.config.ts`:
|
|
84
|
+
* `export default withGoodfellow({ ... })`.
|
|
85
|
+
*
|
|
86
|
+
* - Builds the site as static files (`output: "export"`), so any static host can serve it,
|
|
87
|
+
* with each page in its own folder (`trailingSlash: true`), as `goodfellow build` does.
|
|
88
|
+
* `next dev` serves pages as usual, so an unknown address shows the "not found" page.
|
|
89
|
+
* - Serves the site from `basePath`, or else the `GOODFELLOW_BASE` environment
|
|
90
|
+
* variable, as `goodfellow build` does (GitHub Pages serves most sites from `/repository-name/`).
|
|
91
|
+
* - Serves images as they are unless the site sets an image loader, since static files can't be optimized on request.
|
|
92
|
+
* - Adds the site's redirects (Site Settings → Redirects) ahead of the site's own, for hosts that
|
|
93
|
+
* send them, such as Vercel. Others get the pages `goodfellow-next finish` writes at old addresses.
|
|
94
|
+
* - In `next dev`, runs the admin panel's local backend, so publishing saves to
|
|
95
|
+
* the files on disk. It's never part of a build.
|
|
96
|
+
* - In `next dev`, turns off React's debug channel (`experimental.reactDebugChannel`)
|
|
97
|
+
* unless the site sets it: with the header and footer in a layout, it made the
|
|
98
|
+
* browser's memory grow to gigabytes on every page, until pages crashed.
|
|
99
|
+
*/
|
|
100
|
+
function withGoodfellow(nextConfig = {}, options = {}) {
|
|
101
|
+
const root = resolve(options.root ?? process.cwd());
|
|
102
|
+
const basePath = nextBasePath(nextConfig.basePath ?? process.env.GOODFELLOW_BASE);
|
|
103
|
+
return async (phase) => {
|
|
104
|
+
const config = {
|
|
105
|
+
trailingSlash: true,
|
|
106
|
+
...nextConfig,
|
|
107
|
+
...basePath && { basePath },
|
|
108
|
+
images: imageSettings(nextConfig.images),
|
|
109
|
+
env: {
|
|
110
|
+
...nextConfig.env,
|
|
111
|
+
GOODFELLOW_BASE_PATH: basePath
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
if (phase !== PHASE_DEVELOPMENT_SERVER) return {
|
|
115
|
+
output: "export",
|
|
116
|
+
...config,
|
|
117
|
+
redirects: async () => [...await siteRedirects(root), ...await nextConfig.redirects?.() ?? []]
|
|
118
|
+
};
|
|
119
|
+
const port = await startDevApi(root);
|
|
120
|
+
const rewrite = {
|
|
121
|
+
source: `${DEV_API_PREFIX}/:path*`,
|
|
122
|
+
destination: `http://127.0.0.1:${port}${DEV_API_PREFIX}/:path*`
|
|
123
|
+
};
|
|
124
|
+
const calendars = {
|
|
125
|
+
source: `/${CALENDARS_DIR}/:file(.+\\.ics)`,
|
|
126
|
+
destination: `http://127.0.0.1:${port}${DEV_API_PREFIX}/${CALENDARS_DIR}/:file`
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
...config,
|
|
130
|
+
experimental: {
|
|
131
|
+
reactDebugChannel: false,
|
|
132
|
+
...nextConfig.experimental
|
|
133
|
+
},
|
|
134
|
+
rewrites: async () => withRewrite(withRewrite(await nextConfig.rewrites?.(), calendars), rewrite)
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
export { withGoodfellow };
|
package/dist/export.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/export.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Writes the calendar files of the site's collections of events into the
|
|
4
|
+
* export, as `goodfellow build` does: Next.js has no route for them. Returns
|
|
5
|
+
* how many it wrote.
|
|
6
|
+
*/
|
|
7
|
+
export declare function writeCalendarFiles(outDir: string, root?: string): Promise<number>;
|
|
8
|
+
/**
|
|
9
|
+
* 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.
|
|
12
|
+
*/
|
|
13
|
+
export declare function writeRedirectFiles(outDir: string, root?: string): Promise<number>;
|
|
14
|
+
/**
|
|
15
|
+
* Puts a static export's prefetch files where the browser asks for them.
|
|
16
|
+
* Next.js names each after its segment path with `/` turned into `.`
|
|
17
|
+
* (`__next.$oc$path.__PAGE__.txt`), but on Windows the path has `\` instead, so
|
|
18
|
+
* the export writes folders (`__next.$oc$path/__PAGE__.txt`), and every
|
|
19
|
+
* prefetch is "not found". Returns how many files it moved; none elsewhere.
|
|
20
|
+
*/
|
|
21
|
+
export declare function fixSegmentFiles(outDir: string): Promise<number>;
|
|
22
|
+
//#endregion
|
package/dist/export.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { allPages, calendarFiles, loadSiteContent, redirectFiles } from "@goodfellow-cms/core";
|
|
2
|
+
import { fileSystemSource } from "@goodfellow-cms/core/node";
|
|
3
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import { access, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
//#region src/export.ts
|
|
6
|
+
/**
|
|
7
|
+
* Writes the calendar files of the site's collections of events into the
|
|
8
|
+
* export, as `goodfellow build` does: Next.js has no route for them. Returns
|
|
9
|
+
* how many it wrote.
|
|
10
|
+
*/
|
|
11
|
+
async function writeCalendarFiles(outDir, root = process.cwd()) {
|
|
12
|
+
const files = calendarFiles(await loadSiteContent(fileSystemSource(resolve(root))));
|
|
13
|
+
for (const file of files) {
|
|
14
|
+
const target = join(outDir, file.path.slice(1));
|
|
15
|
+
await mkdir(dirname(target), { recursive: true });
|
|
16
|
+
await writeFile(target, file.content);
|
|
17
|
+
}
|
|
18
|
+
return files.length;
|
|
19
|
+
}
|
|
20
|
+
/** The `basePath` the export was built with, which Next.js records in `.next/routes-manifest.json`. */
|
|
21
|
+
async function exportBasePath(root) {
|
|
22
|
+
try {
|
|
23
|
+
const manifest = JSON.parse(await readFile(join(root, ".next/routes-manifest.json"), "utf8"));
|
|
24
|
+
return typeof manifest.basePath === "string" ? manifest.basePath : "";
|
|
25
|
+
} catch {
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function exists(path) {
|
|
30
|
+
return access(path).then(() => true, () => false);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Writes a page at each of the site's old addresses that sends visitors on, as
|
|
34
|
+
* `goodfellow build` does. Pages win: an address the export has a page at keeps
|
|
35
|
+
* it. Returns how many it wrote.
|
|
36
|
+
*/
|
|
37
|
+
async function writeRedirectFiles(outDir, root = process.cwd()) {
|
|
38
|
+
const dir = resolve(root);
|
|
39
|
+
const content = await loadSiteContent(fileSystemSource(dir));
|
|
40
|
+
const files = redirectFiles(content, {
|
|
41
|
+
taken: allPages(content).map((page) => page.path),
|
|
42
|
+
base: await exportBasePath(dir)
|
|
43
|
+
});
|
|
44
|
+
let written = 0;
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const target = join(outDir, file.path.slice(1));
|
|
47
|
+
const page = join(outDir, `${file.path.replace(/\/index\.html$/, "")}.html`);
|
|
48
|
+
if (await exists(target) || await exists(page)) continue;
|
|
49
|
+
await mkdir(dirname(target), { recursive: true });
|
|
50
|
+
await writeFile(target, file.content);
|
|
51
|
+
written++;
|
|
52
|
+
}
|
|
53
|
+
return written;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Puts a static export's prefetch files where the browser asks for them.
|
|
57
|
+
* Next.js names each after its segment path with `/` turned into `.`
|
|
58
|
+
* (`__next.$oc$path.__PAGE__.txt`), but on Windows the path has `\` instead, so
|
|
59
|
+
* the export writes folders (`__next.$oc$path/__PAGE__.txt`), and every
|
|
60
|
+
* prefetch is "not found". Returns how many files it moved; none elsewhere.
|
|
61
|
+
*/
|
|
62
|
+
async function fixSegmentFiles(outDir) {
|
|
63
|
+
let moved = 0;
|
|
64
|
+
async function visit(dir) {
|
|
65
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
66
|
+
if (!entry.isDirectory() || entry.name === "_next") continue;
|
|
67
|
+
const path = join(dir, entry.name);
|
|
68
|
+
if (!entry.name.startsWith("__next.")) {
|
|
69
|
+
await visit(path);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
for (const file of await readdir(path, {
|
|
73
|
+
recursive: true,
|
|
74
|
+
withFileTypes: true
|
|
75
|
+
})) {
|
|
76
|
+
if (!file.isFile()) continue;
|
|
77
|
+
const source = join(file.parentPath, file.name);
|
|
78
|
+
const parts = relative(path, source).split(/[\\/]/);
|
|
79
|
+
await rename(source, join(dir, [entry.name, ...parts].join(".")));
|
|
80
|
+
moved++;
|
|
81
|
+
}
|
|
82
|
+
await rm(path, {
|
|
83
|
+
recursive: true,
|
|
84
|
+
force: true
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
await visit(outDir);
|
|
89
|
+
return moved;
|
|
90
|
+
}
|
|
91
|
+
//#endregion
|
|
92
|
+
export { fixSegmentFiles, writeCalendarFiles, writeRedirectFiles };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { GoodfellowConfig, Page, SiteContent } from "@goodfellow-cms/core";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
import { Metadata, MetadataRoute } from "next";
|
|
4
|
+
//#region src/index.d.ts
|
|
5
|
+
export interface GoodfellowPagesOptions {
|
|
6
|
+
/** The site's folder, holding `content/`. Defaults to the folder Next.js runs in. */
|
|
7
|
+
root?: string;
|
|
8
|
+
/**
|
|
9
|
+
* The path the site is served from, such as `/my-site`. Defaults to the
|
|
10
|
+
* `basePath` that `withGoodfellow()` gave Next.js.
|
|
11
|
+
*/
|
|
12
|
+
basePath?: string;
|
|
13
|
+
}
|
|
14
|
+
/** The route parameters of an optional catch-all route named `[[...path]]`. */
|
|
15
|
+
export interface PageProps {
|
|
16
|
+
params: Promise<{
|
|
17
|
+
path?: string[];
|
|
18
|
+
}>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Reads the site's content from disk. Cached for the length of one request, so
|
|
22
|
+
* a page and its metadata read it once, and content edits show on reload in development.
|
|
23
|
+
*/
|
|
24
|
+
export declare const loadSite: (root?: string) => Promise<SiteContent>;
|
|
25
|
+
/** A page's title, description and link previews, as Next.js metadata. */
|
|
26
|
+
export declare function pageMetadata(content: SiteContent, page: Page, basePath?: string): Metadata;
|
|
27
|
+
/**
|
|
28
|
+
* Renders the site's pages in a Next.js App Router site, as Server Components.
|
|
29
|
+
* Blocks' links to the site's pages use `next/link`, and their images
|
|
30
|
+
* `next/image`, through `<SiteLink>` and `<SiteImage>`; Client Components in
|
|
31
|
+
* blocks run in the browser. Use the results in `app/(site)/layout.tsx`,
|
|
32
|
+
* which shows the header and footer once for every page, in
|
|
33
|
+
* `app/(site)/[[...path]]/page.tsx`, and in `app/not-found.tsx`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function goodfellowPages(config: GoodfellowConfig, options?: GoodfellowPagesOptions): {
|
|
36
|
+
/** Every page's address, so Next.js builds them all. Any other address is "not found". */
|
|
37
|
+
generateStaticParams(): Promise<Array<{
|
|
38
|
+
path: string[];
|
|
39
|
+
}>>;
|
|
40
|
+
generateMetadata({ params }: PageProps): Promise<Metadata>;
|
|
41
|
+
/** For `app/admin/layout.tsx`: the admin panel's title, kept out of search engines, with the site's icon. */
|
|
42
|
+
adminMetadata(): Promise<Metadata>;
|
|
43
|
+
/**
|
|
44
|
+
* For `app/(site)/layout.tsx`: the site's header and footer around every page, kept as
|
|
45
|
+
* they are when moving between pages. Menus mark the current page in the browser.
|
|
46
|
+
*/
|
|
47
|
+
Layout({ children }: {
|
|
48
|
+
children: ReactNode;
|
|
49
|
+
}): Promise<import("react").JSX.Element>;
|
|
50
|
+
/** The page at the route's address, inside `Layout`'s header and footer. */
|
|
51
|
+
Page({ params }: PageProps): Promise<import("react").JSX.Element>;
|
|
52
|
+
/**
|
|
53
|
+
* The site's "Page not found" page (`content/pages/404.json`), for `app/not-found.tsx`,
|
|
54
|
+
* with the header and footer, since Next.js shows it outside the `(site)` layout.
|
|
55
|
+
*/
|
|
56
|
+
NotFound(): Promise<import("react").JSX.Element>;
|
|
57
|
+
/** For `app/sitemap.ts`: every page but "Page not found", once the site's address is set. */
|
|
58
|
+
sitemap(): Promise<MetadataRoute.Sitemap>;
|
|
59
|
+
/**
|
|
60
|
+
* For `app/admin/demo-content.json/route.ts`: in a demo (the config's `demo`), the copy of
|
|
61
|
+
* the site's content its admin panel starts from. A static export can't leave a route out,
|
|
62
|
+
* so other sites get an empty file.
|
|
63
|
+
*/
|
|
64
|
+
demoContent(): Promise<Response>;
|
|
65
|
+
/** For `app/robots.ts`: lets search engines in, and points them at the sitemap once the site's address is set. */
|
|
66
|
+
robots(): Promise<MetadataRoute.Robots>;
|
|
67
|
+
};
|
|
68
|
+
//#endregion
|