hd-garden 0.7.0 → 0.8.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.
Files changed (220) hide show
  1. package/.agents/skills/garden-ui/SKILL.md +26 -19
  2. package/.env.en.example +67 -0
  3. package/README.en.md +57 -0
  4. package/README.md +31 -46
  5. package/app/a2/[slug]/page.tsx +4 -3
  6. package/app/a2/page.tsx +4 -3
  7. package/app/about/page.tsx +8 -9
  8. package/app/admin/page.tsx +7 -6
  9. package/app/admin/preview/[...path]/page.tsx +2 -1
  10. package/app/admin/ui/blocks/page.tsx +2 -1
  11. package/app/admin/ui/catalog/page.tsx +2 -1
  12. package/app/admin/ui/evals/page.tsx +2 -1
  13. package/app/admin/ui/page.tsx +2 -1
  14. package/app/blog/[...path]/page.tsx +14 -5
  15. package/app/blog/page.tsx +4 -2
  16. package/app/columns/[column]/[...path]/page.tsx +17 -18
  17. package/app/columns/page.tsx +12 -8
  18. package/app/components/AdminUiShell.tsx +4 -3
  19. package/app/components/ArticlePager.tsx +32 -0
  20. package/app/components/ArticleReferences.tsx +4 -1
  21. package/app/components/ArticleTags.tsx +3 -2
  22. package/app/components/BlogArchive.tsx +20 -16
  23. package/app/components/Comments.tsx +17 -10
  24. package/app/components/ContentEmptyState.tsx +29 -28
  25. package/app/components/ExternalEmbed.tsx +15 -14
  26. package/app/components/FeedMore.tsx +70 -0
  27. package/app/components/GardenContentGuide.tsx +10 -2
  28. package/app/components/GardenDocsNav.tsx +29 -22
  29. package/app/components/GiscusComments.tsx +2 -1
  30. package/app/components/GithubLiveOverview.tsx +10 -8
  31. package/app/components/GithubProfile.tsx +31 -21
  32. package/app/components/HandbookLangRedirect.tsx +30 -0
  33. package/app/components/HandbookLangSwitch.tsx +53 -0
  34. package/app/components/HandbookView.tsx +270 -0
  35. package/app/components/MarkdownArticle.tsx +39 -9
  36. package/app/components/MarkdownImage.tsx +6 -4
  37. package/app/components/SiteChrome.tsx +11 -2
  38. package/app/components/SiteFooter.tsx +6 -4
  39. package/app/components/SiteHeader.tsx +38 -25
  40. package/app/components/TableOfContents.tsx +15 -9
  41. package/app/components/ThoughtCard.tsx +16 -14
  42. package/app/components/ThoughtFeed.tsx +41 -0
  43. package/app/components/UiBlock.tsx +3 -2
  44. package/app/components/UiLang.tsx +18 -0
  45. package/app/components/Webmentions.tsx +13 -12
  46. package/app/components/WidgetChart.tsx +8 -6
  47. package/app/components/garden/FilePreview.tsx +14 -12
  48. package/app/components/garden/GardenBlock.tsx +10 -1
  49. package/app/components/garden/GardenFile.tsx +10 -8
  50. package/app/components/garden/RefDemo.tsx +62 -0
  51. package/app/components/garden/blocks.tsx +90 -32
  52. package/app/components/garden/chrome.tsx +20 -13
  53. package/app/components/garden/patterns.tsx +497 -0
  54. package/app/garden/[...slug]/page.tsx +11 -130
  55. package/app/garden/en/[...slug]/page.tsx +28 -0
  56. package/app/garden/en/page.tsx +16 -0
  57. package/app/garden/page.tsx +8 -74
  58. package/app/garden-modules.d.ts +3 -0
  59. package/app/globals.css +10 -0
  60. package/app/layout.tsx +2 -4
  61. package/app/lib/a2-meta.ts +26 -3
  62. package/app/lib/content.ts +13 -3
  63. package/app/lib/garden-docs-meta.ts +84 -5
  64. package/app/lib/github-id.mjs +8 -0
  65. package/app/lib/handbook-path.ts +11 -0
  66. package/app/lib/handbook-switch.mjs +12 -0
  67. package/app/lib/i18n.ts +621 -0
  68. package/app/lib/page-alternates.ts +2 -2
  69. package/app/lib/rehype-field-tables.mjs +47 -0
  70. package/app/lib/remark-a2.mjs +18 -8
  71. package/app/lib/remark-link-card.mjs +22 -0
  72. package/app/lib/remark-ref-demos.mjs +42 -0
  73. package/app/lib/tag-filter.ts +9 -2
  74. package/app/not-found.tsx +34 -4
  75. package/app/page.tsx +17 -14
  76. package/app/rss.xml/route.ts +2 -1
  77. package/app/site.config.ts +1 -1
  78. package/app/styles/a2.css +439 -32
  79. package/app/styles/shell.css +21 -2
  80. package/app/tags/page.tsx +7 -5
  81. package/app/thoughts/[slug]/page.tsx +2 -1
  82. package/app/thoughts/page/[n]/page.tsx +44 -0
  83. package/app/thoughts/page.tsx +4 -7
  84. package/app/ui-parity/page.tsx +38 -22
  85. package/bin/garden.mjs +133 -19
  86. package/docs/handbook/ai-writing.md +99 -0
  87. package/docs/handbook/assets/reading-layout-en.svg +26 -0
  88. package/docs/handbook/components/card.md +43 -20
  89. package/docs/handbook/components/chart.md +10 -24
  90. package/docs/handbook/components/choice.md +42 -0
  91. package/docs/handbook/components/compare.md +34 -21
  92. package/docs/handbook/components/figure.md +33 -44
  93. package/docs/handbook/components/file.md +10 -13
  94. package/docs/handbook/components/html.md +10 -28
  95. package/docs/handbook/components/interactive.md +9 -33
  96. package/docs/handbook/components/list.md +65 -0
  97. package/docs/handbook/components/relations.md +95 -0
  98. package/docs/handbook/components/stat.md +10 -26
  99. package/docs/handbook/components/timeline.md +13 -26
  100. package/docs/handbook/components.md +210 -19
  101. package/docs/handbook/config.md +7 -5
  102. package/docs/handbook/deploy.md +2 -2
  103. package/docs/handbook/design.md +23 -15
  104. package/docs/handbook/en/ai-writing.md +99 -0
  105. package/docs/handbook/en/components/card.md +67 -0
  106. package/docs/handbook/en/components/chart.md +38 -0
  107. package/docs/handbook/en/components/choice.md +42 -0
  108. package/docs/handbook/en/components/compare.md +53 -0
  109. package/docs/handbook/en/components/figure.md +59 -0
  110. package/docs/handbook/en/components/file/checklist.md +17 -0
  111. package/docs/handbook/en/components/file.md +29 -0
  112. package/docs/handbook/en/components/html.md +49 -0
  113. package/docs/handbook/en/components/interactive.md +41 -0
  114. package/docs/handbook/en/components/list.md +65 -0
  115. package/docs/handbook/en/components/relations.md +95 -0
  116. package/docs/handbook/en/components/stat.md +39 -0
  117. package/docs/handbook/en/components/timeline.md +37 -0
  118. package/docs/handbook/en/components.md +222 -0
  119. package/docs/handbook/en/config.md +127 -0
  120. package/docs/handbook/en/deploy.md +273 -0
  121. package/docs/handbook/en/design.md +88 -0
  122. package/docs/handbook/en/index.md +192 -0
  123. package/docs/handbook/en/reference/card.md +85 -0
  124. package/docs/handbook/en/reference/chart.md +68 -0
  125. package/docs/handbook/en/reference/choice.md +69 -0
  126. package/docs/handbook/en/reference/compare.md +59 -0
  127. package/docs/handbook/en/reference/figure.md +84 -0
  128. package/docs/handbook/en/reference/file/checklist.md +17 -0
  129. package/docs/handbook/en/reference/file.md +50 -0
  130. package/docs/handbook/en/reference/html.md +62 -0
  131. package/docs/handbook/en/reference/interactive.md +71 -0
  132. package/docs/handbook/en/reference/list.md +72 -0
  133. package/docs/handbook/en/reference/relations.md +113 -0
  134. package/docs/handbook/en/reference/stat.md +60 -0
  135. package/docs/handbook/en/reference/timeline.md +56 -0
  136. package/docs/handbook/en/reference.md +26 -0
  137. package/docs/handbook/en/start.md +92 -0
  138. package/docs/handbook/en/workflow.md +82 -0
  139. package/docs/handbook/en/writing.md +157 -0
  140. package/docs/handbook/index.md +99 -4
  141. package/docs/handbook/reference/card.md +85 -0
  142. package/docs/handbook/reference/chart.md +68 -0
  143. package/docs/handbook/reference/choice.md +69 -0
  144. package/docs/handbook/reference/compare.md +59 -0
  145. package/docs/handbook/reference/figure.md +84 -0
  146. package/docs/handbook/reference/file/checklist.md +17 -0
  147. package/docs/handbook/reference/file.md +50 -0
  148. package/docs/handbook/reference/html.md +62 -0
  149. package/docs/handbook/reference/interactive.md +71 -0
  150. package/docs/handbook/reference/list.md +72 -0
  151. package/docs/handbook/reference/relations.md +113 -0
  152. package/docs/handbook/reference/stat.md +60 -0
  153. package/docs/handbook/reference/timeline.md +56 -0
  154. package/docs/handbook/reference.md +26 -0
  155. package/docs/handbook/start.md +1 -1
  156. package/docs/handbook/workflow.md +6 -5
  157. package/docs/handbook/writing.md +21 -12
  158. package/next.config.ts +6 -0
  159. package/package.json +2 -1
  160. package/public/images/garden/ref-card-book.svg +1 -0
  161. package/public/images/garden/ref-card-lamp.svg +1 -0
  162. package/scripts/build-content.mjs +57 -16
  163. package/scripts/favicon-ico.mjs +9 -6
  164. package/scripts/file-preview.mjs +10 -7
  165. package/scripts/handbook-allowlist.mjs +18 -0
  166. package/scripts/handbook-facts.mjs +339 -11
  167. package/scripts/html-lang.mjs +36 -0
  168. package/scripts/link-assets.mjs +7 -0
  169. package/scripts/new-post.mjs +18 -9
  170. package/scripts/prepare-pages-output.mjs +30 -18
  171. package/scripts/redirects.mjs +17 -6
  172. package/scripts/static-404.mjs +25 -0
  173. package/scripts/ui-cli.mjs +14 -8
  174. package/templates/garden-blocks.md +67 -0
  175. package/templates/garden-snippets.plist +61 -0
  176. package/templates/site/Makefile.en +45 -0
  177. package/templates/site/README.en.md +15 -0
  178. package/templates/site/site.config.en.yaml +55 -0
  179. package/templates/site/site.config.yaml +2 -0
  180. package/templates/skills-en/garden-interactive-chart/SKILL.md +84 -0
  181. package/templates/skills-en/garden-interactive-chart/references/shell.html +49 -0
  182. package/templates/skills-en/garden-interactive-chart/references/style.md +61 -0
  183. package/templates/skills-en/garden-post-review/SKILL.md +63 -0
  184. package/templates/skills-en/garden-ui/SKILL.md +118 -0
  185. package/templates/workflows/pages.en.yml +195 -0
  186. package/templates/workflows/pages.yml +195 -0
  187. package/ui/core/apply.mjs +22 -4
  188. package/ui/core/cli-locale.mjs +67 -0
  189. package/ui/core/fence.mjs +18 -3
  190. package/ui/core/fix-prompt.mjs +18 -15
  191. package/ui/core/garden-fence.mjs +64 -0
  192. package/ui/core/scan.mjs +19 -3
  193. package/ui/garden/adjacent.mjs +21 -0
  194. package/ui/garden/catalog.mjs +205 -4
  195. package/ui/garden/check.mjs +12 -0
  196. package/ui/garden/file-block.mjs +8 -7
  197. package/ui/garden/html-lang.mjs +88 -0
  198. package/ui/garden/markdown-links.mjs +7 -4
  199. package/ui/garden/paging.mjs +30 -0
  200. package/ui/garden/parse.mjs +45 -0
  201. package/ui/garden/relations.mjs +252 -0
  202. package/ui/garden/search-index.mjs +6 -4
  203. package/ui/garden/source-icon.mjs +54 -0
  204. package/ui/parity-fixtures.mjs +77 -0
  205. package/ui/preview/dist/garden-ui.css +9 -9
  206. package/ui/preview/dist/garden-ui.esm.js +206 -169
  207. package/ui/preview/dist/garden-ui.iife.js +206 -169
  208. package/ui/prompt/rules.md +28 -1
  209. package/ui/react/components/chart.tsx +8 -5
  210. package/ui/react/components/display.tsx +7 -4
  211. package/ui/react/components/inputs.tsx +6 -3
  212. package/ui/react/components/layout.tsx +6 -4
  213. package/ui/react/components/media.tsx +7 -4
  214. package/ui/react/components/sandbox.tsx +3 -1
  215. package/ui/react/index.tsx +11 -7
  216. package/ui/react/lang.tsx +89 -0
  217. package/ui/react/parity.tsx +8 -6
  218. package/ui/react/runtime.tsx +3 -1
  219. package/ui/react/ui.css +9 -9
  220. package/worker/index.ts +14 -1
