hd-garden 0.5.18 → 0.6.1
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/app/a2/[slug]/page.tsx +6 -2
- package/app/admin/preview/[...path]/page.tsx +2 -1
- package/app/blog/[...path]/page.tsx +6 -2
- package/app/columns/[column]/[...path]/page.tsx +12 -5
- package/app/layout.tsx +0 -3
- package/app/styles/a2.css +2 -2
- package/app/styles/tokens.css +3 -0
- package/app/thoughts/[slug]/page.tsx +6 -2
- package/bin/garden.mjs +7 -1
- package/next.config.ts +5 -0
- package/package.json +7 -6
- package/scripts/favicon-ico.mjs +114 -0
- package/scripts/prepare-pages-output.mjs +35 -9
- package/scripts/static-placeholders.mjs +48 -0
- package/ui/garden/static-params.mjs +25 -0
- package/ui/preview/dist/garden-ui.esm.js +5 -5
- package/ui/preview/dist/garden-ui.iife.js +5 -5
- package/vite.config.ts +0 -2
- package/build/static-rsc-path-plugin.mjs +0 -78
- package/ui/garden/hash-jump.mjs +0 -37
package/app/a2/[slug]/page.tsx
CHANGED
|
@@ -8,9 +8,10 @@ import { a2WordCounts } from "@garden/generated-content";
|
|
|
8
8
|
import { demoDateLabel, demoDateTime } from "../../lib/a2-meta";
|
|
9
9
|
import { extractHeadings } from "../../lib/content";
|
|
10
10
|
import { a2Slugs, readA2Article } from "../../lib/a2-preview";
|
|
11
|
+
import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
|
|
11
12
|
|
|
12
13
|
export function generateStaticParams() {
|
|
13
|
-
return a2Slugs().map((slug) => ({ slug }));
|
|
14
|
+
return staticParams(a2Slugs().map((slug) => ({ slug })), { slug: STATIC_PLACEHOLDER });
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
|
@@ -23,7 +24,10 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|
|
23
24
|
export default async function A2ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
|
|
24
25
|
const { slug } = await params;
|
|
25
26
|
const article = readA2Article(slug);
|
|
26
|
-
if (!article)
|
|
27
|
+
if (!article) {
|
|
28
|
+
if (isStaticPlaceholder(slug)) return null;
|
|
29
|
+
notFound();
|
|
30
|
+
}
|
|
27
31
|
const headings = extractHeadings(article.content);
|
|
28
32
|
const wordCount = a2WordCounts[slug] ?? 0;
|
|
29
33
|
return (
|
|
@@ -8,6 +8,7 @@ import { MarkdownArticle } from "../../../components/MarkdownArticle";
|
|
|
8
8
|
import { repositoryContentPath } from "../../../lib/repository-path.mjs";
|
|
9
9
|
import { readMarkdown, readText } from "../../../lib/ui-content";
|
|
10
10
|
import { formatInlineLink, replaceInlineLinks } from "../../../../ui/garden/markdown-destination.mjs";
|
|
11
|
+
import { STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
|
|
11
12
|
|
|
12
13
|
export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
|
|
13
14
|
export const dynamicParams = false;
|
|
@@ -15,7 +16,7 @@ export const metadata = { title: "草稿预览", robots: { index: false, follow:
|
|
|
15
16
|
|
|
16
17
|
/** Draft preview is served by the admin worker. The public static export has no paths here. */
|
|
17
18
|
export function generateStaticParams() {
|
|
18
|
-
return [];
|
|
19
|
+
return staticParams([], { path: [STATIC_PLACEHOLDER] });
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const isRelative = (value: string) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/iu.test(value);
|
|
@@ -25,9 +25,10 @@ import {
|
|
|
25
25
|
webmentionApiEndpoint,
|
|
26
26
|
} from "../../lib/site";
|
|
27
27
|
import { githubUrl, siteConfig } from "../../site.config";
|
|
28
|
+
import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
|
|
28
29
|
|
|
29
30
|
export function generateStaticParams() {
|
|
30
|
-
return posts.map((post) => ({ path: post.path.split("/") }));
|
|
31
|
+
return staticParams(posts.map((post) => ({ path: post.path.split("/") })), { path: [STATIC_PLACEHOLDER] });
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export async function generateMetadata({
|
|
@@ -72,7 +73,10 @@ export default async function PostPage({
|
|
|
72
73
|
}) {
|
|
73
74
|
const { path } = await params;
|
|
74
75
|
const post = findPost(path.join("/"));
|
|
75
|
-
if (!post)
|
|
76
|
+
if (!post) {
|
|
77
|
+
if (isStaticPlaceholder(path)) return null;
|
|
78
|
+
notFound();
|
|
79
|
+
}
|
|
76
80
|
const headings = extractHeadings(post.content);
|
|
77
81
|
const baseUrl = await getSiteUrl();
|
|
78
82
|
const pathname = postHref(post);
|
|
@@ -27,12 +27,16 @@ import {
|
|
|
27
27
|
} from "../../../lib/site";
|
|
28
28
|
import { githubUrl, siteConfig } from "../../../site.config";
|
|
29
29
|
import { siteHref } from "../../../lib/site-href";
|
|
30
|
+
import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
|
|
30
31
|
|
|
31
32
|
export function generateStaticParams() {
|
|
32
|
-
return
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
return staticParams(
|
|
34
|
+
columns.map((entry) => ({
|
|
35
|
+
column: entry.column,
|
|
36
|
+
path: columnEntryPath(entry).split("/"),
|
|
37
|
+
})),
|
|
38
|
+
{ column: STATIC_PLACEHOLDER, path: [STATIC_PLACEHOLDER] },
|
|
39
|
+
);
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
export async function generateMetadata({
|
|
@@ -77,7 +81,10 @@ export default async function ColumnEntryPage({
|
|
|
77
81
|
}) {
|
|
78
82
|
const { column, path } = await params;
|
|
79
83
|
const entry = findColumnEntry(column, path.join("/"));
|
|
80
|
-
if (!entry)
|
|
84
|
+
if (!entry) {
|
|
85
|
+
if (isStaticPlaceholder(column)) return null;
|
|
86
|
+
notFound();
|
|
87
|
+
}
|
|
81
88
|
const series = columns.filter((item) => item.column === column);
|
|
82
89
|
const listed = [...series].sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
|
|
83
90
|
const currentIndex = series.findIndex((item) => item.path === entry.path);
|
package/app/layout.tsx
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
} from "./lib/site";
|
|
13
13
|
import { githubUrl, siteConfig } from "./site.config";
|
|
14
14
|
import { brand } from "@garden/site-config";
|
|
15
|
-
import { HASH_JUMP_SCRIPT } from "../ui/garden/hash-jump.mjs";
|
|
16
15
|
import { FEED_ALTERNATES } from "./lib/page-alternates";
|
|
17
16
|
|
|
18
17
|
export async function generateMetadata(): Promise<Metadata> {
|
|
@@ -84,8 +83,6 @@ export default function RootLayout({
|
|
|
84
83
|
'(function(){try{var t=localStorage.getItem("theme");var d=t==="dark"||(!t&&matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.dataset.theme=d?"dark":"light";}catch(e){}})();',
|
|
85
84
|
}}
|
|
86
85
|
/>
|
|
87
|
-
{/* Before the router's scripts: keeps #links in a post from refetching the page (ui/garden/hash-jump.mjs). */}
|
|
88
|
-
<script dangerouslySetInnerHTML={{ __html: HASH_JUMP_SCRIPT }} />
|
|
89
86
|
{mentionEndpoint && <link rel="webmention" href={mentionEndpoint} />}
|
|
90
87
|
{analyticsDomain && (
|
|
91
88
|
<script
|
package/app/styles/a2.css
CHANGED
|
@@ -466,7 +466,7 @@ html[data-theme="dark"] .markdown-body a.ext {
|
|
|
466
466
|
html[data-theme="dark"] .markdown-body img.fav {
|
|
467
467
|
padding: 1px;
|
|
468
468
|
box-sizing: border-box;
|
|
469
|
-
background:
|
|
469
|
+
background: var(--image-backing);
|
|
470
470
|
}
|
|
471
471
|
|
|
472
472
|
.markdown-body .table-wrap a.ext img.fav,
|
|
@@ -694,7 +694,7 @@ html[data-theme="dark"] .markdown-image-trigger img,
|
|
|
694
694
|
html[data-theme="dark"] .markdown-image-lightbox-content img,
|
|
695
695
|
html[data-theme="dark"] .markdown-body > img,
|
|
696
696
|
html[data-theme="dark"] .markdown-body > p > img:not([class]) {
|
|
697
|
-
background:
|
|
697
|
+
background: var(--image-backing);
|
|
698
698
|
}
|
|
699
699
|
|
|
700
700
|
.markdown-body .fig {
|
package/app/styles/tokens.css
CHANGED
|
@@ -61,6 +61,9 @@
|
|
|
61
61
|
|
|
62
62
|
:root[data-theme="dark"],
|
|
63
63
|
html[data-theme="dark"] {
|
|
64
|
+
/* Light backing under article images and link icons, so dark-on-transparent
|
|
65
|
+
artwork stays legible. */
|
|
66
|
+
--image-backing: #faf9f5;
|
|
64
67
|
--paper: #1f1e1d;
|
|
65
68
|
--paper-deep: #262624;
|
|
66
69
|
--card: #262624;
|
|
@@ -14,9 +14,10 @@ import {
|
|
|
14
14
|
webmentionApiEndpoint,
|
|
15
15
|
} from "../../lib/site";
|
|
16
16
|
import { siteHref } from "../../lib/site-href";
|
|
17
|
+
import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../ui/garden/static-params.mjs";
|
|
17
18
|
|
|
18
19
|
export function generateStaticParams() {
|
|
19
|
-
return thoughts.map((thought) => ({ slug: thought.slug }));
|
|
20
|
+
return staticParams(thoughts.map((thought) => ({ slug: thought.slug })), { slug: STATIC_PLACEHOLDER });
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export async function generateMetadata({
|
|
@@ -60,7 +61,10 @@ export default async function ThoughtPage({
|
|
|
60
61
|
}) {
|
|
61
62
|
const { slug } = await params;
|
|
62
63
|
const thought = findPublishedThought(slug);
|
|
63
|
-
if (!thought)
|
|
64
|
+
if (!thought) {
|
|
65
|
+
if (isStaticPlaceholder(slug)) return null;
|
|
66
|
+
notFound();
|
|
67
|
+
}
|
|
64
68
|
const baseUrl = await getSiteUrl();
|
|
65
69
|
const pathname = siteHref(`/thoughts/${slug}`);
|
|
66
70
|
|
package/bin/garden.mjs
CHANGED
|
@@ -391,8 +391,14 @@ switch (command) {
|
|
|
391
391
|
// Sizes of remote files in garden file blocks; failures fall back to file-sizes.json.
|
|
392
392
|
if (process.env.GARDEN_OFFLINE !== "1") runNode("scripts/file-sizes.mjs");
|
|
393
393
|
runVinext(["build", ...rest], { copyDist: true });
|
|
394
|
+
// /favicon.ico from the site's own icon (brand.favicon).
|
|
395
|
+
runNode("scripts/favicon-ico.mjs");
|
|
394
396
|
// Pages for old addresses listed under redirects in site.config.yaml (static export only).
|
|
395
|
-
if (process.env.STATIC_EXPORT === "1")
|
|
397
|
+
if (process.env.STATIC_EXPORT === "1") {
|
|
398
|
+
// What the export wrote for empty dynamic routes (ui/garden/static-params.mjs).
|
|
399
|
+
runNode("scripts/static-placeholders.mjs");
|
|
400
|
+
runNode("scripts/redirects.mjs");
|
|
401
|
+
}
|
|
396
402
|
break;
|
|
397
403
|
case "links":
|
|
398
404
|
runNode("scripts/link-assets.mjs");
|
package/next.config.ts
CHANGED
|
@@ -10,6 +10,11 @@ const nextConfig: NextConfig = {
|
|
|
10
10
|
: {}),
|
|
11
11
|
// Always on in a static export; a server build opts in with TRAILING_SLASH=1. Links follow it (siteHref).
|
|
12
12
|
trailingSlash: trailingSlashEnabled(process.env),
|
|
13
|
+
// vinext 1.x, like Next.js, streams page metadata after the document shell to
|
|
14
|
+
// clients that run JavaScript: title, canonical, RSS and robots would sit at the
|
|
15
|
+
// end of <body> until a script moves them. Treating every user agent as
|
|
16
|
+
// HTML-limited keeps them in <head> for everyone, as with vinext 0.0.x.
|
|
17
|
+
htmlLimitedBots: /.*/,
|
|
13
18
|
};
|
|
14
19
|
|
|
15
20
|
export default nextConfig;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hd-garden",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
|
|
6
6
|
"license": "MIT",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
"@phosphor-icons/react": "^2.1.10",
|
|
78
78
|
"@tailwindcss/postcss": "4.3.3",
|
|
79
79
|
"@vitejs/plugin-react": "6.1.2",
|
|
80
|
-
"@vitejs/plugin-rsc": "0.5.
|
|
80
|
+
"@vitejs/plugin-rsc": "0.5.38",
|
|
81
81
|
"acorn": "^8.19.0",
|
|
82
82
|
"fuse.js": "^7.5.0",
|
|
83
83
|
"github-slugger": "^2.0.0",
|
|
@@ -93,10 +93,10 @@
|
|
|
93
93
|
"micromark-extension-mdx-expression": "^3.0.1",
|
|
94
94
|
"micromark-extension-mdx-jsx": "^3.0.2",
|
|
95
95
|
"next": "16.3.0",
|
|
96
|
-
"react": "19.3.
|
|
97
|
-
"react-dom": "19.3.
|
|
96
|
+
"react": "19.3.1",
|
|
97
|
+
"react-dom": "19.3.1",
|
|
98
98
|
"react-markdown": "^10.1.0",
|
|
99
|
-
"react-server-dom-webpack": "19.3.
|
|
99
|
+
"react-server-dom-webpack": "19.3.1",
|
|
100
100
|
"rehype-highlight": "^7.0.2",
|
|
101
101
|
"rehype-katex": "^7.0.1",
|
|
102
102
|
"rehype-raw": "^7.0.0",
|
|
@@ -108,11 +108,12 @@
|
|
|
108
108
|
"remark-math": "^6.0.0",
|
|
109
109
|
"remark-parse": "^11.0.0",
|
|
110
110
|
"remark-rehype": "^11.1.2",
|
|
111
|
+
"sharp": "^0.34.5",
|
|
111
112
|
"tailwindcss": "4.2.1",
|
|
112
113
|
"typescript": "5.9.3",
|
|
113
114
|
"unified": "^11.0.5",
|
|
114
115
|
"unist-util-visit": "^5.1.0",
|
|
115
|
-
"vinext": "
|
|
116
|
+
"vinext": "1.2.1",
|
|
116
117
|
"vite": "8.0.13",
|
|
117
118
|
"wrangler": "4.116.0",
|
|
118
119
|
"yaml": "^2.9.0"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* /favicon.ico for browsers, feed readers and crawlers that ask for it
|
|
4
|
+
* regardless of <link rel="icon">. Made from the site's own icon (`brand.favicon`
|
|
5
|
+
* in site.config.yaml, Garden's favicon.svg when unset): 16, 32 and 48 px PNG
|
|
6
|
+
* images in one ICO file. Runs after the build, into dist/client.
|
|
7
|
+
*
|
|
8
|
+
* A favicon.ico the site already has in public/ is left alone. An icon given
|
|
9
|
+
* as a full URL, or a file that is missing or cannot be read, only prints a
|
|
10
|
+
* warning: the page itself still names its icon.
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
import { parse as parseYaml } from "yaml";
|
|
16
|
+
import { resolveBrand } from "./brand.mjs";
|
|
17
|
+
|
|
18
|
+
export const ICO_SIZES = [16, 32, 48];
|
|
19
|
+
|
|
20
|
+
/** An ICO file holding one PNG image per size (Windows Vista and every current browser read PNG entries). */
|
|
21
|
+
export function icoFromPngs(images) {
|
|
22
|
+
const header = Buffer.alloc(6);
|
|
23
|
+
header.writeUInt16LE(0, 0);
|
|
24
|
+
header.writeUInt16LE(1, 2);
|
|
25
|
+
header.writeUInt16LE(images.length, 4);
|
|
26
|
+
const entries = [];
|
|
27
|
+
let offset = 6 + 16 * images.length;
|
|
28
|
+
for (const { size, png } of images) {
|
|
29
|
+
const entry = Buffer.alloc(16);
|
|
30
|
+
entry.writeUInt8(size >= 256 ? 0 : size, 0);
|
|
31
|
+
entry.writeUInt8(size >= 256 ? 0 : size, 1);
|
|
32
|
+
entry.writeUInt8(0, 2);
|
|
33
|
+
entry.writeUInt8(0, 3);
|
|
34
|
+
entry.writeUInt16LE(1, 4);
|
|
35
|
+
entry.writeUInt16LE(32, 6);
|
|
36
|
+
entry.writeUInt32LE(png.length, 8);
|
|
37
|
+
entry.writeUInt32LE(offset, 12);
|
|
38
|
+
offset += png.length;
|
|
39
|
+
entries.push(entry);
|
|
40
|
+
}
|
|
41
|
+
return Buffer.concat([header, ...entries, ...images.map((image) => image.png)]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The icon file behind a brand.favicon URL: public/ first, then what the build copied. Null for remote icons. */
|
|
45
|
+
export function faviconSource(siteRoot, outDir, faviconUrl, basePath = "") {
|
|
46
|
+
if (/^(?:[a-z]+:)?\/\//iu.test(faviconUrl) || faviconUrl.startsWith("data:")) return null;
|
|
47
|
+
let local = faviconUrl.split(/[?#]/u)[0];
|
|
48
|
+
if (basePath && local.startsWith(`${basePath}/`)) local = local.slice(basePath.length);
|
|
49
|
+
local = decodeURIComponent(local).replace(/^\/+/u, "");
|
|
50
|
+
for (const root of [path.join(siteRoot, "public"), outDir]) {
|
|
51
|
+
const file = path.resolve(root, local);
|
|
52
|
+
if (file.startsWith(path.resolve(root) + path.sep) && fs.existsSync(file) && fs.statSync(file).isFile()) return file;
|
|
53
|
+
}
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Writes outDir/favicon.ico from the source icon. Returns what happened, for the log and the tests. */
|
|
58
|
+
export async function writeFaviconIco({ source, outDir, siteRoot }) {
|
|
59
|
+
const target = path.join(outDir, "favicon.ico");
|
|
60
|
+
if (siteRoot && fs.existsSync(path.join(siteRoot, "public", "favicon.ico"))) return { status: "kept", target };
|
|
61
|
+
if (/\.ico$/iu.test(source)) {
|
|
62
|
+
fs.copyFileSync(source, target);
|
|
63
|
+
return { status: "copied", target };
|
|
64
|
+
}
|
|
65
|
+
const { default: sharp } = await import("sharp");
|
|
66
|
+
const input = fs.readFileSync(source);
|
|
67
|
+
const images = [];
|
|
68
|
+
for (const size of ICO_SIZES) {
|
|
69
|
+
// SVG is rasterised at the target size, not scaled down from a bitmap.
|
|
70
|
+
const density = /\.svg$/iu.test(source) ? Math.max(72, Math.ceil((72 * size) / 16)) : undefined;
|
|
71
|
+
const png = await sharp(input, density ? { density } : {})
|
|
72
|
+
.resize(size, size, { fit: "contain", background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
73
|
+
.png()
|
|
74
|
+
.toBuffer();
|
|
75
|
+
images.push({ size, png });
|
|
76
|
+
}
|
|
77
|
+
fs.writeFileSync(target, icoFromPngs(images));
|
|
78
|
+
return { status: "written", target, sizes: ICO_SIZES };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runFaviconStep({ siteRoot, env = process.env, out = process.stdout }) {
|
|
82
|
+
const outDir = path.join(siteRoot, "dist", "client");
|
|
83
|
+
const warn = (line) => out.write(`警告:${line}\n`);
|
|
84
|
+
if (!fs.existsSync(outDir)) {
|
|
85
|
+
warn(`找不到构建产物 ${path.relative(siteRoot, outDir)},没有生成 favicon.ico。`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
const configFile = path.join(siteRoot, "site.config.yaml");
|
|
89
|
+
const config = fs.existsSync(configFile) ? parseYaml(fs.readFileSync(configFile, "utf8")) ?? {} : {};
|
|
90
|
+
const basePath = (env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/u, "");
|
|
91
|
+
const { favicon } = resolveBrand(siteRoot, config, basePath);
|
|
92
|
+
const source = faviconSource(siteRoot, outDir, favicon, basePath);
|
|
93
|
+
if (source === null) {
|
|
94
|
+
warn(`brand.favicon 是外部地址 ${favicon},没有生成 favicon.ico。`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
if (!source) {
|
|
98
|
+
warn(`找不到图标文件 ${favicon},没有生成 favicon.ico。`);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
const result = await writeFaviconIco({ source, outDir, siteRoot });
|
|
103
|
+
if (result.status === "kept") out.write("favicon.ico:使用 public/favicon.ico。\n");
|
|
104
|
+
else out.write(`favicon.ico:由 ${path.relative(siteRoot, source)} 生成${result.sizes ? `(${result.sizes.join("、")} px)` : ""}。\n`);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
warn(`favicon.ico 生成失败(${error.message}),页面仍使用 ${favicon}。`);
|
|
107
|
+
}
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
112
|
+
const siteRoot = process.env.GARDEN_SITE ? path.resolve(process.env.GARDEN_SITE) : process.cwd();
|
|
113
|
+
process.exitCode = await runFaviconStep({ siteRoot });
|
|
114
|
+
}
|
|
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { STATIC_PLACEHOLDER } from "../ui/garden/static-params.mjs";
|
|
5
6
|
|
|
6
7
|
const forbiddenDirectoryNames = new Set([
|
|
7
8
|
".git",
|
|
@@ -55,6 +56,7 @@ async function inspectTree(root, directory = root) {
|
|
|
55
56
|
fail(`不允许源码目录:${relativePath}`);
|
|
56
57
|
}
|
|
57
58
|
if (entry.name.startsWith(".env")) fail(`不允许环境文件:${relativePath}`);
|
|
59
|
+
if (entry.name.startsWith(STATIC_PLACEHOLDER)) fail(`空动态路由的占位页没有删掉:${relativePath}`);
|
|
58
60
|
if (/^package(?:-lock)?\.json$/u.test(entry.name)) {
|
|
59
61
|
fail(`不允许依赖清单:${relativePath}`);
|
|
60
62
|
}
|
|
@@ -99,20 +101,44 @@ async function listFiles(root, directory = root) {
|
|
|
99
101
|
return files;
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
// The deployment ID vinext writes into pages and payloads, and bakes into the
|
|
105
|
+
// client bundle as CLIENT_DEPLOYMENT_VERSION (vinext 1.x compares the two on
|
|
106
|
+
// every client navigation and loads the whole page when they differ).
|
|
107
|
+
const DEPLOYMENT_ID_SOURCES = [".html", ".rsc", ".txt"];
|
|
108
|
+
const DEPLOYMENT_ID_CARRIERS = [".html", ".rsc", ".txt", ".js", ".mjs", ".json"];
|
|
109
|
+
|
|
110
|
+
function buildDeploymentVersions(text) {
|
|
111
|
+
const found = new Set();
|
|
112
|
+
for (const match of text.matchAll(/"deploymentVersion":"([^"]+)"/gu)) found.add(match[1]);
|
|
113
|
+
for (const match of text.matchAll(/\\["]deploymentVersion\\["]:\\["]([^"\\]+)\\["]/gu)) found.add(match[1]);
|
|
114
|
+
found.delete(deploymentVersionPlaceholder);
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
|
|
102
118
|
async function stabilizeDeploymentVersion(root) {
|
|
103
119
|
const hash = crypto.createHash("sha256");
|
|
104
120
|
const normalizedFiles = new Map();
|
|
121
|
+
const files = await listFiles(root);
|
|
122
|
+
|
|
123
|
+
// The build's own IDs, read from the pages and payloads.
|
|
124
|
+
const versions = new Set();
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
if (!DEPLOYMENT_ID_SOURCES.includes(path.extname(file.relativePath))) continue;
|
|
127
|
+
for (const version of buildDeploymentVersions(await fs.readFile(file.absolutePath, "utf8"))) versions.add(version);
|
|
128
|
+
}
|
|
105
129
|
|
|
106
|
-
for (const file of
|
|
130
|
+
for (const file of files) {
|
|
107
131
|
const original = await fs.readFile(file.absolutePath);
|
|
108
132
|
let normalized = original;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
);
|
|
115
|
-
|
|
133
|
+
const extension = path.extname(file.relativePath);
|
|
134
|
+
// Flight payloads: .rsc from vinext 0.0.x, index.txt / <page>.txt from vinext 1.x static export.
|
|
135
|
+
// The same ID also sits in the client JavaScript, so every copy gets the same stable value.
|
|
136
|
+
if (DEPLOYMENT_ID_CARRIERS.includes(extension)) {
|
|
137
|
+
let text = original.toString("utf8");
|
|
138
|
+
if (DEPLOYMENT_ID_SOURCES.includes(extension)) text = normalizeDeploymentVersion(text, deploymentVersionPlaceholder);
|
|
139
|
+
for (const version of versions) text = text.replaceAll(version, deploymentVersionPlaceholder);
|
|
140
|
+
normalized = Buffer.from(text, "utf8");
|
|
141
|
+
if (DEPLOYMENT_ID_SOURCES.includes(extension) || !normalized.equals(original)) normalizedFiles.set(file.absolutePath, normalized);
|
|
116
142
|
}
|
|
117
143
|
hash.update(file.relativePath);
|
|
118
144
|
hash.update("\0");
|
|
@@ -130,7 +156,7 @@ async function stabilizeDeploymentVersion(root) {
|
|
|
130
156
|
].join("-");
|
|
131
157
|
|
|
132
158
|
for (const [filePath, content] of normalizedFiles) {
|
|
133
|
-
const stabilized =
|
|
159
|
+
const stabilized = content.toString("utf8").replaceAll(deploymentVersionPlaceholder, version);
|
|
134
160
|
await fs.writeFile(filePath, stabilized);
|
|
135
161
|
}
|
|
136
162
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Deletes what the static export wrote for STATIC_PLACEHOLDER (see
|
|
4
|
+
* ui/garden/static-params.mjs): the directory or file of every path segment
|
|
5
|
+
* named after it. Runs after the build, on dist/client.
|
|
6
|
+
*
|
|
7
|
+
* Also deletes 404/index.html when it is a copy of 404.html: with trailingSlash
|
|
8
|
+
* vinext writes both, and on a static host the copy makes /404/ answer 200.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { STATIC_PLACEHOLDER } from "../ui/garden/static-params.mjs";
|
|
14
|
+
|
|
15
|
+
/** Removes placeholder output under root and returns the removed paths, relative to root. */
|
|
16
|
+
export function removeStaticPlaceholders(root) {
|
|
17
|
+
const removed = [];
|
|
18
|
+
const visit = (directory) => {
|
|
19
|
+
if (!fs.existsSync(directory)) return;
|
|
20
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
21
|
+
const absolute = path.join(directory, entry.name);
|
|
22
|
+
if (entry.name === STATIC_PLACEHOLDER || entry.name.startsWith(`${STATIC_PLACEHOLDER}.`)) {
|
|
23
|
+
fs.rmSync(absolute, { recursive: true, force: true });
|
|
24
|
+
removed.push(path.relative(root, absolute).split(path.sep).join("/"));
|
|
25
|
+
} else if (entry.isDirectory()) {
|
|
26
|
+
visit(absolute);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
visit(root);
|
|
31
|
+
return removed.sort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Deletes 404/index.html (and the then empty 404/) when it is byte-for-byte 404.html. Returns whether it did. */
|
|
35
|
+
export function removeNotFoundCopy(root) {
|
|
36
|
+
const page = path.join(root, "404.html");
|
|
37
|
+
const copy = path.join(root, "404", "index.html");
|
|
38
|
+
if (!fs.existsSync(page) || !fs.existsSync(copy) || !fs.readFileSync(page).equals(fs.readFileSync(copy))) return false;
|
|
39
|
+
fs.rmSync(copy);
|
|
40
|
+
if (!fs.readdirSync(path.dirname(copy)).length) fs.rmdirSync(path.dirname(copy));
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
45
|
+
const siteRoot = process.env.GARDEN_SITE ? path.resolve(process.env.GARDEN_SITE) : process.cwd();
|
|
46
|
+
removeStaticPlaceholders(path.join(siteRoot, "dist", "client"));
|
|
47
|
+
removeNotFoundCopy(path.join(siteRoot, "dist", "client"));
|
|
48
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vinext, like Next.js, stops a static export (`output: "export"`) when a
|
|
3
|
+
* dynamic route's generateStaticParams() returns an empty array. A garden site
|
|
4
|
+
* can legitimately have no thoughts, no columns or no admin pages to export, so
|
|
5
|
+
* an empty list becomes one placeholder entry instead. The prerender counts a
|
|
6
|
+
* notFound() page as a failed route, so the page renders nothing for the
|
|
7
|
+
* placeholder (isStaticPlaceholder) and the build step
|
|
8
|
+
* scripts/static-placeholders.mjs deletes what the export wrote for it.
|
|
9
|
+
*/
|
|
10
|
+
export const STATIC_PLACEHOLDER = "__garden_static_placeholder__";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @template T
|
|
14
|
+
* @param {T[]} params what the route really has
|
|
15
|
+
* @param {T} placeholder the same shape with every value set to STATIC_PLACEHOLDER
|
|
16
|
+
* @returns {T[]}
|
|
17
|
+
*/
|
|
18
|
+
export function staticParams(params, placeholder) {
|
|
19
|
+
return params.length === 0 && process.env.STATIC_EXPORT === "1" ? [placeholder] : params;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** True for the placeholder value, or a catch-all path that starts with it. */
|
|
23
|
+
export function isStaticPlaceholder(value) {
|
|
24
|
+
return value === STATIC_PLACEHOLDER || (Array.isArray(value) && value[0] === STATIC_PLACEHOLDER);
|
|
25
|
+
}
|