hd-garden 0.3.4 → 0.3.6
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/api/admin/thoughts/route.ts +2 -1
- package/app/blog/[...path]/page.tsx +2 -0
- package/app/columns/[column]/[...path]/page.tsx +2 -0
- package/app/components/AdminDashboard.tsx +2 -1
- package/app/components/ArticleReferences.tsx +47 -0
- package/app/components/GardenContentGuide.tsx +2 -2
- package/app/garden-modules.d.ts +1 -0
- package/app/globals.css +66 -0
- package/app/lib/content.ts +9 -3
- package/app/lib/github-content.ts +19 -5
- package/bin/garden.mjs +0 -13
- package/package.json +1 -1
- package/scripts/build-content.mjs +52 -9
- package/scripts/new-post.mjs +2 -1
- package/scripts/site-date.mjs +59 -0
- package/templates/site/Makefile +3 -3
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
listRepositoryThoughts,
|
|
5
5
|
} from "../../../lib/github-content";
|
|
6
6
|
import type { MediaItem } from "../../../lib/content";
|
|
7
|
+
import { formatAuthorDate } from "../../../../scripts/site-date.mjs";
|
|
7
8
|
|
|
8
9
|
type ThoughtPayload = {
|
|
9
10
|
title?: unknown;
|
|
@@ -108,7 +109,7 @@ export async function POST(request: Request) {
|
|
|
108
109
|
tags: parsed.value.tags,
|
|
109
110
|
media: parsed.value.media,
|
|
110
111
|
status: parsed.value.status,
|
|
111
|
-
date:
|
|
112
|
+
date: formatAuthorDate(),
|
|
112
113
|
});
|
|
113
114
|
return Response.json({ thought }, { status: 201 });
|
|
114
115
|
} catch (error) {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
postHref,
|
|
10
10
|
posts,
|
|
11
11
|
} from "../../lib/content";
|
|
12
|
+
import { ArticleReferences } from "../../components/ArticleReferences";
|
|
12
13
|
import { MarkdownArticle } from "../../components/MarkdownArticle";
|
|
13
14
|
import { TableOfContents } from "../../components/TableOfContents";
|
|
14
15
|
import { Comments } from "../../components/Comments";
|
|
@@ -114,6 +115,7 @@ export default async function PostPage({
|
|
|
114
115
|
<div className="e-content">
|
|
115
116
|
<MarkdownArticle content={post.content} />
|
|
116
117
|
</div>
|
|
118
|
+
<ArticleReferences references={post.references} />
|
|
117
119
|
<div className="article-tags">
|
|
118
120
|
{post.tags?.map((tag) => (
|
|
119
121
|
<span className="p-category" key={tag}>
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
columnHref,
|
|
11
11
|
columnEntryPath,
|
|
12
12
|
} from "../../../lib/content";
|
|
13
|
+
import { ArticleReferences } from "../../../components/ArticleReferences";
|
|
13
14
|
import { MarkdownArticle } from "../../../components/MarkdownArticle";
|
|
14
15
|
import { TableOfContents } from "../../../components/TableOfContents";
|
|
15
16
|
import { Comments } from "../../../components/Comments";
|
|
@@ -137,6 +138,7 @@ export default async function ColumnEntryPage({
|
|
|
137
138
|
<div className="e-content">
|
|
138
139
|
<MarkdownArticle content={entry.content} />
|
|
139
140
|
</div>
|
|
141
|
+
<ArticleReferences references={entry.references} />
|
|
140
142
|
{next && (
|
|
141
143
|
<Link
|
|
142
144
|
href={columnHref(next)}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
|
+
import { formatAuthorDate } from "../../scripts/site-date.mjs";
|
|
4
5
|
import { useMemo, useState } from "react";
|
|
5
6
|
import {
|
|
6
7
|
ArrowSquareOut,
|
|
@@ -239,7 +240,7 @@ export function AdminDashboard({
|
|
|
239
240
|
setThoughts((current) =>
|
|
240
241
|
current.map((thought) =>
|
|
241
242
|
thought.id === id
|
|
242
|
-
? { ...thought, status, date: status === "published" ?
|
|
243
|
+
? { ...thought, status, date: status === "published" ? formatAuthorDate() : thought.date }
|
|
243
244
|
: thought,
|
|
244
245
|
),
|
|
245
246
|
);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
type ReferenceItem = {
|
|
2
|
+
title: string;
|
|
3
|
+
url?: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function ArticleReferences({
|
|
7
|
+
references,
|
|
8
|
+
}: {
|
|
9
|
+
references?: ReferenceItem[];
|
|
10
|
+
}) {
|
|
11
|
+
if (!references?.length) return null;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<section className="article-references" aria-labelledby="article-references-heading">
|
|
15
|
+
<p id="article-references-heading">参考</p>
|
|
16
|
+
<table>
|
|
17
|
+
<tbody>
|
|
18
|
+
{references.map((item, index) => {
|
|
19
|
+
const number = index + 1;
|
|
20
|
+
return (
|
|
21
|
+
<tr key={index}>
|
|
22
|
+
<td>
|
|
23
|
+
{item.url ? (
|
|
24
|
+
<a href={item.url} tabIndex={-1} aria-hidden="true">
|
|
25
|
+
{number}
|
|
26
|
+
</a>
|
|
27
|
+
) : (
|
|
28
|
+
<span>{number}</span>
|
|
29
|
+
)}
|
|
30
|
+
</td>
|
|
31
|
+
<td>
|
|
32
|
+
{item.url ? (
|
|
33
|
+
<a href={item.url} rel="noreferrer">
|
|
34
|
+
<span>{item.title}</span>
|
|
35
|
+
</a>
|
|
36
|
+
) : (
|
|
37
|
+
<span>{item.title}</span>
|
|
38
|
+
)}
|
|
39
|
+
</td>
|
|
40
|
+
</tr>
|
|
41
|
+
);
|
|
42
|
+
})}
|
|
43
|
+
</tbody>
|
|
44
|
+
</table>
|
|
45
|
+
</section>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -26,7 +26,7 @@ function SourcePanel({ path, source, language }: SourcePanelProps) {
|
|
|
26
26
|
|
|
27
27
|
const postSource = `---
|
|
28
28
|
title: 我的第一篇文章
|
|
29
|
-
date: 2026-08-
|
|
29
|
+
date: 2026-08-03 09:00:00
|
|
30
30
|
description: 这篇文章记录我为什么重新开始写博客。
|
|
31
31
|
topic: 写作
|
|
32
32
|
tags: [Markdown, 博客]
|
|
@@ -42,7 +42,7 @@ cover: writing/first-post.jpg
|
|
|
42
42
|
|
|
43
43
|
const thoughtSource = `---
|
|
44
44
|
title: 雨后的散步
|
|
45
|
-
date: 2026-08-
|
|
45
|
+
date: 2026-08-03 18:30:00
|
|
46
46
|
tags: [生活]
|
|
47
47
|
media:
|
|
48
48
|
- type: image
|
package/app/garden-modules.d.ts
CHANGED
package/app/globals.css
CHANGED
|
@@ -2802,6 +2802,72 @@ button {
|
|
|
2802
2802
|
color: #7f8790;
|
|
2803
2803
|
}
|
|
2804
2804
|
|
|
2805
|
+
.article-references {
|
|
2806
|
+
margin-top: 64px;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
.article-references > p {
|
|
2810
|
+
margin: 0 0 14px;
|
|
2811
|
+
color: var(--accent);
|
|
2812
|
+
font-size: 12px;
|
|
2813
|
+
font-weight: 700;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
.article-references table {
|
|
2817
|
+
width: 100%;
|
|
2818
|
+
border-collapse: collapse;
|
|
2819
|
+
font-size: 12px;
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
.article-references td {
|
|
2823
|
+
padding: 0;
|
|
2824
|
+
border: 1px solid var(--line);
|
|
2825
|
+
color: var(--ink-soft);
|
|
2826
|
+
text-align: left;
|
|
2827
|
+
overflow-wrap: anywhere;
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
.article-references td:first-child {
|
|
2831
|
+
width: 1%;
|
|
2832
|
+
color: var(--accent);
|
|
2833
|
+
font-variant-numeric: tabular-nums;
|
|
2834
|
+
text-align: right;
|
|
2835
|
+
white-space: nowrap;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
.article-references td a,
|
|
2839
|
+
.article-references td > span {
|
|
2840
|
+
display: block;
|
|
2841
|
+
padding: 11px 13px;
|
|
2842
|
+
}
|
|
2843
|
+
|
|
2844
|
+
.article-references td:first-child a,
|
|
2845
|
+
.article-references td:first-child > span {
|
|
2846
|
+
padding-right: 8px;
|
|
2847
|
+
color: var(--accent);
|
|
2848
|
+
text-decoration: none;
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
.article-references td a {
|
|
2852
|
+
color: inherit;
|
|
2853
|
+
text-decoration: none;
|
|
2854
|
+
}
|
|
2855
|
+
|
|
2856
|
+
.article-references td a span {
|
|
2857
|
+
text-decoration: underline;
|
|
2858
|
+
text-decoration-color: var(--accent);
|
|
2859
|
+
text-underline-offset: 4px;
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
.article-references td a:hover span {
|
|
2863
|
+
color: var(--accent);
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
.article-references + .article-tags,
|
|
2867
|
+
.article-references + .next-entry {
|
|
2868
|
+
margin-top: 36px;
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2805
2871
|
.article-tags {
|
|
2806
2872
|
display: flex;
|
|
2807
2873
|
flex-wrap: wrap;
|
package/app/lib/content.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type MediaItem,
|
|
8
8
|
} from "@garden/generated-content";
|
|
9
9
|
import GithubSlugger from "github-slugger";
|
|
10
|
+
import { canonicalSiteDate } from "../../scripts/site-date.mjs";
|
|
10
11
|
import { toString } from "mdast-util-to-string";
|
|
11
12
|
import remarkParse from "remark-parse";
|
|
12
13
|
import { unified } from "unified";
|
|
@@ -23,13 +24,18 @@ export const columnEntryPath = (entry: Pick<ContentEntry, "column" | "path">) =>
|
|
|
23
24
|
export const columnHref = (entry: Pick<ContentEntry, "column" | "path">) =>
|
|
24
25
|
`/columns/${entry.column}/${columnEntryPath(entry)}`;
|
|
25
26
|
|
|
26
|
-
export const formatDate = (value: string, withTime = false) =>
|
|
27
|
-
|
|
27
|
+
export const formatDate = (value: string, withTime = false) => {
|
|
28
|
+
const canonical = canonicalSiteDate(value) ?? value;
|
|
29
|
+
const date = new Date(canonical);
|
|
30
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
31
|
+
return new Intl.DateTimeFormat("zh-CN", {
|
|
32
|
+
timeZone: "Asia/Shanghai",
|
|
28
33
|
year: "numeric",
|
|
29
34
|
month: "2-digit",
|
|
30
35
|
day: "2-digit",
|
|
31
36
|
...(withTime ? { hour: "2-digit", minute: "2-digit" } : {}),
|
|
32
|
-
}).format(
|
|
37
|
+
}).format(date);
|
|
38
|
+
};
|
|
33
39
|
|
|
34
40
|
/**
|
|
35
41
|
* Parses the same Markdown heading text that ReactMarkdown sends to
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import matter from "gray-matter";
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
2
3
|
import type { ContentEntry, MediaItem } from "./content";
|
|
3
4
|
import { repositoryPostPath } from "./repository-path.mjs";
|
|
5
|
+
import { canonicalSiteDate, formatAuthorDate } from "../../scripts/site-date.mjs";
|
|
6
|
+
|
|
7
|
+
const matterOptions = {
|
|
8
|
+
engines: {
|
|
9
|
+
yaml: {
|
|
10
|
+
parse: (input: string) => parseYaml(input) ?? {},
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
};
|
|
4
14
|
|
|
5
15
|
const apiBase = "https://api.github.com";
|
|
6
16
|
|
|
@@ -77,10 +87,10 @@ function fileStem(filePath: string) {
|
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
function normalizeThought(source: string, filePath: string, sha?: string): RepositoryThought | null {
|
|
80
|
-
const parsed = matter(source);
|
|
90
|
+
const parsed = matter(source, matterOptions);
|
|
81
91
|
const data = parsed.data;
|
|
82
92
|
const slug = fileStem(filePath);
|
|
83
|
-
if (typeof data.title !== "string" || typeof data.date !== "string" || !slug) {
|
|
93
|
+
if (typeof data.title !== "string" || typeof data.date !== "string" || !canonicalSiteDate(data.date) || !slug) {
|
|
84
94
|
return null;
|
|
85
95
|
}
|
|
86
96
|
if (data.slug !== undefined && data.slug !== slug) return null;
|
|
@@ -114,7 +124,7 @@ function serializeThought(thought: {
|
|
|
114
124
|
}) {
|
|
115
125
|
return matter.stringify(thought.content.trim().concat("\n"), {
|
|
116
126
|
title: thought.title,
|
|
117
|
-
date: thought.date ??
|
|
127
|
+
date: thought.date ?? formatAuthorDate(),
|
|
118
128
|
tags: thought.tags,
|
|
119
129
|
mediaType: thought.media[0]?.type ?? "text",
|
|
120
130
|
media: thought.media,
|
|
@@ -178,7 +188,11 @@ export async function listRepositoryThoughts() {
|
|
|
178
188
|
);
|
|
179
189
|
return thoughts
|
|
180
190
|
.filter((thought): thought is RepositoryThought => Boolean(thought))
|
|
181
|
-
.sort((left, right) =>
|
|
191
|
+
.sort((left, right) => {
|
|
192
|
+
const rightTime = Date.parse(canonicalSiteDate(right.date) ?? "");
|
|
193
|
+
const leftTime = Date.parse(canonicalSiteDate(left.date) ?? "");
|
|
194
|
+
return rightTime - leftTime;
|
|
195
|
+
});
|
|
182
196
|
}
|
|
183
197
|
|
|
184
198
|
export async function createRepositoryThought(input: Omit<RepositoryThought, "id" | "kind" | "sourcePath" | "draft" | "sha" | "mediaType">) {
|
|
@@ -201,7 +215,7 @@ export async function updateRepositoryThought(slug: string, status: "draft" | "p
|
|
|
201
215
|
if (!existing) throw new Error("未找到对应随想。");
|
|
202
216
|
const thought = normalizeThought(existing.source, path, existing.sha);
|
|
203
217
|
if (!thought) throw new Error("随想内容格式不正确。");
|
|
204
|
-
const source = serializeThought({ ...thought, status, date: status === "published" ?
|
|
218
|
+
const source = serializeThought({ ...thought, status, date: status === "published" ? formatAuthorDate() : thought.date });
|
|
205
219
|
const { response } = await mutateRepository(path, "PUT", {
|
|
206
220
|
message: `content: ${status === "published" ? "发布" : "撤回"}随想 ${thought.title}`,
|
|
207
221
|
content: utf8ToBase64(source),
|
package/bin/garden.mjs
CHANGED
|
@@ -170,14 +170,6 @@ function ensureSiteKit(destinationRoot = siteRoot) {
|
|
|
170
170
|
) {
|
|
171
171
|
added.push("Makefile");
|
|
172
172
|
}
|
|
173
|
-
if (
|
|
174
|
-
copyIfMissing(
|
|
175
|
-
path.join(gardenPackageRoot, "scripts", "new-post.mjs"),
|
|
176
|
-
path.join(destinationRoot, "scripts", "new-post.mjs"),
|
|
177
|
-
)
|
|
178
|
-
) {
|
|
179
|
-
added.push("scripts/new-post.mjs");
|
|
180
|
-
}
|
|
181
173
|
if (added.length) {
|
|
182
174
|
process.stdout.write(`已补齐站点文件:${added.join("、")}\n`);
|
|
183
175
|
}
|
|
@@ -251,11 +243,6 @@ function initSite() {
|
|
|
251
243
|
}
|
|
252
244
|
copyTemplate(template, destination);
|
|
253
245
|
writeEngineDependency(destination);
|
|
254
|
-
fs.mkdirSync(path.join(destination, "scripts"), { recursive: true });
|
|
255
|
-
fs.copyFileSync(
|
|
256
|
-
path.join(gardenPackageRoot, "scripts", "new-post.mjs"),
|
|
257
|
-
path.join(destination, "scripts", "new-post.mjs"),
|
|
258
|
-
);
|
|
259
246
|
process.stdout.write(`已创建站点:${destination}\n`);
|
|
260
247
|
installSkill(destination);
|
|
261
248
|
process.stdout.write("下一步:\n");
|
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import matter from "gray-matter";
|
|
5
5
|
import { marked } from "marked";
|
|
6
6
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
7
|
+
import { canonicalSiteDate } from "./site-date.mjs";
|
|
7
8
|
|
|
8
9
|
function resolveSiteRoot() {
|
|
9
10
|
if (process.env.GARDEN_SITE) return path.resolve(process.env.GARDEN_SITE);
|
|
@@ -52,15 +53,47 @@ const optionalString = (data, key, sourcePath) => {
|
|
|
52
53
|
};
|
|
53
54
|
|
|
54
55
|
const normalizeDate = (value, key, sourcePath) => {
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
!normalized ||
|
|
59
|
-
Number.isNaN(Date.parse(normalized))
|
|
60
|
-
) {
|
|
61
|
-
fail(sourcePath, `${key} 必须是有效日期`);
|
|
56
|
+
const canonical = canonicalSiteDate(value);
|
|
57
|
+
if (!canonical) {
|
|
58
|
+
fail(sourcePath, `${key} 必须是有效日期,例如 2026-09-18 14:39:00`);
|
|
62
59
|
}
|
|
63
|
-
return
|
|
60
|
+
return canonical;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const normalizeReferences = (value, sourcePath) => {
|
|
64
|
+
if (value === undefined) return undefined;
|
|
65
|
+
if (!Array.isArray(value)) fail(sourcePath, "references 必须是数组");
|
|
66
|
+
const references = value.map((item, index) => {
|
|
67
|
+
const where = `references[${index}]`;
|
|
68
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
69
|
+
fail(sourcePath, `${where} 必须是包含 title 的对象`);
|
|
70
|
+
}
|
|
71
|
+
for (const key of Object.keys(item)) {
|
|
72
|
+
if (key !== "title" && key !== "url") {
|
|
73
|
+
fail(sourcePath, `${where} 不支持 ${key}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (typeof item.title !== "string" || !item.title.trim()) {
|
|
77
|
+
fail(sourcePath, `${where} 缺少有效的 title`);
|
|
78
|
+
}
|
|
79
|
+
const title = item.title.trim();
|
|
80
|
+
if (item.url === undefined || item.url === null || item.url === "") {
|
|
81
|
+
return { title };
|
|
82
|
+
}
|
|
83
|
+
if (typeof item.url !== "string") fail(sourcePath, `${where}.url 必须是字符串`);
|
|
84
|
+
const url = item.url.trim();
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = new URL(url);
|
|
88
|
+
} catch {
|
|
89
|
+
fail(sourcePath, `${where}.url 必须是 http 或 https 链接`);
|
|
90
|
+
}
|
|
91
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
92
|
+
fail(sourcePath, `${where}.url 必须是 http 或 https 链接`);
|
|
93
|
+
}
|
|
94
|
+
return { title, url };
|
|
95
|
+
});
|
|
96
|
+
return references.length ? references : undefined;
|
|
64
97
|
};
|
|
65
98
|
|
|
66
99
|
const isExternalAsset = (value) =>
|
|
@@ -291,9 +324,12 @@ function validateAndNormalize(data, content, markdownPath, kind, sourcePath) {
|
|
|
291
324
|
const publishTime = Date.parse(publishAt ?? date);
|
|
292
325
|
if (!includeDrafts && (data.draft === true || publishTime > now)) return null;
|
|
293
326
|
delete data.coverAlt;
|
|
327
|
+
const references = normalizeReferences(data.references, sourcePath);
|
|
328
|
+
delete data.references;
|
|
294
329
|
|
|
295
330
|
return {
|
|
296
331
|
...data,
|
|
332
|
+
references,
|
|
297
333
|
title,
|
|
298
334
|
slug,
|
|
299
335
|
path: kind === "post"
|
|
@@ -335,7 +371,13 @@ function readMarkdownTree(directory, kind) {
|
|
|
335
371
|
if (!entry.name.endsWith(".md")) return [];
|
|
336
372
|
|
|
337
373
|
const raw = fs.readFileSync(fullPath, "utf8");
|
|
338
|
-
const parsed = matter(raw
|
|
374
|
+
const parsed = matter(raw, {
|
|
375
|
+
engines: {
|
|
376
|
+
yaml: {
|
|
377
|
+
parse: (input) => parseYaml(input) ?? {},
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
});
|
|
339
381
|
const sourcePath = path.relative(contentRoot, fullPath);
|
|
340
382
|
const normalized = validateAndNormalize(
|
|
341
383
|
parsed.data,
|
|
@@ -482,6 +524,7 @@ export type ContentEntry = {
|
|
|
482
524
|
draft?: boolean;
|
|
483
525
|
topic?: string;
|
|
484
526
|
tags?: string[];
|
|
527
|
+
references?: { title: string; url?: string }[];
|
|
485
528
|
pinned?: boolean;
|
|
486
529
|
readingTime?: string;
|
|
487
530
|
kind: "post" | "column" | "thought";
|
package/scripts/new-post.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { lstat, mkdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { formatAuthorDate } from "./site-date.mjs";
|
|
4
5
|
|
|
5
6
|
const root = path.resolve(process.env.GARDEN_SITE || process.cwd());
|
|
6
7
|
const option = (name) => (process.env[`GARDEN_NEW_${name}`] ?? "").trim();
|
|
7
8
|
|
|
8
9
|
async function main() {
|
|
9
10
|
const title = option("TITLE") || "新文章";
|
|
10
|
-
const date =
|
|
11
|
+
const date = formatAuthorDate();
|
|
11
12
|
const titleSlug = title.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "");
|
|
12
13
|
const slug = option("SLUG") || titleSlug || `post-${date.slice(0, 10)}-${randomUUID().slice(0, 8)}`;
|
|
13
14
|
const directory = option("DIR");
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const siteTimeZone = "Asia/Shanghai";
|
|
2
|
+
const siteTimeOffset = "+08:00";
|
|
3
|
+
const civilDatePattern =
|
|
4
|
+
/^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
5
|
+
|
|
6
|
+
const clockInShanghai = (date) => {
|
|
7
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
8
|
+
timeZone: siteTimeZone,
|
|
9
|
+
year: "numeric",
|
|
10
|
+
month: "2-digit",
|
|
11
|
+
day: "2-digit",
|
|
12
|
+
hour: "2-digit",
|
|
13
|
+
minute: "2-digit",
|
|
14
|
+
second: "2-digit",
|
|
15
|
+
hourCycle: "h23",
|
|
16
|
+
}).formatToParts(date);
|
|
17
|
+
const pick = (type) => parts.find((part) => part.type === type)?.value ?? "";
|
|
18
|
+
return {
|
|
19
|
+
year: pick("year"),
|
|
20
|
+
month: pick("month"),
|
|
21
|
+
day: pick("day"),
|
|
22
|
+
hour: pick("hour"),
|
|
23
|
+
minute: pick("minute"),
|
|
24
|
+
second: pick("second"),
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function formatAuthorDate(now = new Date()) {
|
|
29
|
+
const clock = clockInShanghai(now);
|
|
30
|
+
return `${clock.year}-${clock.month}-${clock.day} ${clock.hour}:${clock.minute}:${clock.second}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function canonicalSiteDate(value) {
|
|
34
|
+
if (value instanceof Date) {
|
|
35
|
+
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
36
|
+
}
|
|
37
|
+
if (typeof value !== "string") return null;
|
|
38
|
+
const text = value.trim();
|
|
39
|
+
const match = civilDatePattern.exec(text);
|
|
40
|
+
if (match) {
|
|
41
|
+
const [, year, month, day, hour = "00", minute = "00", second = "00"] = match;
|
|
42
|
+
const iso = `${year}-${month}-${day}T${hour}:${minute}:${second}${siteTimeOffset}`;
|
|
43
|
+
const parsed = new Date(iso);
|
|
44
|
+
if (Number.isNaN(parsed.getTime())) return null;
|
|
45
|
+
const clock = clockInShanghai(parsed);
|
|
46
|
+
if (
|
|
47
|
+
clock.year !== year ||
|
|
48
|
+
clock.month !== month ||
|
|
49
|
+
clock.day !== day ||
|
|
50
|
+
clock.hour !== hour ||
|
|
51
|
+
clock.minute !== minute ||
|
|
52
|
+
clock.second !== second
|
|
53
|
+
) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return iso;
|
|
57
|
+
}
|
|
58
|
+
return Number.isNaN(Date.parse(text)) ? null : text;
|
|
59
|
+
}
|
package/templates/site/Makefile
CHANGED
|
@@ -12,10 +12,10 @@ export GARDEN_NEW_TOPIC := $(value TOPIC)
|
|
|
12
12
|
export GARDEN_EMBED_URL := $(value URL)
|
|
13
13
|
|
|
14
14
|
help:
|
|
15
|
-
@printf "%s\n" "Garden 常用命令:" " make new
|
|
15
|
+
@printf "%s\n" "Garden 常用命令:" " make new 用引擎创建文章草稿" ' make new TITLE="文章标题" SLUG=my-post DIR=writing TOPIC="写作"' ' make embed URL="https://example.com" 拉取外部网页摘要' " make skill 安装可交互图表 skill" " make dev 安装依赖并本地预览" " make build 构建公开站点" " make update 检查、提交并触发自动发布"
|
|
16
16
|
|
|
17
|
-
new:
|
|
18
|
-
|
|
17
|
+
new: prepare
|
|
18
|
+
npx garden new
|
|
19
19
|
|
|
20
20
|
embed:
|
|
21
21
|
@npx garden embed
|