hd-garden 0.3.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/.agents/skills/garden-interactive-chart/SKILL.md +84 -0
- package/.agents/skills/garden-interactive-chart/references/shell.html +49 -0
- package/.agents/skills/garden-interactive-chart/references/style.md +61 -0
- package/.env.example +56 -0
- package/.openai/hosting.json +5 -0
- package/LICENSE +21 -0
- package/Makefile +48 -0
- package/README.md +72 -0
- package/app/about/page.tsx +60 -0
- package/app/admin/page.tsx +117 -0
- package/app/api/admin/distribution/route.ts +120 -0
- package/app/api/admin/posts/pin/route.ts +32 -0
- package/app/api/admin/thoughts/[id]/route.ts +44 -0
- package/app/api/admin/thoughts/route.ts +120 -0
- package/app/api/admin/uploads/route.ts +20 -0
- package/app/api/auth/github/callback/route.ts +136 -0
- package/app/api/auth/github/route.ts +29 -0
- package/app/api/auth/logout/route.ts +13 -0
- package/app/blog/[...path]/page.tsx +135 -0
- package/app/blog/page.tsx +139 -0
- package/app/chatgpt-auth.ts +86 -0
- package/app/columns/[column]/[...path]/page.tsx +160 -0
- package/app/columns/page.tsx +68 -0
- package/app/components/AdminDashboard.tsx +410 -0
- package/app/components/Comments.tsx +55 -0
- package/app/components/ContentEmptyState.tsx +51 -0
- package/app/components/ExternalEmbed.tsx +127 -0
- package/app/components/GardenAdminGuide.tsx +116 -0
- package/app/components/GardenContentGuide.tsx +241 -0
- package/app/components/GardenMediaGuide.tsx +81 -0
- package/app/components/GiscusComments.tsx +71 -0
- package/app/components/GithubLiveOverview.tsx +118 -0
- package/app/components/GithubProfile.tsx +326 -0
- package/app/components/MarkdownArticle.tsx +144 -0
- package/app/components/MermaidDiagram.tsx +117 -0
- package/app/components/PageIntro.tsx +32 -0
- package/app/components/SiteChrome.tsx +18 -0
- package/app/components/SiteFooter.tsx +29 -0
- package/app/components/SiteHeader.tsx +420 -0
- package/app/components/TableOfContents.tsx +433 -0
- package/app/components/ThoughtCard.tsx +242 -0
- package/app/components/Webmentions.tsx +194 -0
- package/app/components/WidgetChart.tsx +222 -0
- package/app/garden/page.tsx +284 -0
- package/app/garden-modules.d.ts +62 -0
- package/app/globals.css +4454 -0
- package/app/layout.tsx +102 -0
- package/app/lib/admin-auth.ts +134 -0
- package/app/lib/content.ts +112 -0
- package/app/lib/fallback-site-config.ts +56 -0
- package/app/lib/generated-content.ts +380 -0
- package/app/lib/github-content.ts +244 -0
- package/app/lib/markdown-plugins.ts +13 -0
- package/app/lib/oss.ts +100 -0
- package/app/lib/public-thoughts.ts +9 -0
- package/app/lib/repository-path.mjs +32 -0
- package/app/lib/site.ts +51 -0
- package/app/page.tsx +119 -0
- package/app/robots.ts +15 -0
- package/app/rss.xml/route.ts +84 -0
- package/app/site.config.ts +35 -0
- package/app/sitemap.ts +46 -0
- package/app/thoughts/[slug]/page.tsx +79 -0
- package/app/thoughts/page.tsx +33 -0
- package/app/yaml.d.ts +4 -0
- package/bin/garden.mjs +348 -0
- package/build/sites-vite-plugin.ts +39 -0
- package/eslint.config.mjs +20 -0
- package/next.config.ts +13 -0
- package/package.json +112 -0
- package/postcss.config.mjs +7 -0
- package/public/favicon.svg +4 -0
- package/public/images/garden/admin-studio.png +0 -0
- package/public/og.png +0 -0
- package/scripts/build-content.mjs +582 -0
- package/scripts/fetch-embed.mjs +189 -0
- package/scripts/garden-paths.mjs +21 -0
- package/scripts/new-post.mjs +73 -0
- package/scripts/prepare-pages-output.mjs +182 -0
- package/scripts/send-webmentions.mjs +27 -0
- package/site.config.yaml +48 -0
- package/templates/site/Makefile +46 -0
- package/templates/site/README.md +13 -0
- package/templates/site/content/columns.yaml +1 -0
- package/templates/site/content/posts/.gitkeep +0 -0
- package/templates/site/content/thoughts/.gitkeep +0 -0
- package/templates/site/package.json +19 -0
- package/templates/site/site.config.yaml +46 -0
- package/tsconfig.json +34 -0
- package/vite.config.ts +130 -0
- package/worker/index.ts +53 -0
package/app/lib/oss.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
|
|
3
|
+
type OssConfig = {
|
|
4
|
+
accessKeyId: string;
|
|
5
|
+
accessKeySecret: string;
|
|
6
|
+
bucket: string;
|
|
7
|
+
endpoint: string;
|
|
8
|
+
publicBaseUrl: string;
|
|
9
|
+
prefix: string;
|
|
10
|
+
maxUploadBytes: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function normalizedUrl(value: string) {
|
|
14
|
+
return value.replace(/\/+$/u, "");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getOssConfig(): OssConfig | null {
|
|
18
|
+
const accessKeyId = process.env.OSS_ACCESS_KEY_ID;
|
|
19
|
+
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET;
|
|
20
|
+
const bucket = process.env.OSS_BUCKET;
|
|
21
|
+
const endpoint = process.env.OSS_ENDPOINT;
|
|
22
|
+
if (!accessKeyId || !accessKeySecret || !bucket || !endpoint) return null;
|
|
23
|
+
|
|
24
|
+
const endpointUrl = new URL(endpoint.includes("://") ? endpoint : `https://${endpoint}`);
|
|
25
|
+
const uploadEndpoint = `${endpointUrl.protocol}//${bucket}.${endpointUrl.host}`;
|
|
26
|
+
const publicBaseUrl = normalizedUrl(process.env.OSS_PUBLIC_BASE_URL ?? uploadEndpoint);
|
|
27
|
+
const prefix = (process.env.OSS_PREFIX ?? "blog-media").replace(/^\/+|\/+$/gu, "");
|
|
28
|
+
const configuredMegabytes = Number(process.env.OSS_MAX_UPLOAD_MB ?? "200");
|
|
29
|
+
const maxUploadBytes = Number.isFinite(configuredMegabytes) && configuredMegabytes > 0
|
|
30
|
+
? Math.floor(configuredMegabytes * 1024 * 1024)
|
|
31
|
+
: 200 * 1024 * 1024;
|
|
32
|
+
return { accessKeyId, accessKeySecret, bucket, endpoint: uploadEndpoint, publicBaseUrl, prefix, maxUploadBytes };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isOssConfigured() {
|
|
36
|
+
return Boolean(getOssConfig());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function base64(bytes: Uint8Array) {
|
|
40
|
+
return btoa(String.fromCharCode(...bytes));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function signPolicy(policy: string, secret: string) {
|
|
44
|
+
const key = await crypto.subtle.importKey(
|
|
45
|
+
"raw",
|
|
46
|
+
encoder.encode(secret),
|
|
47
|
+
{ name: "HMAC", hash: "SHA-1" },
|
|
48
|
+
false,
|
|
49
|
+
["sign"],
|
|
50
|
+
);
|
|
51
|
+
return base64(new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(policy))));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function assetKind(mime: string) {
|
|
55
|
+
if (mime.startsWith("image/")) return "images";
|
|
56
|
+
if (mime.startsWith("audio/")) return "audio";
|
|
57
|
+
if (mime.startsWith("video/")) return "video";
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extension(name: string, mime: string) {
|
|
62
|
+
const candidate = name.split(".").at(-1)?.toLowerCase();
|
|
63
|
+
if (candidate && /^[a-z0-9]{1,10}$/u.test(candidate)) return candidate;
|
|
64
|
+
return mime.split("/")[1]?.replace(/[^a-z0-9]/giu, "").toLowerCase() || "bin";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function createOssUploadPolicy(input: { name: string; type: string; size: number }) {
|
|
68
|
+
const config = getOssConfig();
|
|
69
|
+
if (!config) throw new Error("尚未配置阿里云 OSS。请设置 OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_BUCKET 和 OSS_ENDPOINT。");
|
|
70
|
+
const kind = assetKind(input.type);
|
|
71
|
+
if (!kind) throw new Error("仅支持图片、音频和视频文件。");
|
|
72
|
+
if (!input.size || input.size > config.maxUploadBytes) {
|
|
73
|
+
throw new Error(`单个附件需小于 ${(config.maxUploadBytes / 1024 / 1024).toFixed(0)} MB。`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const fileExtension = extension(input.name, input.type);
|
|
77
|
+
const key = `${config.prefix}/thoughts/${kind}/${new Date().toISOString().slice(0, 10)}/${crypto.randomUUID()}.${fileExtension}`;
|
|
78
|
+
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
|
79
|
+
const policy = base64(encoder.encode(JSON.stringify({
|
|
80
|
+
expiration: expiresAt,
|
|
81
|
+
conditions: [
|
|
82
|
+
["content-length-range", 1, config.maxUploadBytes],
|
|
83
|
+
["eq", "$key", key],
|
|
84
|
+
["eq", "$success_action_status", "201"],
|
|
85
|
+
],
|
|
86
|
+
})));
|
|
87
|
+
const signature = await signPolicy(policy, config.accessKeySecret);
|
|
88
|
+
const encodedKey = key.split("/").map(encodeURIComponent).join("/");
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
endpoint: config.endpoint,
|
|
92
|
+
key,
|
|
93
|
+
policy,
|
|
94
|
+
signature,
|
|
95
|
+
accessKeyId: config.accessKeyId,
|
|
96
|
+
expiresAt,
|
|
97
|
+
publicUrl: `${config.publicBaseUrl}/${encodedKey}`,
|
|
98
|
+
maxUploadBytes: config.maxUploadBytes,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const invalidPostPathMessage = "博文路径不合法,无法修改置顶状态。";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Content entries store paths relative to `content/`, while GitHub's Contents
|
|
5
|
+
* API expects paths relative to the repository root. Normalize that boundary
|
|
6
|
+
* without allowing a generated or legacy path to escape `content/posts/`.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} sourcePath
|
|
9
|
+
*/
|
|
10
|
+
export function repositoryPostPath(sourcePath) {
|
|
11
|
+
const relativePath = sourcePath.startsWith("content/")
|
|
12
|
+
? sourcePath.slice("content/".length)
|
|
13
|
+
: sourcePath;
|
|
14
|
+
const segments = relativePath.split("/");
|
|
15
|
+
const fileName = segments.at(-1) ?? "";
|
|
16
|
+
const hasUnsafeSegment = segments.some((segment) => {
|
|
17
|
+
if (!segment || segment === "." || segment === ".." || segment.includes("\\") || segment.includes("\0")) {
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const decoded = decodeURIComponent(segment);
|
|
22
|
+
return decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\") || decoded.includes("\0");
|
|
23
|
+
} catch {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (segments.length < 2 || segments[0] !== "posts" || !fileName.endsWith(".md") || hasUnsafeSegment) {
|
|
29
|
+
throw new Error(invalidPostPathMessage);
|
|
30
|
+
}
|
|
31
|
+
return `content/${relativePath}`;
|
|
32
|
+
}
|
package/app/lib/site.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { headers } from "next/headers";
|
|
2
|
+
import { siteConfig } from "../site.config";
|
|
3
|
+
|
|
4
|
+
const fallbackUrl = "http://localhost:3000";
|
|
5
|
+
|
|
6
|
+
export const siteName = siteConfig.name;
|
|
7
|
+
export const siteDescription = siteConfig.description;
|
|
8
|
+
export const githubUsername = siteConfig.author.github;
|
|
9
|
+
|
|
10
|
+
const normalizeBaseUrl = (value: string) => value.replace(/\/+$/u, "");
|
|
11
|
+
|
|
12
|
+
export async function getSiteUrl() {
|
|
13
|
+
const configured = process.env.SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL;
|
|
14
|
+
if (configured || process.env.STATIC_EXPORT === "1") {
|
|
15
|
+
return normalizeBaseUrl(configured ?? fallbackUrl);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const requestHeaders = await headers();
|
|
19
|
+
const host =
|
|
20
|
+
requestHeaders.get("x-forwarded-host") ??
|
|
21
|
+
requestHeaders.get("host") ??
|
|
22
|
+
"localhost:3000";
|
|
23
|
+
const protocol =
|
|
24
|
+
requestHeaders.get("x-forwarded-proto") ??
|
|
25
|
+
(host.startsWith("localhost") ? "http" : "https");
|
|
26
|
+
return `${protocol}://${host}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function configuredSiteUrl() {
|
|
30
|
+
return normalizeBaseUrl(
|
|
31
|
+
process.env.SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL ?? fallbackUrl,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function absoluteUrl(pathname: string, baseUrl: string) {
|
|
36
|
+
return new URL(pathname, `${normalizeBaseUrl(baseUrl)}/`).toString();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function webmentionEndpoint() {
|
|
40
|
+
const explicit = process.env.WEBMENTION_ENDPOINT;
|
|
41
|
+
if (explicit) return explicit;
|
|
42
|
+
const domain = process.env.WEBMENTION_IO_DOMAIN;
|
|
43
|
+
return domain ? `https://webmention.io/${domain}/webmention` : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function webmentionApiEndpoint() {
|
|
47
|
+
const domain = process.env.WEBMENTION_IO_DOMAIN;
|
|
48
|
+
return domain
|
|
49
|
+
? `https://webmention.io/api/mentions.jf2?domain=${encodeURIComponent(domain)}`
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
package/app/page.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import {
|
|
3
|
+
ArrowRight,
|
|
4
|
+
ArrowUpRight,
|
|
5
|
+
GithubLogo,
|
|
6
|
+
PushPin,
|
|
7
|
+
} from "@phosphor-icons/react/ssr";
|
|
8
|
+
import { columnHref, columns, formatDate, postHref, posts } from "./lib/content";
|
|
9
|
+
import { ThoughtCard } from "./components/ThoughtCard";
|
|
10
|
+
import { PageIntro } from "./components/PageIntro";
|
|
11
|
+
import { getPublishedThoughts } from "./lib/public-thoughts";
|
|
12
|
+
import { githubUrl, siteConfig } from "./site.config";
|
|
13
|
+
|
|
14
|
+
export const metadata = {
|
|
15
|
+
title: `${siteConfig.name} · ${siteConfig.tagline}`,
|
|
16
|
+
description: siteConfig.description,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default function Home() {
|
|
20
|
+
const pinned = posts.filter((post) => post.pinned);
|
|
21
|
+
const thoughts = getPublishedThoughts();
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<main className="page-shell home-page">
|
|
25
|
+
<PageIntro
|
|
26
|
+
eyebrow="HELLO, WORLD · 你好,世界"
|
|
27
|
+
title={siteConfig.pages.home.title}
|
|
28
|
+
subtitle={siteConfig.pages.home.subtitle}
|
|
29
|
+
>
|
|
30
|
+
<a
|
|
31
|
+
className="inline-cta"
|
|
32
|
+
href={githubUrl}
|
|
33
|
+
target="_blank"
|
|
34
|
+
rel="me noopener noreferrer"
|
|
35
|
+
>
|
|
36
|
+
<GithubLogo size={21} weight="fill" />
|
|
37
|
+
在 GitHub 找到我
|
|
38
|
+
<ArrowUpRight size={16} />
|
|
39
|
+
</a>
|
|
40
|
+
</PageIntro>
|
|
41
|
+
|
|
42
|
+
{pinned.length > 0 && <section className="home-section">
|
|
43
|
+
<div className="section-heading accent-heading">
|
|
44
|
+
<div>
|
|
45
|
+
<h2>置顶博文</h2>
|
|
46
|
+
</div>
|
|
47
|
+
<PushPin size={20} weight="light" aria-hidden="true" />
|
|
48
|
+
</div>
|
|
49
|
+
<div className="post-list">
|
|
50
|
+
{pinned.map((post) => (
|
|
51
|
+
<Link href={postHref(post)} key={post.path} className="post-row">
|
|
52
|
+
<time>{formatDate(post.date)}</time>
|
|
53
|
+
<span>
|
|
54
|
+
<strong>{post.title}</strong>
|
|
55
|
+
<small>{post.topic}</small>
|
|
56
|
+
</span>
|
|
57
|
+
<ArrowRight size={18} />
|
|
58
|
+
</Link>
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
</section>}
|
|
62
|
+
|
|
63
|
+
{posts.length > 0 && <section className="home-section">
|
|
64
|
+
<div className="section-heading accent-heading">
|
|
65
|
+
<div>
|
|
66
|
+
<h2>最近博文</h2>
|
|
67
|
+
</div>
|
|
68
|
+
<Link href="/blog">全部文章 →</Link>
|
|
69
|
+
</div>
|
|
70
|
+
<div className="post-list">
|
|
71
|
+
{posts.slice(0, 5).map((post) => (
|
|
72
|
+
<Link href={postHref(post)} key={post.path} className="post-row">
|
|
73
|
+
<time>{formatDate(post.date)}</time>
|
|
74
|
+
<span>
|
|
75
|
+
<strong>{post.title}</strong>
|
|
76
|
+
<small>{post.topic}</small>
|
|
77
|
+
</span>
|
|
78
|
+
<ArrowRight size={18} />
|
|
79
|
+
</Link>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</section>}
|
|
83
|
+
|
|
84
|
+
{thoughts.length > 0 && <section className="home-section">
|
|
85
|
+
<div className="section-heading accent-heading">
|
|
86
|
+
<div>
|
|
87
|
+
<h2>最近随想</h2>
|
|
88
|
+
</div>
|
|
89
|
+
<Link href="/thoughts">进入随想 →</Link>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="home-thought-list">
|
|
92
|
+
{thoughts.slice(0, 5).map((thought) => (
|
|
93
|
+
<ThoughtCard key={thought.slug} thought={thought} />
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
</section>}
|
|
97
|
+
|
|
98
|
+
{columns[0] && <section className="home-section column-preview">
|
|
99
|
+
<div className="section-heading accent-heading">
|
|
100
|
+
<div>
|
|
101
|
+
<h2>主题专栏</h2>
|
|
102
|
+
</div>
|
|
103
|
+
<Link href="/columns">全部专栏 →</Link>
|
|
104
|
+
</div>
|
|
105
|
+
<Link
|
|
106
|
+
href={columnHref(columns[0])}
|
|
107
|
+
className="column-card"
|
|
108
|
+
>
|
|
109
|
+
<div>
|
|
110
|
+
<span>连载中 · {columns.length} 篇</span>
|
|
111
|
+
<h3>{columns[0].columnTitle}</h3>
|
|
112
|
+
<p>记录一个人从想法、最小闭环到稳定发布的完整构建过程。</p>
|
|
113
|
+
</div>
|
|
114
|
+
<span className="column-number">#01</span>
|
|
115
|
+
</Link>
|
|
116
|
+
</section>}
|
|
117
|
+
</main>
|
|
118
|
+
);
|
|
119
|
+
}
|
package/app/robots.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
import { absoluteUrl, getSiteUrl } from "./lib/site";
|
|
3
|
+
|
|
4
|
+
export default async function robots(): Promise<MetadataRoute.Robots> {
|
|
5
|
+
const baseUrl = await getSiteUrl();
|
|
6
|
+
return {
|
|
7
|
+
rules: {
|
|
8
|
+
userAgent: "*",
|
|
9
|
+
allow: "/",
|
|
10
|
+
disallow: ["/api/"],
|
|
11
|
+
},
|
|
12
|
+
sitemap: absoluteUrl("/sitemap.xml", baseUrl),
|
|
13
|
+
host: baseUrl,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import { columnHref, columns, postHref, posts } from "../lib/content";
|
|
3
|
+
import { getPublishedThoughts } from "../lib/public-thoughts";
|
|
4
|
+
import { configuredSiteUrl, siteDescription, siteName } from "../lib/site";
|
|
5
|
+
|
|
6
|
+
const escapeXml = (value: string) =>
|
|
7
|
+
value
|
|
8
|
+
.replaceAll("&", "&")
|
|
9
|
+
.replaceAll("<", "<")
|
|
10
|
+
.replaceAll(">", ">")
|
|
11
|
+
.replaceAll('"', """)
|
|
12
|
+
.replaceAll("'", "'");
|
|
13
|
+
|
|
14
|
+
const cdata = (value: string) => value.replaceAll("]]>", "]]]]><![CDATA[>");
|
|
15
|
+
|
|
16
|
+
export async function GET() {
|
|
17
|
+
const origin = configuredSiteUrl();
|
|
18
|
+
const thoughts = getPublishedThoughts();
|
|
19
|
+
const entries = [
|
|
20
|
+
...posts.map((post) => ({ ...post, path: postHref(post) })),
|
|
21
|
+
...columns.map((entry) => ({
|
|
22
|
+
...entry,
|
|
23
|
+
path: columnHref(entry),
|
|
24
|
+
})),
|
|
25
|
+
...thoughts.map((thought) => ({
|
|
26
|
+
...thought,
|
|
27
|
+
description: thought.content.replace(/\s+/gu, " ").slice(0, 180),
|
|
28
|
+
path: `/thoughts/${thought.slug}`,
|
|
29
|
+
})),
|
|
30
|
+
].sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
|
|
31
|
+
|
|
32
|
+
const items = entries
|
|
33
|
+
.map((entry) => {
|
|
34
|
+
const body = marked.parse(entry.content, {
|
|
35
|
+
async: false,
|
|
36
|
+
gfm: true,
|
|
37
|
+
breaks: true,
|
|
38
|
+
});
|
|
39
|
+
const cover = entry.cover
|
|
40
|
+
? `<p><img src="${escapeXml(entry.cover)}" alt="${escapeXml(
|
|
41
|
+
entry.coverAlt ?? entry.title,
|
|
42
|
+
)}" /></p>`
|
|
43
|
+
: "";
|
|
44
|
+
return `
|
|
45
|
+
<item>
|
|
46
|
+
<title>${escapeXml(entry.title)}</title>
|
|
47
|
+
<link>${origin}${entry.path}</link>
|
|
48
|
+
<guid isPermaLink="true">${origin}${entry.path}</guid>
|
|
49
|
+
<pubDate>${new Date(entry.date).toUTCString()}</pubDate>
|
|
50
|
+
<description>${escapeXml(entry.description ?? "")}</description>
|
|
51
|
+
${(entry.tags ?? [])
|
|
52
|
+
.map((tag) => `<category>${escapeXml(tag)}</category>`)
|
|
53
|
+
.join("")}
|
|
54
|
+
<content:encoded><![CDATA[${cdata(`${cover}${body}`)}]]></content:encoded>
|
|
55
|
+
</item>`;
|
|
56
|
+
})
|
|
57
|
+
.join("");
|
|
58
|
+
const lastBuildDate = new Date(entries.reduce(
|
|
59
|
+
(latest, entry) => Math.max(latest, Date.parse(entry.updated ?? entry.date)),
|
|
60
|
+
0,
|
|
61
|
+
)).toUTCString();
|
|
62
|
+
|
|
63
|
+
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
|
64
|
+
<rss version="2.0"
|
|
65
|
+
xmlns:atom="http://www.w3.org/2005/Atom"
|
|
66
|
+
xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
|
67
|
+
<channel>
|
|
68
|
+
<title>${escapeXml(siteName)}</title>
|
|
69
|
+
<link>${origin}</link>
|
|
70
|
+
<atom:link href="${origin}/rss.xml" rel="self" type="application/rss+xml" />
|
|
71
|
+
<description>${escapeXml(siteDescription)}</description>
|
|
72
|
+
<language>zh-CN</language>
|
|
73
|
+
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
74
|
+
${items}
|
|
75
|
+
</channel>
|
|
76
|
+
</rss>`;
|
|
77
|
+
|
|
78
|
+
return new Response(xml, {
|
|
79
|
+
headers: {
|
|
80
|
+
"Content-Type": "application/rss+xml; charset=utf-8",
|
|
81
|
+
"Cache-Control": "public, max-age=3600",
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { githubUrl, siteConfig as raw } from "@garden/site-config";
|
|
2
|
+
|
|
3
|
+
export type PageCopy = {
|
|
4
|
+
title: string;
|
|
5
|
+
subtitle: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type AboutPage = PageCopy & {
|
|
9
|
+
heading?: string;
|
|
10
|
+
paragraphs?: string[];
|
|
11
|
+
quote?: string;
|
|
12
|
+
focus?: string[];
|
|
13
|
+
cta?: { href: string; label: string };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SiteConfig = {
|
|
17
|
+
name: string;
|
|
18
|
+
tagline: string;
|
|
19
|
+
description: string;
|
|
20
|
+
locale: string;
|
|
21
|
+
author: { name: string; github: string; githubBio: string; githubPinned?: string[] };
|
|
22
|
+
pages: {
|
|
23
|
+
home: PageCopy;
|
|
24
|
+
blog: PageCopy;
|
|
25
|
+
thoughts: PageCopy;
|
|
26
|
+
columns: PageCopy;
|
|
27
|
+
about: AboutPage;
|
|
28
|
+
garden: PageCopy;
|
|
29
|
+
};
|
|
30
|
+
footer: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** 站点身份来自根目录 site.config.yaml,由 garden sync 生成。 */
|
|
34
|
+
export const siteConfig = raw as SiteConfig;
|
|
35
|
+
export { githubUrl };
|
package/app/sitemap.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { MetadataRoute } from "next";
|
|
2
|
+
import { columnHref, columns, postHref, posts } from "./lib/content";
|
|
3
|
+
import { absoluteUrl, getSiteUrl } from "./lib/site";
|
|
4
|
+
import { getPublishedThoughts } from "./lib/public-thoughts";
|
|
5
|
+
|
|
6
|
+
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
|
7
|
+
const baseUrl = await getSiteUrl();
|
|
8
|
+
const thoughts = getPublishedThoughts();
|
|
9
|
+
const staticRoutes = [
|
|
10
|
+
{ path: "/", priority: 1, changeFrequency: "weekly" as const },
|
|
11
|
+
{ path: "/blog", priority: 0.9, changeFrequency: "weekly" as const },
|
|
12
|
+
{ path: "/thoughts", priority: 0.8, changeFrequency: "daily" as const },
|
|
13
|
+
{ path: "/columns", priority: 0.8, changeFrequency: "weekly" as const },
|
|
14
|
+
{ path: "/about", priority: 0.5, changeFrequency: "monthly" as const },
|
|
15
|
+
{ path: "/garden", priority: 0.4, changeFrequency: "monthly" as const },
|
|
16
|
+
].map((route) => ({
|
|
17
|
+
url: absoluteUrl(route.path, baseUrl),
|
|
18
|
+
changeFrequency: route.changeFrequency,
|
|
19
|
+
priority: route.priority,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
return [
|
|
23
|
+
...staticRoutes,
|
|
24
|
+
...posts.map((post) => ({
|
|
25
|
+
url: absoluteUrl(postHref(post), baseUrl),
|
|
26
|
+
lastModified: new Date(post.updated ?? post.date),
|
|
27
|
+
changeFrequency: "monthly" as const,
|
|
28
|
+
priority: 0.8,
|
|
29
|
+
})),
|
|
30
|
+
...columns.map((entry) => ({
|
|
31
|
+
url: absoluteUrl(
|
|
32
|
+
columnHref(entry),
|
|
33
|
+
baseUrl,
|
|
34
|
+
),
|
|
35
|
+
lastModified: new Date(entry.updated ?? entry.date),
|
|
36
|
+
changeFrequency: "monthly" as const,
|
|
37
|
+
priority: 0.75,
|
|
38
|
+
})),
|
|
39
|
+
...thoughts.map((thought) => ({
|
|
40
|
+
url: absoluteUrl(`/thoughts/${thought.slug}`, baseUrl),
|
|
41
|
+
lastModified: new Date(thought.updated ?? thought.date),
|
|
42
|
+
changeFrequency: "monthly" as const,
|
|
43
|
+
priority: 0.6,
|
|
44
|
+
})),
|
|
45
|
+
];
|
|
46
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
import { notFound } from "next/navigation";
|
|
4
|
+
import { ArrowLeft } from "@phosphor-icons/react/ssr";
|
|
5
|
+
import { Comments } from "../../components/Comments";
|
|
6
|
+
import { ThoughtCard } from "../../components/ThoughtCard";
|
|
7
|
+
import { Webmentions } from "../../components/Webmentions";
|
|
8
|
+
import { thoughts } from "../../lib/content";
|
|
9
|
+
import { findPublishedThought } from "../../lib/public-thoughts";
|
|
10
|
+
import {
|
|
11
|
+
absoluteUrl,
|
|
12
|
+
getSiteUrl,
|
|
13
|
+
webmentionApiEndpoint,
|
|
14
|
+
} from "../../lib/site";
|
|
15
|
+
|
|
16
|
+
export function generateStaticParams() {
|
|
17
|
+
return thoughts.map((thought) => ({ slug: thought.slug }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function generateMetadata({
|
|
21
|
+
params,
|
|
22
|
+
}: {
|
|
23
|
+
params: Promise<{ slug: string }>;
|
|
24
|
+
}) {
|
|
25
|
+
const { slug } = await params;
|
|
26
|
+
const thought = findPublishedThought(slug);
|
|
27
|
+
if (!thought) return {};
|
|
28
|
+
const baseUrl = await getSiteUrl();
|
|
29
|
+
const pathname = `/thoughts/${slug}`;
|
|
30
|
+
const images = Array.isArray(thought.media)
|
|
31
|
+
? thought.media.filter((item) => item.type === "image").slice(0, 4)
|
|
32
|
+
: [];
|
|
33
|
+
return {
|
|
34
|
+
title: thought.title,
|
|
35
|
+
description: thought.content.replace(/\s+/gu, " ").slice(0, 160),
|
|
36
|
+
alternates: { canonical: pathname },
|
|
37
|
+
openGraph: {
|
|
38
|
+
type: "article",
|
|
39
|
+
url: absoluteUrl(pathname, baseUrl),
|
|
40
|
+
title: thought.title,
|
|
41
|
+
description: thought.content.replace(/\s+/gu, " ").slice(0, 160),
|
|
42
|
+
publishedTime: new Date(thought.date).toISOString(),
|
|
43
|
+
images: images.map((item) => ({ url: item.src, alt: item.alt })),
|
|
44
|
+
},
|
|
45
|
+
twitter: {
|
|
46
|
+
card: images.length ? "summary_large_image" : "summary",
|
|
47
|
+
title: thought.title,
|
|
48
|
+
description: thought.content.replace(/\s+/gu, " ").slice(0, 160),
|
|
49
|
+
images: images.map((item) => item.src),
|
|
50
|
+
},
|
|
51
|
+
} satisfies Metadata;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default async function ThoughtPage({
|
|
55
|
+
params,
|
|
56
|
+
}: {
|
|
57
|
+
params: Promise<{ slug: string }>;
|
|
58
|
+
}) {
|
|
59
|
+
const { slug } = await params;
|
|
60
|
+
const thought = findPublishedThought(slug);
|
|
61
|
+
if (!thought) notFound();
|
|
62
|
+
const baseUrl = await getSiteUrl();
|
|
63
|
+
const pathname = `/thoughts/${slug}`;
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<main className="page-shell thought-detail-page">
|
|
67
|
+
<Link href="/thoughts" className="back-link">
|
|
68
|
+
<ArrowLeft size={16} />
|
|
69
|
+
返回随想
|
|
70
|
+
</Link>
|
|
71
|
+
<ThoughtCard thought={thought} showPermalink={false} />
|
|
72
|
+
<Webmentions
|
|
73
|
+
target={absoluteUrl(pathname, baseUrl)}
|
|
74
|
+
endpoint={webmentionApiEndpoint()}
|
|
75
|
+
/>
|
|
76
|
+
<Comments slug={`thought:${slug}`} />
|
|
77
|
+
</main>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getPublishedThoughts } from "../lib/public-thoughts";
|
|
2
|
+
import { ThoughtCard } from "../components/ThoughtCard";
|
|
3
|
+
import { ContentEmptyState } from "../components/ContentEmptyState";
|
|
4
|
+
import { PageIntro } from "../components/PageIntro";
|
|
5
|
+
import { siteConfig } from "../site.config";
|
|
6
|
+
|
|
7
|
+
export const metadata = {
|
|
8
|
+
title: siteConfig.pages.thoughts.title,
|
|
9
|
+
description: siteConfig.pages.thoughts.subtitle,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export default function ThoughtsPage() {
|
|
13
|
+
const thoughts = getPublishedThoughts();
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<main className="page-shell thoughts-page">
|
|
17
|
+
<PageIntro
|
|
18
|
+
eyebrow="THOUGHTS / 随想"
|
|
19
|
+
title={siteConfig.pages.thoughts.title}
|
|
20
|
+
subtitle={siteConfig.pages.thoughts.subtitle}
|
|
21
|
+
/>
|
|
22
|
+
{thoughts.length > 0 ? (
|
|
23
|
+
<div className="thought-stream">
|
|
24
|
+
{thoughts.map((thought) => (
|
|
25
|
+
<ThoughtCard key={thought.slug} thought={thought} />
|
|
26
|
+
))}
|
|
27
|
+
</div>
|
|
28
|
+
) : (
|
|
29
|
+
<ContentEmptyState kind="thoughts" />
|
|
30
|
+
)}
|
|
31
|
+
</main>
|
|
32
|
+
);
|
|
33
|
+
}
|
package/app/yaml.d.ts
ADDED