package/app/blog/page.tsx CHANGED
@@ -4,6 +4,7 @@ import { BlogArchive, BlogArchiveFallback } from "../components/BlogArchive";
4
4
  import { posts } from "../lib/content";
5
5
  import { archivePosts, archiveSeries } from "../lib/archive";
6
6
  import { pageAlternates } from "../lib/page-alternates";
7
+ import { siteLang, ui } from "../lib/i18n";
7
8
 
8
9
  export const metadata = {
9
10
  title: siteDocumentTitle,
@@ -12,13 +13,14 @@ export const metadata = {
12
13
  };
13
14
 
14
15
  export default function BlogIndex() {
16
+ const t = ui();
15
17
  const list = archivePosts();
16
18
  const series = archiveSeries();
17
19
  return (
18
20
  <main className="sh-main" id="content">
19
21
  <div className="sh-head">
20
- <h1 id="sh-title">文章</h1>
21
- <p className="sh-deck">阶段性的完整沉淀,关于工程、架构与技术思考。共 {posts.length} 篇,按年份归档。</p>
22
+ <h1 id="sh-title">{t.blogHeading}</h1>
23
+ <p className="sh-deck">{siteLang === "en" ? t.blogDeck(posts.length) : <>阶段性的完整沉淀,关于工程、架构与技术思考。共 {posts.length} 篇,按年份归档。</>}</p>
22
24
  </div>
23
25
  <Suspense fallback={<BlogArchiveFallback posts={list} series={series} />}>
24
26
  <BlogArchive posts={list} series={series} />
@@ -1,9 +1,10 @@
1
1
  import Link from "next/link";
2
+ import { ArticlePager } from "../../../components/ArticlePager";
2
3
  import { CoverImage } from "../../../components/CoverImage";
3
4
  import type { Metadata } from "next";
4
5
  import { FEED_ALTERNATES } from "../../../lib/page-alternates";
5
6
  import { notFound } from "next/navigation";
6
- import { ArrowLeft, ArrowRight } from "@phosphor-icons/react/ssr";
7
+ import { ArrowLeft } from "@phosphor-icons/react/ssr";
7
8
  import {
8
9
  columns,
9
10
  extractHeadings,
@@ -28,6 +29,7 @@ import {
28
29
  import { githubUrl, siteConfig } from "../../../site.config";
29
30
  import { siteHref } from "../../../lib/site-href";
30
31
  import { isStaticPlaceholder, STATIC_PLACEHOLDER, staticParams } from "../../../../ui/garden/static-params.mjs";
32
+ import { columnStatusLabel, siteLang, ui } from "../../../lib/i18n";
31
33
 
32
34
  export function generateStaticParams() {
33
35
  return staticParams(
@@ -88,20 +90,22 @@ export default async function ColumnEntryPage({
88
90
  const series = columns.filter((item) => item.column === column);
89
91
  const listed = [...series].sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
90
92
  const currentIndex = series.findIndex((item) => item.path === entry.path);
91
- const next = series[currentIndex + 1];
93
+ const prev = series[currentIndex - 1] ?? null;
94
+ const next = series[currentIndex + 1] ?? null;
92
95
  const headings = extractHeadings(entry.content, { numbering: entry.numbering });
93
96
  const baseUrl = await getSiteUrl();
94
97
  const pathname = columnHref(entry);
95
98
  const canonicalUrl = absoluteUrl(pathname, baseUrl);
99
+ const t = ui();
96
100
 
97
101
  return (
98
102
  <main className="column-reading-page">
99
103
  <aside className="series-nav">
100
104
  <Link href={siteHref("/columns")} className="back-link">
101
105
  <ArrowLeft size={15} />
102
- 所有专栏
106
+ {t.allColumnsBack}
103
107
  </Link>
104
- <p>主题专栏</p>
108
+ <p>{t.seriesEyebrow}</p>
105
109
  <h2>{entry.columnTitle}</h2>
106
110
  <ol>
107
111
  {listed.map((item) => (
@@ -110,12 +114,12 @@ export default async function ColumnEntryPage({
110
114
  </li>
111
115
  ))}
112
116
  </ol>
113
- <small>{series.length} 篇 · 持续更新</small>
117
+ <small>{ui().columnSideCount(series.length, columnStatusLabel(entry.columnStatus))}</small>
114
118
  </aside>
115
119
 
116
120
  <article className="article-column series-article h-entry">
117
121
  <Link className="u-url sr-only" href={pathname}>
118
- {entry.title} 永久链接
122
+ {siteLang === "en" ? t.permalinkLabel(entry.title) : <>{entry.title} 永久链接</>}
119
123
  </Link>
120
124
  <a
121
125
  className="p-author h-card sr-only"
@@ -134,13 +138,13 @@ export default async function ColumnEntryPage({
134
138
  </time>
135
139
  </li>
136
140
  {originalUrl(entry) ? (
137
- <li><a className="orig ext" href={originalUrl(entry)} target="_blank" rel="noopener noreferrer">原文 ↗</a></li>
141
+ <li><a className="orig ext" href={originalUrl(entry)} target="_blank" rel="noopener noreferrer">{t.originalLink}</a></li>
138
142
  ) : null}
139
143
  </ul>
140
144
  <ArticleTags tags={entry.tags} />
141
145
  {entry.cover && (
142
146
  <figure className="article-hero article-cover cover-img">
143
- <CoverImage className="u-photo" src={entry.cover} image={entry.coverImage} alt={`${entry.title} 封面`} sizes="(max-width: 852px) calc(100vw - 40px), 812px" priority />
147
+ <CoverImage className="u-photo" src={entry.cover} image={entry.coverImage} alt={siteLang === "en" ? t.coverLabel(entry.title) : `${entry.title} 封面`} sizes="(max-width: 852px) calc(100vw - 40px), 812px" priority />
144
148
  </figure>
145
149
  )}
146
150
  </header>
@@ -150,20 +154,15 @@ export default async function ColumnEntryPage({
150
154
  <MarkdownArticle content={entry.content} numbering={entry.numbering} />
151
155
  </div>
152
156
  <ArticleReferences references={entry.references} />
153
- {next && (
154
- <Link
155
- href={columnHref(next)}
156
- className="next-entry"
157
- >
158
- <span>下一篇</span>
159
- <strong>{next.title}</strong>
160
- <ArrowRight size={20} />
161
- </Link>
162
- )}
163
157
  <Webmentions
164
158
  target={canonicalUrl}
165
159
  endpoint={webmentionApiEndpoint()}
166
160
  />
161
+ <ArticlePager
162
+ label={ui().columnPagerLabel}
163
+ prev={prev ? { href: columnHref(prev), title: prev.title } : null}
164
+ next={next ? { href: columnHref(next), title: next.title } : null}
165
+ />
167
166
  <Comments slug={`${column}:${entry.path}`} />
168
167
  </article>
169
168
 
@@ -4,6 +4,7 @@ import { columnHref, columns } from "../lib/content";
4
4
  import { ContentEmptyState } from "../components/ContentEmptyState";
5
5
  import { siteConfig, siteDocumentTitle } from "../site.config";
6
6
  import { pageAlternates } from "../lib/page-alternates";
7
+ import { columnStatusLabel, ui } from "../lib/i18n";
7
8
 
8
9
  export const metadata = {
9
10
  title: siteDocumentTitle,
@@ -23,6 +24,7 @@ function day(value: string) {
23
24
  }
24
25
 
25
26
  export default function ColumnsPage() {
27
+ const t = ui();
26
28
  const grouped = Array.from(
27
29
  columns.reduce((result, entry) => {
28
30
  const existing = result.get(entry.column ?? "");
@@ -39,19 +41,21 @@ export default function ColumnsPage() {
39
41
  return (
40
42
  <main className="sh-main" id="content">
41
43
  <div className="sh-head">
42
- <h1>{siteConfig.pages.columns.title || "专栏"}</h1>
44
+ <h1>{siteConfig.pages.columns.title || t.homeColumns}</h1>
43
45
  {siteConfig.pages.columns.subtitle ? <p className="sh-deck">{siteConfig.pages.columns.subtitle}</p> : null}
44
46
  </div>
45
47
  {grouped.length ? grouped.map(({ slug, first, entries }) => (
46
- <section className="sh-series" aria-labelledby={slug} key={slug}>
47
- <div className="cover" style={{ background: "var(--well)" }}>
48
- {first.columnCover ? <img className="column-feature-cover" src={first.columnCover} alt={first.columnCoverAlt ?? `${first.columnTitle} 封面`} /> : null}
49
- </div>
48
+ <section className={first.columnCover ? "sh-series" : "sh-series no-cover"} aria-labelledby={slug} key={slug}>
49
+ {first.columnCover ? (
50
+ <div className="cover" style={{ background: "var(--well)" }}>
51
+ <img className="column-feature-cover" src={first.columnCover} alt={first.columnCoverAlt ?? t.columnCoverAlt(first.columnTitle)} />
52
+ </div>
53
+ ) : null}
50
54
  <div className="body">
51
55
  <h2 id={slug}>{first.columnTitle}</h2>
52
- <p className="count">阅读清单,{entries.length} 篇,{first.columnStatus ?? "持续更新"}</p>
53
- <p className="desc">{first.columnDescription ?? first.description ?? "按顺序组织的专题文章系列。"}</p>
54
- <ol aria-label={`${first.columnTitle},从新到旧`}>
56
+ <p className="count">{t.columnReadingList(entries.length, columnStatusLabel(first.columnStatus))}</p>
57
+ <p className="desc">{first.columnDescription ?? first.description ?? t.columnFallbackDesc}</p>
58
+ <ol aria-label={t.columnNewestFirst(first.columnTitle)}>
55
59
  {entries.map((entry) => (
56
60
  <li key={entry.path}>
57
61
  <Link href={columnHref(entry)}>{entry.title}</Link>
@@ -2,6 +2,7 @@ import Link from "next/link";
2
2
  import { redirect } from "next/navigation";
3
3
  import type { ReactNode } from "react";
4
4
  import { getAdminUser } from "../lib/admin-auth";
5
+ import { ui } from "../lib/i18n";
5
6
 
6
7
  const SECTIONS = [
7
8
  { href: "/admin", label: "总览" },
@@ -23,9 +24,9 @@ export function StaticAdminNotice() {
23
24
  return (
24
25
  <main className="admin-access-page">
25
26
  <div className="admin-access-card">
26
- <h1>后台在另一个地址。</h1>
27
- <p>公开博客上看不到这一页。写随想和改交互,去单独部署的后台。</p>
28
- <Link href="/">返回公开博客</Link>
27
+ <h1>{ui().staticAdminHeading}</h1>
28
+ <p>{ui().staticAdminBody}</p>
29
+ <Link href="/">{ui().staticAdminBack}</Link>
29
30
  </div>
30
31
  </main>
31
32
  );
@@ -0,0 +1,32 @@
1
+ import Link from "next/link";
2
+ import { ArrowLeft, ArrowRight } from "@phosphor-icons/react/ssr";
3
+ import { ui } from "../lib/i18n";
4
+
5
+ export type PagerTarget = { href: string; title: string } | null;
6
+
7
+ /** 上一篇 / 下一篇, the same cards as the handbook pager. A missing side stays empty. */
8
+ export function ArticlePager({ prev, next, label = ui().pagerLabel }: { prev: PagerTarget; next: PagerTarget; label?: string }) {
9
+ if (!prev && !next) return null;
10
+ return (
11
+ <nav className="gdoc-pager article-pager" aria-label={label}>
12
+ {prev ? (
13
+ <Link href={prev.href} className="gdoc-pager-card is-prev">
14
+ <span className="gdoc-pager-dir">
15
+ <ArrowLeft size={14} aria-hidden="true" />
16
+ {ui().prev}
17
+ </span>
18
+ <strong>{prev.title}</strong>
19
+ </Link>
20
+ ) : null}
21
+ {next ? (
22
+ <Link href={next.href} className="gdoc-pager-card is-next">
23
+ <span className="gdoc-pager-dir">
24
+ {ui().next}
25
+ <ArrowRight size={14} aria-hidden="true" />
26
+ </span>
27
+ <strong>{next.title}</strong>
28
+ </Link>
29
+ ) : null}
30
+ </nav>
31
+ );
32
+ }
@@ -1,4 +1,5 @@
1
1
  import { referenceColumns, referenceDomain } from "../../ui/garden/references.mjs";
2
+ import { ui, type UiLang } from "../lib/i18n";
2
3
 
3
4
  type ReferenceItem = {
4
5
  title: string;
@@ -8,8 +9,10 @@ type ReferenceItem = {
8
9
 
9
10
  export function ArticleReferences({
10
11
  references,
12
+ lang,
11
13
  }: {
12
14
  references?: ReferenceItem[];
15
+ lang?: UiLang;
13
16
  }) {
14
17
  if (!references?.length) return null;
15
18
  const split = referenceColumns(references.length) === 2;
@@ -19,7 +22,7 @@ export function ArticleReferences({
19
22
  className={split ? "article-references is-split" : "article-references"}
20
23
  aria-labelledby="article-references-heading"
21
24
  >
22
- <h2 id="article-references-heading">参考</h2>
25
+ <h2 id="article-references-heading">{ui(lang).referencesHeading}</h2>
23
26
  <ol>
24
27
  {references.map((item, index) => {
25
28
  const domain = item.domain ?? referenceDomain(item.url);
@@ -1,13 +1,14 @@
1
1
  import Link from "next/link";
2
2
  import { articleTagHref } from "../lib/tag-filter";
3
3
  import { siteHref } from "../lib/site-href";
4
+ import { ui, type UiLang } from "../lib/i18n";
4
5
 
5
6
  /** Tags under an article title. Link, so following one is a client navigation like the rest of the site. */
6
- export function ArticleTags({ tags }: { tags?: string[] }) {
7
+ export function ArticleTags({ tags, lang }: { tags?: string[]; lang?: UiLang }) {
7
8
  const list = (tags ?? []).filter(Boolean);
8
9
  if (!list.length) return null;
9
10
  return (
10
- <ul className="article-tagrow" aria-label="标签">
11
+ <ul className="article-tagrow" aria-label={ui(lang).articleTagsLabel}>
11
12
  {list.map((tag) => (
12
13
  <li key={tag}>
13
14
  <Link className="tag-chip p-category" href={siteHref(articleTagHref(tag))}>
@@ -17,6 +17,7 @@ import {
17
17
  } from "../lib/tag-filter";
18
18
  import { ContentEmptyState } from "./ContentEmptyState";
19
19
  import { siteHref } from "../lib/site-href";
20
+ import { siteLang, ui } from "../lib/i18n";
20
21
 
21
22
  function day(value: string) {
22
23
  const date = new Date(value);
@@ -72,6 +73,7 @@ export function BlogArchiveFallback({ posts, series }: ArchiveData) {
72
73
  }
73
74
 
74
75
  function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected: string[]; go: (next: string[]) => void }) {
76
+ const t = ui();
75
77
  const [compact, setCompact] = useState(false);
76
78
  const [expanded, setExpanded] = useState(false);
77
79
  const options = useMemo(() => tagOptions(posts, selected), [posts, selected]);
@@ -81,7 +83,7 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
81
83
  .sort((left, right) => Date.parse(right.date) - Date.parse(left.date));
82
84
  const years = new Map<string, typeof filtered>();
83
85
  for (const post of filtered) {
84
- const year = day(post.date).slice(0, 4) || "未知";
86
+ const year = day(post.date).slice(0, 4) || t.unknownYear;
85
87
  const list = years.get(year);
86
88
  if (list) list.push(post);
87
89
  else years.set(year, [post]);
@@ -92,7 +94,7 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
92
94
  <div>
93
95
  {options.length ? (
94
96
  <div className="sh-tagfilter">
95
- <ul className="sh-tagchips" aria-label="按标签筛选">
97
+ <ul className="sh-tagchips" aria-label={t.tagFilterLabel}>
96
98
  {shown.map((option) => (
97
99
  <li key={option.tag}>
98
100
  <button
@@ -111,7 +113,9 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
111
113
  {hidden > 0 || expanded ? (
112
114
  <li>
113
115
  <button type="button" className="sh-tagmore" aria-expanded={expanded} onClick={() => setExpanded(!expanded)}>
114
- {expanded ? "收起" : `展开全部 ${options.length} 个标签`}
116
+ {siteLang === "en"
117
+ ? (expanded ? t.tagCollapse : t.tagExpandAll(options.length))
118
+ : (expanded ? "收起" : `展开全部 ${options.length} 个标签`)}
115
119
  </button>
116
120
  </li>
117
121
  ) : null}
@@ -119,17 +123,17 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
119
123
  </div>
120
124
  ) : null}
121
125
  <div className="sh-status">
122
- <span aria-live="polite">{tagSummary(selected.length, filtered.length)}</span>
123
- {selected.length ? <button type="button" onClick={() => go([])}>清除</button> : null}
124
- <div className="sh-view" role="group" aria-label="列表密度">
125
- <button type="button" aria-pressed={!compact} onClick={() => setCompact(false)}>摘要</button>
126
- <button type="button" aria-pressed={compact} onClick={() => setCompact(true)}>紧凑</button>
126
+ <span aria-live="polite">{tagSummary(selected.length, filtered.length, siteLang)}</span>
127
+ {selected.length ? <button type="button" onClick={() => go([])}>{siteLang === "en" ? t.tagClear : "清除"}</button> : null}
128
+ <div className="sh-view" role="group" aria-label={t.densityLabel}>
129
+ <button type="button" aria-pressed={!compact} onClick={() => setCompact(false)}>{t.densityDetailed}</button>
130
+ <button type="button" aria-pressed={compact} onClick={() => setCompact(true)}>{t.densityCompact}</button>
127
131
  </div>
128
132
  </div>
129
133
  <div id="sh-list" className={compact ? "is-compact" : undefined}>
130
134
  {filtered.length ? [...years.entries()].map(([year, entries]) => (
131
135
  <section className="sh-year" id={`y${year}`} key={year} aria-labelledby={`yh${year}`}>
132
- <h2 id={`yh${year}`}>{year}<span className="n">{entries.length} 篇</span></h2>
136
+ <h2 id={`yh${year}`}>{year}<span className="n">{siteLang === "en" ? t.yearPostCount(entries.length) : <>{entries.length} 篇</>}</span></h2>
133
137
  <ol className="sh-rows">
134
138
  {entries.map((post) => (
135
139
  <li className="sh-row" key={post.path}>
@@ -137,7 +141,7 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
137
141
  <div className="archive-copy">
138
142
  <h3>
139
143
  <Link href={postHref(post)}>{post.title}</Link>
140
- {post.pinned ? <span className="pin">置顶</span> : null}
144
+ {post.pinned ? <span className="pin">{t.pinnedBadge}</span> : null}
141
145
  </h3>
142
146
  {post.description ? <p>{post.description}</p> : null}
143
147
  </div>
@@ -148,19 +152,19 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
148
152
  </section>
149
153
  )) : selected.length ? (
150
154
  <div className="sh-tagempty">
151
- <p>没有同时带这些标签的文章。</p>
155
+ <p>{siteLang === "en" ? t.tagEmptyMatch : "没有同时带这些标签的文章。"}</p>
152
156
  <div className="acts">
153
157
  {selected.length > 1 ? (
154
- <button type="button" onClick={() => go(selected.slice(0, -1))}>去掉「{selected[selected.length - 1]}」</button>
158
+ <button type="button" onClick={() => go(selected.slice(0, -1))}>{t.tagDropLast(selected[selected.length - 1])}</button>
155
159
  ) : null}
156
- <button type="button" onClick={() => go([])}>看全部文章</button>
160
+ <button type="button" onClick={() => go([])}>{t.browseAllPosts}</button>
157
161
  </div>
158
162
  </div>
159
163
  ) : <ContentEmptyState kind="posts" />}
160
164
  </div>
161
165
  </div>
162
- <aside className="sh-rail" aria-label="按年份跳转">
163
- {years.size ? <h2>按年份</h2> : null}
166
+ <aside className="sh-rail" aria-label={t.railJumpByYear}>
167
+ {years.size ? <h2>{t.railByYear}</h2> : null}
164
168
  <ol>
165
169
  {[...years.keys()].map((year) => (
166
170
  <li key={year}>
@@ -170,7 +174,7 @@ function ArchiveView({ posts, series, selected, go }: ArchiveData & { selected:
170
174
  </ol>
171
175
  {series.length ? (
172
176
  <>
173
- <h2>专栏</h2>
177
+ <h2>{t.railColumns}</h2>
174
178
  <ol>
175
179
  {series.map((column) => (
176
180
  <li key={column.slug}>
@@ -1,20 +1,20 @@
1
1
  import { ChatCircle, GithubLogo } from "@phosphor-icons/react/ssr";
2
2
  import { readGiscusConfig } from "../lib/giscus-config";
3
3
  import { GiscusComments } from "./GiscusComments";
4
+ import { ui } from "../lib/i18n";
4
5
 
5
6
  export function Comments({ slug }: { slug: string }) {
6
7
  const config = readGiscusConfig();
8
+ const t = ui();
7
9
 
8
10
  return (
9
11
  <section className="comments" aria-labelledby={`comments-heading-${slug}`}>
10
12
  <div className="comments-heading">
11
13
  <ChatCircle size={23} />
12
14
  <div>
13
- <h2 id={`comments-heading-${slug}`}>评论</h2>
15
+ <h2 id={`comments-heading-${slug}`}>{t.comments}</h2>
14
16
  <p>
15
- {config
16
- ? "评论由 GitHub Discussions 保存和管理。"
17
- : "评论还没开放。配好下面四项后,这里才会出现评论框。"}
17
+ {config ? t.commentsSummaryReady : t.commentsSummaryUnconfigured}
18
18
  </p>
19
19
  </div>
20
20
  </div>
@@ -25,14 +25,21 @@ export function Comments({ slug }: { slug: string }) {
25
25
  <div className="integration-notice">
26
26
  <GithubLogo size={20} weight="fill" />
27
27
  <p>
28
- 打开{" "}
29
- <a href="https://giscus.app/zh-CN" target="_blank" rel="noreferrer">
28
+ {t.commentsSetupBefore}{" "}
29
+ <a href={t.commentsGiscusGuideUrl} target="_blank" rel="noreferrer">
30
30
  Giscus
31
31
  </a>
32
- ,安装到一个已开启 Discussions 的公开仓库,把仓库、仓库 ID、分类、分类 ID
33
- 写入 <code>.env.local</code> 和 GitHub Actions Variables:
34
- <code>GISCUS_REPO</code>、<code>GISCUS_REPO_ID</code>、
35
- <code>GISCUS_CATEGORY</code>、<code>GISCUS_CATEGORY_ID</code>。
32
+ {t.commentsSetupMiddle}
33
+ <code>.env.local</code>
34
+ {t.commentsSetupAnd}
35
+ <code>GISCUS_REPO</code>
36
+ {t.commentsSetupJoin}
37
+ <code>GISCUS_REPO_ID</code>
38
+ {t.commentsSetupJoin}
39
+ <code>GISCUS_CATEGORY</code>
40
+ {t.commentsSetupJoin}
41
+ <code>GISCUS_CATEGORY_ID</code>
42
+ {t.commentsSetupEnd}
36
43
  </p>
37
44
  </div>
38
45
  )}
@@ -3,35 +3,36 @@
3
3
  import Link from "next/link";
4
4
  import { ArrowRight, Article, Books, Quotes } from "@phosphor-icons/react/ssr";
5
5
  import { siteHref } from "../lib/site-href";
6
+ import { ui } from "../lib/i18n";
6
7
 
7
- const emptyContent = {
8
- posts: {
9
- icon: Article,
10
- eyebrow: "暂无博文",
11
- title: "暂无博文。",
12
- description: "文章会按时间排在这里。",
13
- actionHref: "/",
14
- actionLabel: "返回首页",
15
- },
16
- thoughts: {
17
- icon: Quotes,
18
- eyebrow: "暂无随想",
19
- title: "暂无随想。",
20
- description: "短笔记和链接会在这里。",
21
- actionHref: "/blog",
22
- actionLabel: "先读博文",
23
- },
24
- columns: {
25
- icon: Books,
26
- eyebrow: "暂无专栏",
27
- title: "暂无专栏。",
28
- description: "一组文章会按顺序列在这里。",
29
- actionHref: "/blog",
30
- actionLabel: "浏览全部博文",
31
- },
32
- } as const;
33
-
34
- export function ContentEmptyState({ kind }: { kind: keyof typeof emptyContent }) {
8
+ export function ContentEmptyState({ kind }: { kind: "posts" | "thoughts" | "columns" }) {
9
+ const s = ui();
10
+ const emptyContent = {
11
+ posts: {
12
+ icon: Article,
13
+ eyebrow: s.emptyPostsEyebrow,
14
+ title: s.emptyPostsTitle,
15
+ description: s.emptyPostsDesc,
16
+ actionHref: "/",
17
+ actionLabel: s.emptyPostsCta,
18
+ },
19
+ thoughts: {
20
+ icon: Quotes,
21
+ eyebrow: s.emptyThoughtsEyebrow,
22
+ title: s.emptyThoughtsTitle,
23
+ description: s.emptyThoughtsDesc,
24
+ actionHref: "/blog",
25
+ actionLabel: s.emptyThoughtsCta,
26
+ },
27
+ columns: {
28
+ icon: Books,
29
+ eyebrow: s.emptyColumnsEyebrow,
30
+ title: s.emptyColumnsTitle,
31
+ description: s.emptyColumnsDesc,
32
+ actionHref: "/blog",
33
+ actionLabel: s.emptyColumnsCta,
34
+ },
35
+ } as const;
35
36
  const content = emptyContent[kind];
36
37
  const Icon = content.icon;
37
38
 
@@ -7,6 +7,7 @@ import {
7
7
  YoutubeLogo,
8
8
  } from "@phosphor-icons/react/ssr";
9
9
  import { parse } from "yaml";
10
+ import { ui, type UiLang } from "../lib/i18n";
10
11
 
11
12
  type EmbedData = {
12
13
  url: string;
@@ -70,23 +71,23 @@ function playableUrl(provider: EmbedProvider, url: URL) {
70
71
  return null;
71
72
  }
72
73
 
73
- const providerCopy = {
74
- youtube: { label: "YouTube", Icon: YoutubeLogo },
75
- bilibili: { label: "Bilibili", Icon: VideoCamera },
76
- x: { label: "X", Icon: XLogo },
77
- wechat: { label: "微信公众号", Icon: WechatLogo },
78
- website: { label: "网页摘要", Icon: LinkSimple },
79
- };
80
-
81
- export function ExternalEmbed({ source }: { source: string }) {
74
+ export function ExternalEmbed({ source, lang }: { source: string; lang?: UiLang }) {
75
+ const s = ui(lang);
82
76
  const data = readEmbedData(source);
83
77
  if (!data) {
84
- return <p className="external-embed-error">无法解析这个嵌入块,请检查 URL 与 YAML 格式。</p>;
78
+ return <p className="external-embed-error">{s.embedParseError}</p>;
85
79
  }
86
80
 
87
81
  const url = new URL(data.url);
88
82
  const provider = providerFor(url);
89
83
  const playerUrl = playableUrl(provider, url);
84
+ const providerCopy = {
85
+ youtube: { label: "YouTube", Icon: YoutubeLogo },
86
+ bilibili: { label: "Bilibili", Icon: VideoCamera },
87
+ x: { label: "X", Icon: XLogo },
88
+ wechat: { label: s.embedProviderWechat, Icon: WechatLogo },
89
+ website: { label: s.embedProviderWeb, Icon: LinkSimple },
90
+ };
90
91
  const { Icon, label } = providerCopy[provider];
91
92
 
92
93
  if (playerUrl) {
@@ -95,7 +96,7 @@ export function ExternalEmbed({ source }: { source: string }) {
95
96
  <div className="external-embed-frame">
96
97
  <iframe
97
98
  src={playerUrl}
98
- title={data.title ?? `${label} 嵌入内容`}
99
+ title={data.title ?? s.embedFrameTitle(label)}
99
100
  loading="lazy"
100
101
  allow="accelerometer; autoplay; encrypted-media; fullscreen; picture-in-picture"
101
102
  referrerPolicy="strict-origin-when-cross-origin"
@@ -104,7 +105,7 @@ export function ExternalEmbed({ source }: { source: string }) {
104
105
  </div>
105
106
  <figcaption>
106
107
  <span><Icon size={17} />{label}</span>
107
- <a href={data.url} target="_blank" rel="noopener noreferrer">在原网站打开<ArrowUpRight size={14} /></a>
108
+ <a href={data.url} target="_blank" rel="noopener noreferrer">{s.embedOpenOriginal}<ArrowUpRight size={14} /></a>
108
109
  </figcaption>
109
110
  </figure>
110
111
  );
@@ -118,8 +119,8 @@ export function ExternalEmbed({ source }: { source: string }) {
118
119
  )}
119
120
  <span className="external-embed-copy">
120
121
  <small><Icon size={16} />{label} · {url.hostname}</small>
121
- <strong>{data.title ?? "打开这条外部内容"}</strong>
122
- <p>{data.description ?? (provider === "x" || provider === "wechat" ? "平台不提供稳定的无脚本嵌入,Garden 会保留清晰、可访问的原文入口。" : "查看原网页中的完整内容与上下文。")}</p>
122
+ <strong>{data.title ?? s.embedFallbackTitle}</strong>
123
+ <p>{data.description ?? (provider === "x" || provider === "wechat" ? s.embedFallbackNoScript : s.embedFallbackWeb)}</p>
123
124
  </span>
124
125
  <ArrowUpRight size={21} />
125
126
  </a>
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ type Labels = { nextPage: string; loadingMore: string; loadFailed: string; feedEnd: string };
6
+
7
+ /**
8
+ * The end of a feed page. Without JavaScript it is a plain 下一页 link to the
9
+ * next pre-rendered page. With JavaScript, when it scrolls near the viewport it
10
+ * fetches that page's HTML, appends its items to the list above and moves on.
11
+ */
12
+ export function FeedMore({ next, streamSelector, labels }: { next: string | null; streamSelector: string; labels: Labels }) {
13
+ // Page links are written without the basePath; the pages fetched later carry it already.
14
+ const base = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/u, "");
15
+ const [href, setHref] = useState(next ? `${base}${next}` : null);
16
+ const [state, setState] = useState<"idle" | "loading" | "error">("idle");
17
+ const navRef = useRef<HTMLElement>(null);
18
+ const busy = useRef(false);
19
+
20
+ const load = useCallback(async () => {
21
+ if (!href || busy.current) return;
22
+ busy.current = true;
23
+ setState("loading");
24
+ try {
25
+ const response = await fetch(href, { headers: { Accept: "text/html" } });
26
+ if (!response.ok) throw new Error(String(response.status));
27
+ const page = new DOMParser().parseFromString(await response.text(), "text/html");
28
+ const incoming = page.querySelector(streamSelector);
29
+ const stream = navRef.current?.parentElement?.querySelector(streamSelector);
30
+ if (!incoming || !stream) throw new Error("feed markup");
31
+ stream.append(...Array.from(incoming.children).map((node) => document.importNode(node, true)));
32
+ setHref(page.querySelector<HTMLAnchorElement>("[data-feed-next]")?.getAttribute("href") ?? null);
33
+ setState("idle");
34
+ } catch {
35
+ setState("error");
36
+ } finally {
37
+ busy.current = false;
38
+ }
39
+ }, [href, streamSelector]);
40
+
41
+ useEffect(() => {
42
+ const node = navRef.current;
43
+ if (!node || !href || state !== "idle" || typeof IntersectionObserver === "undefined") return;
44
+ const observer = new IntersectionObserver((entries) => {
45
+ if (entries.some((entry) => entry.isIntersecting)) void load();
46
+ }, { rootMargin: "600px 0px" });
47
+ observer.observe(node);
48
+ return () => observer.disconnect();
49
+ }, [href, state, load]);
50
+
51
+ return (
52
+ <nav ref={navRef} className="feed-more" aria-live="polite">
53
+ {href ? (
54
+ <a
55
+ href={href}
56
+ rel="next"
57
+ data-feed-next=""
58
+ onClick={(event) => {
59
+ event.preventDefault();
60
+ void load();
61
+ }}
62
+ >
63
+ {state === "loading" ? labels.loadingMore : state === "error" ? labels.loadFailed : labels.nextPage}
64
+ </a>
65
+ ) : (
66
+ <span className="feed-end">{labels.feedEnd}</span>
67
+ )}
68
+ </nav>
69
+ );
70
+ }