hd-garden 0.5.17 → 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/app/a2/[slug]/page.tsx +6 -2
- package/app/admin/preview/[...path]/page.tsx +5 -3
- 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 +13 -1
- 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/build-content.mjs +5 -4
- package/scripts/check-post.mjs +4 -2
- package/scripts/favicon-ico.mjs +114 -0
- package/scripts/prepare-pages-output.mjs +4 -1
- package/scripts/static-placeholders.mjs +48 -0
- package/ui/garden/markdown-destination.mjs +77 -0
- package/ui/garden/markdown-links.mjs +9 -3
- package/ui/garden/search-index.mjs +6 -2
- package/ui/garden/static-params.mjs +25 -0
- package/ui/preview/dist/garden-ui.esm.js +107 -107
- package/ui/preview/dist/garden-ui.iife.js +107 -107
- 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 (
|
|
@@ -7,6 +7,8 @@ import { AdminUiShell, isStaticExport, requireAdminPage, StaticAdminNotice } fro
|
|
|
7
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
|
+
import { formatInlineLink, replaceInlineLinks } from "../../../../ui/garden/markdown-destination.mjs";
|
|
11
|
+
import { STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
|
|
10
12
|
|
|
11
13
|
export const dynamic = process.env.STATIC_EXPORT === "1" ? "force-static" : "force-dynamic";
|
|
12
14
|
export const dynamicParams = false;
|
|
@@ -14,7 +16,7 @@ export const metadata = { title: "草稿预览", robots: { index: false, follow:
|
|
|
14
16
|
|
|
15
17
|
/** Draft preview is served by the admin worker. The public static export has no paths here. */
|
|
16
18
|
export function generateStaticParams() {
|
|
17
|
-
return [];
|
|
19
|
+
return staticParams([], { path: [STATIC_PLACEHOLDER] });
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
const isRelative = (value: string) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/iu.test(value);
|
|
@@ -22,8 +24,8 @@ const isRelative = (value: string) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/iu.test(val
|
|
|
22
24
|
/** Same-name-directory files go through the authenticated, sandboxed proxy. */
|
|
23
25
|
function proxyAssets(content: string, post: string) {
|
|
24
26
|
const proxy = (relative: string) => `/api/admin/preview/asset?post=${encodeURIComponent(post)}&path=${encodeURIComponent(relative)}`;
|
|
25
|
-
let output = content
|
|
26
|
-
isRelative(
|
|
27
|
+
let output = replaceInlineLinks(content, (link) =>
|
|
28
|
+
link.image && isRelative(link.url) ? formatInlineLink(link, proxy(link.url)) : undefined,
|
|
27
29
|
);
|
|
28
30
|
for (const fence of [...scanFences(output)].reverse()) {
|
|
29
31
|
if (fence.lang !== "widget") continue;
|
|
@@ -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,
|
|
@@ -685,6 +685,18 @@ html[data-theme="dark"] .markdown-body .markdown-alert code {
|
|
|
685
685
|
height: auto;
|
|
686
686
|
}
|
|
687
687
|
|
|
688
|
+
/* Dark mode: article images sit on the light paper colour, so a transparent
|
|
689
|
+
image (black lines on nothing, like most SVG diagrams) stays legible and an
|
|
690
|
+
opaque one looks the same. The backing is the image's own background, so it
|
|
691
|
+
takes the image's own corner radius; no padding, no filters. Favicons, link
|
|
692
|
+
card thumbnails, covers, avatars and the logo are not article images. */
|
|
693
|
+
html[data-theme="dark"] .markdown-image-trigger img,
|
|
694
|
+
html[data-theme="dark"] .markdown-image-lightbox-content img,
|
|
695
|
+
html[data-theme="dark"] .markdown-body > img,
|
|
696
|
+
html[data-theme="dark"] .markdown-body > p > img:not([class]) {
|
|
697
|
+
background: var(--image-backing);
|
|
698
|
+
}
|
|
699
|
+
|
|
688
700
|
.markdown-body .fig {
|
|
689
701
|
margin: 36px 0;
|
|
690
702
|
}
|
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.0",
|
|
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"
|
|
@@ -10,6 +10,7 @@ import { countDemoWords } from "../ui/garden/word-count.mjs";
|
|
|
10
10
|
import { referenceDomain } from "../ui/garden/references.mjs";
|
|
11
11
|
import { linkAssetsForRender } from "./link-assets.mjs";
|
|
12
12
|
import { cardUrls } from "../ui/garden/markdown-links.mjs";
|
|
13
|
+
import { formatInlineLink, replaceInlineLinks } from "../ui/garden/markdown-destination.mjs";
|
|
13
14
|
import { configuredSiteAddress, sameSiteMatcher } from "../ui/garden/same-site.mjs";
|
|
14
15
|
import { resolveBrand } from "./brand.mjs";
|
|
15
16
|
import { imageSources, readImageCache, sizeFromBytes } from "./image-sizes.mjs";
|
|
@@ -173,10 +174,10 @@ function copyLocalAsset(value, markdownPath, sourcePath, options = {}) {
|
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
function rewriteMarkdownAssets(content, markdownPath, sourcePath) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
177
|
+
// `.png>)` keeps its angle brackets: without them the space
|
|
178
|
+
// and the parentheses would no longer be one destination.
|
|
179
|
+
const markdownImages = replaceInlineLinks(content, (link) =>
|
|
180
|
+
link.image ? formatInlineLink(link, copyLocalAsset(link.url, markdownPath, sourcePath)) : undefined,
|
|
180
181
|
);
|
|
181
182
|
|
|
182
183
|
return markdownImages.replace(
|
package/scripts/check-post.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { gardenSiteRoot } from "./garden-paths.mjs";
|
|
|
7
7
|
import { checkUiBlock } from "../ui/core/validate.mjs";
|
|
8
8
|
import { checkGardenMarkdown } from "../ui/garden/check.mjs";
|
|
9
9
|
import { checkFileSources } from "../ui/garden/file-paths.mjs";
|
|
10
|
+
import { inlineLinkParts, inlineLinkPattern } from "../ui/garden/markdown-destination.mjs";
|
|
10
11
|
|
|
11
12
|
const siteRoot = gardenSiteRoot();
|
|
12
13
|
const calloutTypes = new Set([
|
|
@@ -247,8 +248,9 @@ const scanBody = (errors, content, markdownPath, sourcePath, { warnings = [], dr
|
|
|
247
248
|
error(errors, "callout-type", lineNumber, `${sourcePath}: 不支持的警告框类型 ${callout[1]}`);
|
|
248
249
|
}
|
|
249
250
|
|
|
250
|
-
for (const
|
|
251
|
-
|
|
251
|
+
for (const match of line.matchAll(inlineLinkPattern())) {
|
|
252
|
+
const image = inlineLinkParts(match);
|
|
253
|
+
if (image.image) assertLocalFile(errors, image.url, path.dirname(markdownPath), sourcePath, lineNumber, "图片");
|
|
252
254
|
}
|
|
253
255
|
});
|
|
254
256
|
|
|
@@ -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
|
}
|
|
@@ -106,7 +108,8 @@ async function stabilizeDeploymentVersion(root) {
|
|
|
106
108
|
for (const file of await listFiles(root)) {
|
|
107
109
|
const original = await fs.readFile(file.absolutePath);
|
|
108
110
|
let normalized = original;
|
|
109
|
-
|
|
111
|
+
// Flight payloads: .rsc from vinext 0.0.x, index.txt / <page>.txt from vinext 1.x static export.
|
|
112
|
+
if ([".html", ".rsc", ".txt"].includes(path.extname(file.relativePath))) {
|
|
110
113
|
const text = original.toString("utf8");
|
|
111
114
|
normalized = Buffer.from(
|
|
112
115
|
normalizeDeploymentVersion(text, deploymentVersionPlaceholder),
|
|
@@ -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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline links and images, `[label](destination "title")` and
|
|
3
|
+
* ``, read the way CommonMark reads them. A
|
|
4
|
+
* destination is either
|
|
5
|
+
* - `<…>`: anything but a line break or an unescaped `<` / `>`, so it may hold
|
|
6
|
+
* spaces and unbalanced parentheses: `.png>)`; or
|
|
7
|
+
* - a bare run without spaces, where parentheses must balance:
|
|
8
|
+
* `[w](https://en.wikipedia.org/wiki/Trie_(data_structure))`.
|
|
9
|
+
* The build, the checker, the draft preview, link cards and the search index
|
|
10
|
+
* all use this one reading, so a destination that works in the article also
|
|
11
|
+
* works everywhere else, and a rewritten destination keeps its brackets.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const ESCAPED = String.raw`\\.`;
|
|
15
|
+
const ANGLE = String.raw`<((?:[^<>\n\\]|${ESCAPED})*)>`;
|
|
16
|
+
const PLAIN = String.raw`(?:[^\s()<\\]|${ESCAPED})`;
|
|
17
|
+
const INNER = String.raw`(?:[^\s()\\]|${ESCAPED})`;
|
|
18
|
+
// Balanced parentheses, nested up to three deep.
|
|
19
|
+
const PARENS = [1, 2, 3].reduce((inner) => String.raw`\((?:${INNER}|${inner})*\)`, String.raw`\((?:${INNER})*\)`);
|
|
20
|
+
const BARE = String.raw`((?:${PLAIN}|${PARENS})(?:${INNER}|${PARENS})*)`;
|
|
21
|
+
const LABEL = String.raw`(?:[^\[\]\\]|${ESCAPED}|\[(?:[^\[\]\\]|${ESCAPED})*\])*`;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Source of the inline link pattern. Groups: 1 `!` for an image, 2 label,
|
|
25
|
+
* 3 destination written in `<…>`, 4 bare destination, 5 title (with the space
|
|
26
|
+
* before it).
|
|
27
|
+
*/
|
|
28
|
+
export const INLINE_LINK_SOURCE = String.raw`(!?)\[(${LABEL})\]\(\s*(?:${ANGLE}|${BARE})((?:\s+(?:"(?:[^"\\]|${ESCAPED})*"|'(?:[^'\\]|${ESCAPED})*'|\((?:[^()\\]|${ESCAPED})*\)))?)\s*\)`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A fresh global pattern for inline links and images (`flags` adds e.g. `i`).
|
|
32
|
+
* @param {string} [flags]
|
|
33
|
+
*/
|
|
34
|
+
export const inlineLinkPattern = (flags = "") => new RegExp(INLINE_LINK_SOURCE, `gu${flags.replace(/[gu]/gu, "")}`);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {{ whole: string, image: boolean, label: string, url: string, angle: boolean, title: string }} InlineLink
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The parts of one match of inlineLinkPattern().
|
|
42
|
+
* @param {ArrayLike<string | undefined>} match
|
|
43
|
+
* @returns {InlineLink}
|
|
44
|
+
*/
|
|
45
|
+
export function inlineLinkParts(match) {
|
|
46
|
+
const angle = match[3] !== undefined;
|
|
47
|
+
return { whole: String(match[0]), image: match[1] === "!", label: String(match[2] ?? ""), url: String((angle ? match[3] : match[4]) ?? ""), angle, title: match[5] ?? "" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A destination written back the way it was written: in `<…>` if it was.
|
|
52
|
+
* @param {string} url
|
|
53
|
+
* @param {boolean} angle
|
|
54
|
+
*/
|
|
55
|
+
export const formatDestination = (url, angle) => (angle ? `<${url}>` : url);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `[label](destination title)` or `` from parts, with a new url.
|
|
59
|
+
* @param {Pick<InlineLink, "image" | "label" | "angle" | "title">} link
|
|
60
|
+
* @param {string} url
|
|
61
|
+
*/
|
|
62
|
+
export const formatInlineLink = ({ image, label, angle, title }, url) =>
|
|
63
|
+
`${image ? "!" : ""}[${label}](${formatDestination(url, angle)}${title})`;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Replace every inline link and image. `replace(parts)` returns the new text,
|
|
67
|
+
* or undefined to leave that link as written.
|
|
68
|
+
* @param {string} text
|
|
69
|
+
* @param {(link: InlineLink) => string | undefined} replace
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
export function replaceInlineLinks(text, replace) {
|
|
73
|
+
return String(text ?? "").replace(inlineLinkPattern(), (...match) => {
|
|
74
|
+
const next = replace(inlineLinkParts(/** @type {string[]} */ (match)));
|
|
75
|
+
return next === undefined ? match[0] : next;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* External links written in a post body. Code (fenced blocks, indented blocks
|
|
3
3
|
* and inline spans) is skipped, so a URL in a snippet is never a link.
|
|
4
4
|
*/
|
|
5
|
+
import { INLINE_LINK_SOURCE } from "./markdown-destination.mjs";
|
|
5
6
|
|
|
6
7
|
const FENCE = /^ {0,3}(`{3,}|~{3,})/u;
|
|
7
8
|
|
|
@@ -47,7 +48,12 @@ function mapInline(text, visit) {
|
|
|
47
48
|
return result + visit(text.slice(index));
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
|
|
51
|
+
// Inline links read as CommonMark reads them (ui/garden/markdown-destination.mjs):
|
|
52
|
+
// `[t](<https://e.com/a (1)>)` and `[t](https://e.com/Trie_(x))` are whole addresses.
|
|
53
|
+
// Groups: 1 `!`, 3 or 4 the address, 6 `{card}`.
|
|
54
|
+
const LINK = new RegExp(`${INLINE_LINK_SOURCE}(\\{card\\})?`, "giu");
|
|
55
|
+
const linkUrl = (match) => match[3] ?? match[4];
|
|
56
|
+
const isWebUrl = (url) => /^https?:\/\//iu.test(url);
|
|
51
57
|
const AUTOLINK = /<(https?:\/\/[^\s>]+)>/giu;
|
|
52
58
|
const BARE = /(^|[\s((::,,。])(https?:\/\/[^\s<>()()\],。、]+[^\s<>()()\].,;:!?,。、;:!?'"])/giu;
|
|
53
59
|
|
|
@@ -56,7 +62,7 @@ export function markdownLinks(markdown) {
|
|
|
56
62
|
const links = [];
|
|
57
63
|
mapOutsideCode(markdown, (text) => {
|
|
58
64
|
for (const match of text.matchAll(LINK)) {
|
|
59
|
-
if (match[1] !== "!") links.push({ url: match
|
|
65
|
+
if (match[1] !== "!" && isWebUrl(linkUrl(match))) links.push({ url: linkUrl(match), card: Boolean(match[6]) });
|
|
60
66
|
}
|
|
61
67
|
const rest = text.replace(LINK, " ");
|
|
62
68
|
for (const match of rest.matchAll(AUTOLINK)) links.push({ url: match[1], card: false });
|
|
@@ -75,7 +81,7 @@ export const CARD_MARKER = "{card}";
|
|
|
75
81
|
*/
|
|
76
82
|
export function stripCardMarkers(markdown) {
|
|
77
83
|
if (!String(markdown ?? "").includes(CARD_MARKER)) return markdown;
|
|
78
|
-
return mapOutsideCode(markdown, (text) => text.replace(LINK, (
|
|
84
|
+
return mapOutsideCode(markdown, (text) => text.replace(LINK, (...match) => (match[6] && isWebUrl(linkUrl(match)) ? match[0].slice(0, -CARD_MARKER.length) : match[0])));
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
/** URLs the author asked to show as cards, in order, without repeats. */
|
|
@@ -8,13 +8,17 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import crypto from "node:crypto";
|
|
10
10
|
import { contentToSearchText } from "../core/transform.mjs";
|
|
11
|
+
import { inlineLinkPattern } from "./markdown-destination.mjs";
|
|
12
|
+
|
|
13
|
+
const INLINE_LINK = inlineLinkPattern();
|
|
11
14
|
|
|
12
15
|
/** Plain text of a post body, as search and word counts see it. */
|
|
13
16
|
export function searchableText(markdown) {
|
|
14
17
|
return contentToSearchText(markdown)
|
|
15
18
|
.replace(/```[^\n]*\n([\s\S]*?)```/g, " $1 ")
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
// Links and images leave their text; the address goes whole, even with
|
|
20
|
+
// parentheses or in <…> (ui/garden/markdown-destination.mjs).
|
|
21
|
+
.replace(INLINE_LINK, (...match) => ` ${match[2]} `)
|
|
18
22
|
.replace(/<[^>]+>/g, " ")
|
|
19
23
|
.replace(/[#>*_~`|[\]{}()]/g, " ")
|
|
20
24
|
.replace(/\s+/g, " ")
|