hd-garden 0.7.0 → 0.8.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-ui/SKILL.md +26 -19
- package/README.en.md +57 -0
- package/README.md +31 -46
- package/app/blog/[...path]/page.tsx +9 -1
- package/app/columns/[column]/[...path]/page.tsx +10 -12
- package/app/components/ArticlePager.tsx +32 -0
- package/app/components/Comments.tsx +2 -1
- package/app/components/FeedMore.tsx +70 -0
- package/app/components/GardenDocsNav.tsx +25 -20
- package/app/components/HandbookLangRedirect.tsx +30 -0
- package/app/components/HandbookLangSwitch.tsx +49 -0
- package/app/components/HandbookView.tsx +187 -0
- package/app/components/MarkdownArticle.tsx +11 -5
- package/app/components/MarkdownImage.tsx +6 -4
- package/app/components/SiteChrome.tsx +11 -2
- package/app/components/SiteFooter.tsx +5 -3
- package/app/components/SiteHeader.tsx +27 -23
- package/app/components/TableOfContents.tsx +15 -9
- package/app/components/ThoughtFeed.tsx +41 -0
- package/app/components/UiLang.tsx +18 -0
- package/app/components/Webmentions.tsx +3 -2
- package/app/components/garden/FilePreview.tsx +13 -11
- package/app/components/garden/GardenBlock.tsx +3 -1
- package/app/components/garden/GardenFile.tsx +9 -7
- package/app/components/garden/blocks.tsx +34 -24
- package/app/components/garden/chrome.tsx +20 -13
- package/app/garden/[...slug]/page.tsx +10 -130
- package/app/garden/en/[...slug]/page.tsx +28 -0
- package/app/garden/en/page.tsx +16 -0
- package/app/garden/page.tsx +7 -74
- package/app/garden-modules.d.ts +3 -0
- package/app/layout.tsx +2 -4
- package/app/lib/content.ts +12 -2
- package/app/lib/garden-docs-meta.ts +60 -5
- package/app/lib/handbook-path.ts +11 -0
- package/app/lib/i18n.ts +222 -0
- package/app/lib/page-alternates.ts +2 -2
- package/app/not-found.tsx +5 -3
- package/app/site.config.ts +1 -1
- package/app/styles/a2.css +101 -32
- package/app/styles/shell.css +6 -0
- package/app/thoughts/page/[n]/page.tsx +44 -0
- package/app/thoughts/page.tsx +2 -6
- package/bin/garden.mjs +4 -0
- package/docs/handbook/ai-writing.md +99 -0
- package/docs/handbook/components/card.md +2 -0
- package/docs/handbook/components/chart.md +2 -0
- package/docs/handbook/components/compare.md +10 -12
- package/docs/handbook/components/figure.md +2 -0
- package/docs/handbook/components/file.md +2 -0
- package/docs/handbook/components/html.md +2 -0
- package/docs/handbook/components/interactive.md +2 -0
- package/docs/handbook/components/stat.md +2 -0
- package/docs/handbook/components/timeline.md +2 -0
- package/docs/handbook/components.md +1 -1
- package/docs/handbook/config.md +2 -2
- package/docs/handbook/deploy.md +1 -1
- package/docs/handbook/design.md +3 -3
- package/docs/handbook/en/ai-writing.md +99 -0
- package/docs/handbook/en/components/card.md +46 -0
- package/docs/handbook/en/components/chart.md +54 -0
- package/docs/handbook/en/components/compare.md +38 -0
- package/docs/handbook/en/components/figure.md +72 -0
- package/docs/handbook/en/components/file/checklist.md +17 -0
- package/docs/handbook/en/components/file.md +34 -0
- package/docs/handbook/en/components/html.md +69 -0
- package/docs/handbook/en/components/interactive.md +67 -0
- package/docs/handbook/en/components/stat.md +57 -0
- package/docs/handbook/en/components/timeline.md +52 -0
- package/docs/handbook/en/components.md +30 -0
- package/docs/handbook/en/config.md +125 -0
- package/docs/handbook/en/deploy.md +273 -0
- package/docs/handbook/en/design.md +81 -0
- package/docs/handbook/en/index.md +192 -0
- package/docs/handbook/en/start.md +92 -0
- package/docs/handbook/en/workflow.md +81 -0
- package/docs/handbook/en/writing.md +156 -0
- package/docs/handbook/index.md +98 -3
- package/docs/handbook/writing.md +18 -10
- package/package.json +1 -1
- package/scripts/build-content.mjs +23 -3
- package/scripts/handbook-facts.mjs +34 -0
- package/scripts/html-lang.mjs +36 -0
- package/scripts/static-404.mjs +25 -0
- package/scripts/ui-cli.mjs +14 -8
- package/templates/site/site.config.yaml +2 -0
- package/ui/core/apply.mjs +22 -4
- package/ui/core/cli-locale.mjs +67 -0
- package/ui/core/fence.mjs +18 -3
- package/ui/core/fix-prompt.mjs +18 -15
- package/ui/core/garden-fence.mjs +64 -0
- package/ui/core/scan.mjs +19 -3
- package/ui/garden/adjacent.mjs +21 -0
- package/ui/garden/check.mjs +6 -0
- package/ui/garden/html-lang.mjs +16 -0
- package/ui/garden/paging.mjs +30 -0
- package/ui/preview/dist/garden-ui.css +9 -9
- package/ui/preview/dist/garden-ui.esm.js +169 -169
- package/ui/preview/dist/garden-ui.iife.js +169 -169
- package/ui/react/components/chart.tsx +7 -4
- package/ui/react/components/display.tsx +6 -3
- package/ui/react/components/inputs.tsx +6 -3
- package/ui/react/components/layout.tsx +6 -4
- package/ui/react/components/media.tsx +7 -4
- package/ui/react/components/sandbox.tsx +3 -1
- package/ui/react/index.tsx +11 -7
- package/ui/react/lang.tsx +85 -0
- package/ui/react/runtime.tsx +3 -1
- package/ui/react/ui.css +9 -9
- package/worker/index.ts +8 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { HANDBOOK_LANG_KEY, type HandbookLang } from "../lib/garden-docs-meta";
|
|
5
|
+
import { swapHandbookLang } from "../lib/handbook-path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* First visit: follow the browser language. Afterwards: follow the reader's
|
|
9
|
+
* saved choice (set by the 中 / EN switch). Only moves to a page that exists,
|
|
10
|
+
* which the hreflang link in <head> tells us.
|
|
11
|
+
*/
|
|
12
|
+
export function HandbookLangRedirect({ lang }: { lang: HandbookLang }) {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
let saved: string | null = null;
|
|
15
|
+
try {
|
|
16
|
+
saved = localStorage.getItem(HANDBOOK_LANG_KEY);
|
|
17
|
+
} catch {}
|
|
18
|
+
const wanted: HandbookLang = saved === "en" || saved === "zh"
|
|
19
|
+
? saved
|
|
20
|
+
: /^en\b/i.test(navigator.language || "") ? "en" : "zh";
|
|
21
|
+
if (wanted === lang) return;
|
|
22
|
+
const link = document.querySelector<HTMLLinkElement>(`link[rel="alternate"][hreflang="${wanted === "en" ? "en" : "zh-CN"}"]`);
|
|
23
|
+
if (!link?.href) return;
|
|
24
|
+
// The hreflang link says the page exists; the address comes from this one,
|
|
25
|
+
// so the origin and any basePath stay as they are.
|
|
26
|
+
const pathname = swapHandbookLang(location.pathname, wanted);
|
|
27
|
+
if (pathname && pathname !== location.pathname) location.replace(pathname + location.search + location.hash);
|
|
28
|
+
}, [lang]);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import { HANDBOOK_LANG_KEY, handbookBase, type HandbookLang } from "../lib/garden-docs-meta";
|
|
6
|
+
import { siteHref } from "../lib/site-href";
|
|
7
|
+
import { swapHandbookLang } from "../lib/handbook-path";
|
|
8
|
+
|
|
9
|
+
const noSubscribe = () => () => {};
|
|
10
|
+
|
|
11
|
+
/** The counterpart page from the hreflang link in <head>, on this origin. */
|
|
12
|
+
function counterpartHref(target: HandbookLang) {
|
|
13
|
+
const link = document.querySelector<HTMLLinkElement>(`link[rel="alternate"][hreflang="${target === "en" ? "en" : "zh-CN"}"]`);
|
|
14
|
+
if (link?.href) return swapHandbookLang(location.pathname, target);
|
|
15
|
+
return target === "zh" ? siteHref(`${handbookBase("zh")}/`) : null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 「中 / EN」 in the top bar on handbook pages. Goes to the counterpart page. */
|
|
19
|
+
export function HandbookLangSwitch() {
|
|
20
|
+
const pathname = usePathname() ?? "";
|
|
21
|
+
const lang: HandbookLang = /\/garden\/en(\/|$)/.test(pathname) ? "en" : "zh";
|
|
22
|
+
const target: HandbookLang = lang === "en" ? "zh" : "en";
|
|
23
|
+
const href = useSyncExternalStore(
|
|
24
|
+
noSubscribe,
|
|
25
|
+
() => counterpartHref(target),
|
|
26
|
+
() => null,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const remember = () => {
|
|
30
|
+
try {
|
|
31
|
+
localStorage.setItem(HANDBOOK_LANG_KEY, target);
|
|
32
|
+
} catch {}
|
|
33
|
+
};
|
|
34
|
+
const label = (
|
|
35
|
+
<>
|
|
36
|
+
<span aria-current={lang === "zh" ? "true" : undefined}>中</span>
|
|
37
|
+
<span aria-hidden="true"> / </span>
|
|
38
|
+
<span aria-current={lang === "en" ? "true" : undefined}>EN</span>
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
41
|
+
if (!href) {
|
|
42
|
+
return <span className="sh-lang is-disabled" title="No English version of this page yet">{label}</span>;
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
<a className="sh-lang" href={href} hrefLang={target === "en" ? "en" : "zh-CN"} lang={target === "en" ? "en" : undefined} onClick={remember} aria-label={target === "en" ? "Read in English" : "Switch to Chinese"}>
|
|
46
|
+
{label}
|
|
47
|
+
</a>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import type { Metadata } from "next";
|
|
3
|
+
import { ArrowLeft, ArrowRight } from "@phosphor-icons/react/ssr";
|
|
4
|
+
import { ArticleMeasure } from "./ArticleMeasure";
|
|
5
|
+
import { ArticleReferences } from "./ArticleReferences";
|
|
6
|
+
import { ArticleTitle } from "./ArticleTitle";
|
|
7
|
+
import { GardenDocsNav } from "./GardenDocsNav";
|
|
8
|
+
import { HandbookLangRedirect } from "./HandbookLangRedirect";
|
|
9
|
+
import { MarkdownArticle } from "./MarkdownArticle";
|
|
10
|
+
import { TableOfContents } from "./TableOfContents";
|
|
11
|
+
import { extractHeadings, handbookDocs, handbookIndexDoc, hasHandbookPage, type GardenDocEntry } from "../lib/content";
|
|
12
|
+
import { pageAlternates } from "../lib/page-alternates";
|
|
13
|
+
import { absoluteUrl, getSiteUrl } from "../lib/site";
|
|
14
|
+
import { siteHref } from "../lib/site-href";
|
|
15
|
+
import {
|
|
16
|
+
COMPONENT_BADGE,
|
|
17
|
+
HANDBOOK_SECTIONS,
|
|
18
|
+
HANDBOOK_UI,
|
|
19
|
+
handbookPath,
|
|
20
|
+
orderedHandbookDocs,
|
|
21
|
+
sectionTitle,
|
|
22
|
+
toHandbookNavItems,
|
|
23
|
+
type HandbookLang,
|
|
24
|
+
} from "../lib/garden-docs-meta";
|
|
25
|
+
import { siteConfig } from "../site.config";
|
|
26
|
+
|
|
27
|
+
const other = (lang: HandbookLang): HandbookLang => (lang === "en" ? "zh" : "en");
|
|
28
|
+
const hreflang = (lang: HandbookLang) => (lang === "en" ? "en" : "zh-CN");
|
|
29
|
+
|
|
30
|
+
/** canonical + hreflang links; the counterpart is listed only when it exists. */
|
|
31
|
+
export function handbookLanguages(lang: HandbookLang, docPath: string) {
|
|
32
|
+
if (!hasHandbookPage(other(lang), docPath)) return undefined;
|
|
33
|
+
const zh = siteHref(handbookPath("zh", docPath));
|
|
34
|
+
const en = siteHref(handbookPath("en", docPath));
|
|
35
|
+
return { "zh-CN": zh, en, "x-default": zh };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const handbookAlternates = (lang: HandbookLang, docPath: string) =>
|
|
39
|
+
pageAlternates(handbookPath(lang, docPath), handbookLanguages(lang, docPath));
|
|
40
|
+
|
|
41
|
+
export async function handbookDocMetadata(lang: HandbookLang, doc: GardenDocEntry | undefined) {
|
|
42
|
+
if (!doc) return {};
|
|
43
|
+
const t = HANDBOOK_UI[lang];
|
|
44
|
+
const baseUrl = await getSiteUrl();
|
|
45
|
+
const pathname = siteHref(handbookPath(lang, doc.path));
|
|
46
|
+
return {
|
|
47
|
+
title: `${doc.title} · ${t.name}`,
|
|
48
|
+
description: doc.description,
|
|
49
|
+
alternates: handbookAlternates(lang, doc.path),
|
|
50
|
+
openGraph: {
|
|
51
|
+
type: "article",
|
|
52
|
+
url: absoluteUrl(pathname, baseUrl),
|
|
53
|
+
title: `${doc.title} · ${t.name}`,
|
|
54
|
+
description: doc.description,
|
|
55
|
+
locale: lang === "en" ? "en_US" : "zh_CN",
|
|
56
|
+
},
|
|
57
|
+
} satisfies Metadata;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function HandbookOverview({ lang }: { lang: HandbookLang }) {
|
|
61
|
+
const t = HANDBOOK_UI[lang];
|
|
62
|
+
const docs = handbookDocs(lang);
|
|
63
|
+
const indexDoc = handbookIndexDoc(lang);
|
|
64
|
+
const navItems = toHandbookNavItems(docs);
|
|
65
|
+
const firstDoc = orderedHandbookDocs(docs)[0] ?? null;
|
|
66
|
+
const content = indexDoc?.content ?? "";
|
|
67
|
+
const headings = extractHeadings(content);
|
|
68
|
+
// The site config title/subtitle is written in the site's own language.
|
|
69
|
+
const configured = lang === "zh" ? siteConfig.pages.garden : { title: "", subtitle: "" };
|
|
70
|
+
const title = configured.title?.trim() || indexDoc?.title || t.name;
|
|
71
|
+
const subtitle = configured.subtitle?.trim() || indexDoc?.description || "";
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<main className="column-reading-page garden-doc-page" lang={hreflang(lang)}>
|
|
75
|
+
<HandbookLangRedirect lang={lang} />
|
|
76
|
+
<GardenDocsNav docs={navItems} currentPath="" lang={lang} />
|
|
77
|
+
|
|
78
|
+
<article className="article-column garden-doc-article h-entry">
|
|
79
|
+
<header className="gdoc-article-head">
|
|
80
|
+
<div className="gdoc-eyebrow">
|
|
81
|
+
<span>{t.name}</span>
|
|
82
|
+
<span aria-hidden="true">/</span>
|
|
83
|
+
<span>{t.overview}</span>
|
|
84
|
+
</div>
|
|
85
|
+
<ArticleTitle title={title} />
|
|
86
|
+
{subtitle ? <p className="gdoc-article-deck p-summary">{subtitle}</p> : null}
|
|
87
|
+
</header>
|
|
88
|
+
|
|
89
|
+
<ArticleMeasure />
|
|
90
|
+
|
|
91
|
+
<div className="e-content">
|
|
92
|
+
<MarkdownArticle content={content} lang={lang} />
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<ArticleReferences references={indexDoc?.references} />
|
|
96
|
+
|
|
97
|
+
{firstDoc ? (
|
|
98
|
+
<nav className="gdoc-pager" aria-label={t.pager}>
|
|
99
|
+
<Link href={siteHref(handbookPath(lang, firstDoc.path))} className="gdoc-pager-card is-next">
|
|
100
|
+
<span className="gdoc-pager-dir">
|
|
101
|
+
{t.next}
|
|
102
|
+
<ArrowRight size={14} aria-hidden="true" />
|
|
103
|
+
</span>
|
|
104
|
+
<strong>{firstDoc.title}</strong>
|
|
105
|
+
</Link>
|
|
106
|
+
</nav>
|
|
107
|
+
) : null}
|
|
108
|
+
</article>
|
|
109
|
+
|
|
110
|
+
<TableOfContents headings={headings} label={t.toc} lang={lang} />
|
|
111
|
+
</main>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function HandbookDoc({ lang, doc }: { lang: HandbookLang; doc: GardenDocEntry }) {
|
|
116
|
+
const t = HANDBOOK_UI[lang];
|
|
117
|
+
const docs = handbookDocs(lang);
|
|
118
|
+
const ordered = orderedHandbookDocs(docs);
|
|
119
|
+
const navItems = toHandbookNavItems(docs);
|
|
120
|
+
const index = ordered.findIndex((item) => item.path === doc.path);
|
|
121
|
+
const prev = index > 0 ? ordered[index - 1] : null;
|
|
122
|
+
const next = index >= 0 && index < ordered.length - 1 ? ordered[index + 1] : null;
|
|
123
|
+
const headings = extractHeadings(doc.content);
|
|
124
|
+
const sectionMeta = HANDBOOK_SECTIONS.find((sec) => sec.id === doc.section);
|
|
125
|
+
const componentBadge = COMPONENT_BADGE[doc.path];
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<main className="column-reading-page garden-doc-page" lang={hreflang(lang)}>
|
|
129
|
+
<HandbookLangRedirect lang={lang} />
|
|
130
|
+
<GardenDocsNav docs={navItems} currentPath={doc.path} lang={lang} />
|
|
131
|
+
|
|
132
|
+
<article className="article-column garden-doc-article h-entry">
|
|
133
|
+
<header className="gdoc-article-head">
|
|
134
|
+
<div className="gdoc-eyebrow">
|
|
135
|
+
<Link href={siteHref(handbookPath(lang))} className="gdoc-eyebrow-root">
|
|
136
|
+
{t.name}
|
|
137
|
+
</Link>
|
|
138
|
+
<span aria-hidden="true">/</span>
|
|
139
|
+
<span>{sectionTitle(sectionMeta, lang) ?? doc.sectionTitle ?? t.guide}</span>
|
|
140
|
+
{componentBadge ? <code className="gdoc-eyebrow-fence">{`garden ${componentBadge}`}</code> : null}
|
|
141
|
+
</div>
|
|
142
|
+
<ArticleTitle title={doc.title} />
|
|
143
|
+
{doc.description ? <p className="gdoc-article-deck p-summary">{doc.description}</p> : null}
|
|
144
|
+
</header>
|
|
145
|
+
|
|
146
|
+
<ArticleMeasure />
|
|
147
|
+
|
|
148
|
+
<div className="e-content">
|
|
149
|
+
<MarkdownArticle content={doc.content} lang={lang} />
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
<ArticleReferences references={doc.references} />
|
|
153
|
+
|
|
154
|
+
<nav className="gdoc-pager" aria-label={t.pager}>
|
|
155
|
+
{prev ? (
|
|
156
|
+
<Link href={siteHref(handbookPath(lang, prev.path))} className="gdoc-pager-card is-prev">
|
|
157
|
+
<span className="gdoc-pager-dir">
|
|
158
|
+
<ArrowLeft size={14} aria-hidden="true" />
|
|
159
|
+
{t.prev}
|
|
160
|
+
</span>
|
|
161
|
+
<strong>{prev.title}</strong>
|
|
162
|
+
</Link>
|
|
163
|
+
) : (
|
|
164
|
+
<Link href={siteHref(handbookPath(lang))} className="gdoc-pager-card is-prev">
|
|
165
|
+
<span className="gdoc-pager-dir">
|
|
166
|
+
<ArrowLeft size={14} aria-hidden="true" />
|
|
167
|
+
{t.backToOverview}
|
|
168
|
+
</span>
|
|
169
|
+
<strong>{t.overviewDocTitle}</strong>
|
|
170
|
+
</Link>
|
|
171
|
+
)}
|
|
172
|
+
{next ? (
|
|
173
|
+
<Link href={siteHref(handbookPath(lang, next.path))} className="gdoc-pager-card is-next">
|
|
174
|
+
<span className="gdoc-pager-dir">
|
|
175
|
+
{t.next}
|
|
176
|
+
<ArrowRight size={14} aria-hidden="true" />
|
|
177
|
+
</span>
|
|
178
|
+
<strong>{next.title}</strong>
|
|
179
|
+
</Link>
|
|
180
|
+
) : null}
|
|
181
|
+
</nav>
|
|
182
|
+
</article>
|
|
183
|
+
|
|
184
|
+
<TableOfContents headings={headings} label={t.toc} lang={lang} />
|
|
185
|
+
</main>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { UiLangProvider } from "./UiLang";
|
|
2
|
+
import { ui, type UiLang } from "../lib/i18n";
|
|
1
3
|
import { markdownPlugins } from "../lib/markdown-plugins";
|
|
2
4
|
import { remarkA2 } from "../lib/remark-a2.mjs";
|
|
3
5
|
import { remarkLinkCard } from "../lib/remark-link-card.mjs";
|
|
@@ -28,7 +30,7 @@ import { imageDimensions, lookupImageSize } from "../../ui/garden/image-dims.mjs
|
|
|
28
30
|
import { siteConfig, siteLinks } from "@garden/site-config";
|
|
29
31
|
import { articleLink, sameSiteMatcher } from "../../ui/garden/same-site.mjs";
|
|
30
32
|
import { columnRedirects, internalLinkResolver, readRedirects } from "../../ui/garden/internal-links.mjs";
|
|
31
|
-
import { columnHref, columns, gardenDocHref, gardenDocs, postHref, posts, thoughts } from "../lib/content";
|
|
33
|
+
import { columnHref, columns, gardenDocHref, gardenDocs, gardenDocsEn, postHref, posts, thoughts } from "../lib/content";
|
|
32
34
|
import { LinkCardRefresh } from "./LinkCardRefresh";
|
|
33
35
|
import { linkHost } from "../../scripts/link-meta-core.mjs";
|
|
34
36
|
import { cardDate } from "../../ui/garden/card-refresh.mjs";
|
|
@@ -46,11 +48,12 @@ const internalRoute = internalLinkResolver({
|
|
|
46
48
|
...columnRedirects({ columns: [...new Set(columns.map((entry) => entry.column).filter(Boolean))].map((slug) => ({ slug })) }),
|
|
47
49
|
],
|
|
48
50
|
routes: [
|
|
49
|
-
"/", "/blog", "/columns", "/thoughts", "/tags", "/about", "/garden",
|
|
51
|
+
"/", "/blog", "/columns", "/thoughts", "/tags", "/about", "/garden", "/garden/en",
|
|
50
52
|
...posts.map(postHref),
|
|
51
53
|
...columns.map(columnHref),
|
|
52
54
|
...thoughts.map((thought) => `/thoughts/${thought.slug}`),
|
|
53
55
|
...gardenDocs.map(gardenDocHref),
|
|
56
|
+
...gardenDocsEn.map((doc) => siteHref(`/garden/en/${doc.path}`)),
|
|
54
57
|
],
|
|
55
58
|
basePath: siteLinks?.basePath ?? "",
|
|
56
59
|
// Same shape as every other address in the site (the slash follows trailingSlash).
|
|
@@ -208,7 +211,8 @@ const headingText = (children: ReactNode) => {
|
|
|
208
211
|
return parts.join("");
|
|
209
212
|
};
|
|
210
213
|
|
|
211
|
-
export function MarkdownArticle({ content, numbering }: { content: string; numbering?: boolean }) {
|
|
214
|
+
export function MarkdownArticle({ content, numbering, lang }: { content: string; numbering?: boolean; lang?: UiLang }) {
|
|
215
|
+
const t = ui(lang);
|
|
212
216
|
// Optional layer 2: a Worker that refreshes card text. Without it, cards stay as built.
|
|
213
217
|
const cardWorker = content.includes("{card}") ? siteConfig.linkPreview?.worker : undefined;
|
|
214
218
|
const numbers = { h2: 0, h3: 0 };
|
|
@@ -248,7 +252,7 @@ export function MarkdownArticle({ content, numbering }: { content: string; numbe
|
|
|
248
252
|
"garden-block": (props: { dataPayload?: string; "data-payload"?: string }) => {
|
|
249
253
|
const payload = props.dataPayload ?? props["data-payload"];
|
|
250
254
|
const block = readGardenPayload(payload);
|
|
251
|
-
if (block?.name === "file" && !block.error) return <GardenFile props={(block.props ?? {}) as Record<string, unknown>} />;
|
|
255
|
+
if (block?.name === "file" && !block.error) return <GardenFile props={(block.props ?? {}) as Record<string, unknown>} lang={lang} />;
|
|
252
256
|
return <GardenBlock dataPayload={payload} />;
|
|
253
257
|
},
|
|
254
258
|
"garden-codediff": (props: {
|
|
@@ -266,6 +270,7 @@ export function MarkdownArticle({ content, numbering }: { content: string; numbe
|
|
|
266
270
|
),
|
|
267
271
|
};
|
|
268
272
|
return (
|
|
273
|
+
<UiLangProvider lang={lang}>
|
|
269
274
|
<div className="markdown-body prose">
|
|
270
275
|
<ReactMarkdown
|
|
271
276
|
remarkPlugins={[...markdownPlugins, [remarkLinkCard, { sameSite }], [remarkA2, { imageSize: (src: string) => lookupImageSize(imageSizes, src) }]]}
|
|
@@ -320,7 +325,7 @@ export function MarkdownArticle({ content, numbering }: { content: string; numbe
|
|
|
320
325
|
return <MarkdownImage {...props} {...dims} style={{ ...props.style, ...dims.style }} />;
|
|
321
326
|
},
|
|
322
327
|
table: ({ children, ...props }) => (
|
|
323
|
-
<div className="table-wrap" role="region" aria-label=
|
|
328
|
+
<div className="table-wrap" role="region" aria-label={t.tableScroll} tabIndex={0}>
|
|
324
329
|
<table {...props}>{children}</table>
|
|
325
330
|
</div>
|
|
326
331
|
),
|
|
@@ -356,5 +361,6 @@ export function MarkdownArticle({ content, numbering }: { content: string; numbe
|
|
|
356
361
|
</ReactMarkdown>
|
|
357
362
|
{cardWorker ? <LinkCardRefresh endpoint={cardWorker} /> : null}
|
|
358
363
|
</div>
|
|
364
|
+
</UiLangProvider>
|
|
359
365
|
);
|
|
360
366
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useUi } from "./UiLang";
|
|
3
4
|
import { useEffect, useState, type ComponentPropsWithoutRef } from "react";
|
|
4
5
|
|
|
5
6
|
// react-markdown also hands over its syntax-tree `node`; it must not reach the DOM.
|
|
@@ -8,7 +9,8 @@ type MarkdownImageProps = ComponentPropsWithoutRef<"img"> & { node?: unknown };
|
|
|
8
9
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
9
10
|
export function MarkdownImage({ alt = "", src, node, style, ...props }: MarkdownImageProps) {
|
|
10
11
|
const [open, setOpen] = useState(false);
|
|
11
|
-
const
|
|
12
|
+
const t = useUi();
|
|
13
|
+
const description = alt || t.viewImage;
|
|
12
14
|
|
|
13
15
|
useEffect(() => {
|
|
14
16
|
if (!open) return undefined;
|
|
@@ -33,7 +35,7 @@ export function MarkdownImage({ alt = "", src, node, style, ...props }: Markdown
|
|
|
33
35
|
type="button"
|
|
34
36
|
className="markdown-image-trigger"
|
|
35
37
|
onClick={() => setOpen(true)}
|
|
36
|
-
aria-label={
|
|
38
|
+
aria-label={t.zoomImage(description)}
|
|
37
39
|
>
|
|
38
40
|
{/* Native images preserve Typora-authored relative paths. */}
|
|
39
41
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
@@ -56,9 +58,9 @@ export function MarkdownImage({ alt = "", src, node, style, ...props }: Markdown
|
|
|
56
58
|
type="button"
|
|
57
59
|
className="markdown-image-lightbox-close"
|
|
58
60
|
onClick={() => setOpen(false)}
|
|
59
|
-
aria-label=
|
|
61
|
+
aria-label={t.closeImage}
|
|
60
62
|
>
|
|
61
|
-
|
|
63
|
+
{t.close.trim()}
|
|
62
64
|
</button>
|
|
63
65
|
</div>
|
|
64
66
|
</div>
|
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { usePathname } from "next/navigation";
|
|
4
|
+
import { routeLang, ui } from "../lib/i18n";
|
|
4
5
|
import { SiteFooter } from "./SiteFooter";
|
|
5
6
|
import { SiteHeader } from "./SiteHeader";
|
|
6
7
|
|
|
7
8
|
export function SiteChrome({ children }: { children: React.ReactNode }) {
|
|
8
9
|
const pathname = usePathname();
|
|
9
|
-
|
|
10
|
+
const lang = routeLang(pathname);
|
|
11
|
+
// The skip link follows the route language too (/garden/en/ is English).
|
|
12
|
+
const skip = (
|
|
13
|
+
<a className="skip-link" href="#main-content">
|
|
14
|
+
{ui(lang).skipToContent}
|
|
15
|
+
</a>
|
|
16
|
+
);
|
|
17
|
+
if (pathname?.startsWith("/admin")) return <>{skip}{children}</>;
|
|
10
18
|
|
|
11
19
|
return (
|
|
12
20
|
<>
|
|
21
|
+
{skip}
|
|
13
22
|
<SiteHeader />
|
|
14
23
|
<div id="main-content">{children}</div>
|
|
15
|
-
<SiteFooter docs={pathname?.startsWith("/garden") ?? false} />
|
|
24
|
+
<SiteFooter docs={pathname?.startsWith("/garden") ?? false} lang={lang} />
|
|
16
25
|
</>
|
|
17
26
|
);
|
|
18
27
|
}
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { GithubLogo, Rss } from "@phosphor-icons/react/ssr";
|
|
2
2
|
import { githubUrl, siteConfig } from "../site.config";
|
|
3
|
+
import { ui, type UiLang } from "../lib/i18n";
|
|
3
4
|
|
|
4
5
|
/** On the Garden docs pages the footer carries no site-owner line. */
|
|
5
|
-
export function SiteFooter({ docs = false }: { docs?: boolean }) {
|
|
6
|
+
export function SiteFooter({ docs = false, lang }: { docs?: boolean; lang?: UiLang }) {
|
|
7
|
+
const t = ui(lang);
|
|
6
8
|
return (
|
|
7
9
|
<footer className="sh-footer">
|
|
8
10
|
<div className="sh-foot">
|
|
9
11
|
<p>
|
|
10
12
|
{docs ? (
|
|
11
|
-
<span className="footer-framework"
|
|
13
|
+
<span className="footer-framework">{t.builtWith[0]}<a href="https://github.com/hedon954/garden" target="_blank" rel="noreferrer">Garden</a>{t.builtWith[1]}</span>
|
|
12
14
|
) : (
|
|
13
15
|
<>
|
|
14
16
|
© {new Date().getFullYear()} {siteConfig.author.name} · {siteConfig.footer}
|
|
15
|
-
<span className="footer-framework"> ·
|
|
17
|
+
<span className="footer-framework"> · {t.builtWith[0]}<a href="https://github.com/hedon954/garden" target="_blank" rel="noreferrer">Garden</a>{t.builtWith[1]}</span>
|
|
16
18
|
</>
|
|
17
19
|
)}
|
|
18
20
|
</p>
|
|
@@ -14,9 +14,11 @@ import type Fuse from "fuse.js";
|
|
|
14
14
|
import { MagnifyingGlass, X } from "@phosphor-icons/react";
|
|
15
15
|
import { brand, searchFull, searchIndexUrl } from "@garden/site-config";
|
|
16
16
|
import { createSearch } from "../../ui/garden/search-match.mjs";
|
|
17
|
-
import { cropMatchedText, highlightParts
|
|
17
|
+
import { cropMatchedText, highlightParts } from "../../ui/garden/search-preview.mjs";
|
|
18
18
|
import { resolveDark, themeSwitchProps } from "../../ui/garden/theme.mjs";
|
|
19
19
|
import { SITE_NAV, isCurrentNav } from "../../ui/garden/site-nav.mjs";
|
|
20
|
+
import { routeLang, ui } from "../lib/i18n";
|
|
21
|
+
import { HandbookLangSwitch } from "./HandbookLangSwitch";
|
|
20
22
|
import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
|
|
21
23
|
import { siteConfig } from "../site.config";
|
|
22
24
|
import { siteHref } from "../lib/site-href";
|
|
@@ -87,7 +89,7 @@ function loadSearchEngine() {
|
|
|
87
89
|
return engineLoad;
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
const nav = SITE_NAV.map((item) => ({ ...item, href: siteHref(item.href) }));
|
|
92
|
+
const nav = SITE_NAV.map((item) => ({ ...item, key: item.href, href: siteHref(item.href) }));
|
|
91
93
|
|
|
92
94
|
function rangesFor(
|
|
93
95
|
matches: readonly SearchMatch[] | undefined,
|
|
@@ -140,6 +142,7 @@ function searchPreview(
|
|
|
140
142
|
|
|
141
143
|
export function SiteHeader() {
|
|
142
144
|
const pathname = usePathname() || "/";
|
|
145
|
+
const t = ui(routeLang(pathname));
|
|
143
146
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
144
147
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
145
148
|
const [query, setQuery] = useState("");
|
|
@@ -313,29 +316,30 @@ export function SiteHeader() {
|
|
|
313
316
|
{brand.logo ? <span className="sh-mark" aria-hidden="true" dangerouslySetInnerHTML={{ __html: brand.logo }} /> : null}
|
|
314
317
|
{siteConfig.name}
|
|
315
318
|
</Link>
|
|
316
|
-
<nav className="sh-nav" aria-label=
|
|
319
|
+
<nav className="sh-nav" aria-label={t.siteNav}>
|
|
317
320
|
{nav.map((item) => (
|
|
318
321
|
<Link key={item.href} href={item.href} aria-current={current(item.href) ? "page" : undefined}>
|
|
319
|
-
{item.label}
|
|
322
|
+
{t.navLabels[item.key] ?? item.label}
|
|
320
323
|
</Link>
|
|
321
324
|
))}
|
|
322
325
|
</nav>
|
|
323
326
|
<div className="sh-tools">
|
|
324
|
-
<button ref={searchTriggerRef} className="sh-search" type="button" onClick={openSearch} onPointerEnter={prefetchSearch} onFocus={prefetchSearch} aria-label=
|
|
327
|
+
<button ref={searchTriggerRef} className="sh-search" type="button" onClick={openSearch} onPointerEnter={prefetchSearch} onFocus={prefetchSearch} aria-label={t.searchArticles} aria-expanded={searchOpen} aria-controls="site-search-dialog">
|
|
325
328
|
<svg viewBox="0 0 20 20" aria-hidden="true">
|
|
326
329
|
<circle cx="9" cy="9" r="5.8" />
|
|
327
330
|
<path d="M13.3 13.3 17 17" />
|
|
328
331
|
</svg>
|
|
329
|
-
<span className="lbl"
|
|
332
|
+
<span className="lbl">{t.search}</span>
|
|
330
333
|
<kbd><span className="kbd-mod kbd-mac">⌘</span><span className="kbd-mod kbd-other">Ctrl</span>{" "}K</kbd>
|
|
331
334
|
</button>
|
|
332
|
-
<button className="sh-icon sh-search-m" type="button" onClick={openSearch} onPointerDown={prefetchSearch} aria-label=
|
|
335
|
+
<button className="sh-icon sh-search-m" type="button" onClick={openSearch} onPointerDown={prefetchSearch} aria-label={t.searchArticles}>
|
|
333
336
|
<svg viewBox="0 0 20 20" aria-hidden="true">
|
|
334
337
|
<circle cx="9" cy="9" r="5.8" />
|
|
335
338
|
<path d="M13.3 13.3 17 17" />
|
|
336
339
|
</svg>
|
|
337
340
|
</button>
|
|
338
|
-
|
|
341
|
+
{pathname?.startsWith("/garden") ? <HandbookLangSwitch /> : null}
|
|
342
|
+
<button className="sh-icon sh-theme" type="button" onClick={toggleTheme} aria-label={dark ? t.toLight : t.toDark}>
|
|
339
343
|
<svg className="i-moon" viewBox="0 0 20 20" aria-hidden="true">
|
|
340
344
|
<path d="M16.5 12.2A6.8 6.8 0 0 1 7.8 3.5a6.8 6.8 0 1 0 8.7 8.7Z" />
|
|
341
345
|
</svg>
|
|
@@ -344,27 +348,27 @@ export function SiteHeader() {
|
|
|
344
348
|
<path d="M10 1.8v2M10 16.2v2M1.8 10h2M16.2 10h2M4.2 4.2l1.4 1.4M14.4 14.4l1.4 1.4M4.2 15.8l1.4-1.4M14.4 5.6l1.4-1.4" />
|
|
345
349
|
</svg>
|
|
346
350
|
</button>
|
|
347
|
-
<button className="sh-icon sh-menu-btn" type="button" aria-expanded={menuOpen} aria-controls="sh-menu" aria-label={menuOpen ?
|
|
351
|
+
<button className="sh-icon sh-menu-btn" type="button" aria-expanded={menuOpen} aria-controls="sh-menu" aria-label={menuOpen ? t.closeMenu : t.openMenu} onClick={() => setMenuOpen((open) => !open)}>
|
|
348
352
|
{menuOpen ? <X size={18} /> : <span aria-hidden="true">☰</span>}
|
|
349
353
|
</button>
|
|
350
354
|
{/* Without JavaScript the button above cannot open anything: these two links take its place
|
|
351
355
|
and open or close the menu through :target on the fixed anchors above (shell.css). */}
|
|
352
|
-
<a className="sh-icon sh-menu-nojs" id="sh-menu-open" href="#sh-menu-on" aria-label=
|
|
356
|
+
<a className="sh-icon sh-menu-nojs" id="sh-menu-open" href="#sh-menu-on" aria-label={t.openMenu}>
|
|
353
357
|
<span aria-hidden="true">☰</span>
|
|
354
358
|
</a>
|
|
355
|
-
<a className="sh-icon sh-menu-nojs-close" href="#sh-menu-off" aria-label=
|
|
359
|
+
<a className="sh-icon sh-menu-nojs-close" href="#sh-menu-off" aria-label={t.closeMenu}>
|
|
356
360
|
<X size={18} />
|
|
357
361
|
</a>
|
|
358
362
|
</div>
|
|
359
363
|
</div>
|
|
360
|
-
<nav className={`sh-menu${menuOpen ? " is-open" : ""}`} id="sh-menu" aria-label=
|
|
364
|
+
<nav className={`sh-menu${menuOpen ? " is-open" : ""}`} id="sh-menu" aria-label={t.siteNavMobile}>
|
|
361
365
|
{nav.map((item) => (
|
|
362
366
|
<Link key={item.href} href={item.href} aria-current={current(item.href) ? "page" : undefined} onClick={() => setMenuOpen(false)}>
|
|
363
|
-
{item.label}
|
|
367
|
+
{t.navLabels[item.key] ?? item.label}
|
|
364
368
|
</Link>
|
|
365
369
|
))}
|
|
366
370
|
<button className="sh-menu-theme" {...themeSwitchProps(dark, toggleTheme)}>
|
|
367
|
-
<span
|
|
371
|
+
<span>{t.darkMode}</span>
|
|
368
372
|
<span className="sh-switch" aria-hidden="true" />
|
|
369
373
|
</button>
|
|
370
374
|
</nav>
|
|
@@ -388,7 +392,7 @@ export function SiteHeader() {
|
|
|
388
392
|
onKeyDown={handleSearchKeyDown}
|
|
389
393
|
>
|
|
390
394
|
<h2 className="sr-only" id="site-search-title">
|
|
391
|
-
|
|
395
|
+
{t.searchTitle}
|
|
392
396
|
</h2>
|
|
393
397
|
<div className="search-input-wrap">
|
|
394
398
|
<MagnifyingGlass size={20} aria-hidden="true" />
|
|
@@ -400,8 +404,8 @@ export function SiteHeader() {
|
|
|
400
404
|
setActiveResult(-1);
|
|
401
405
|
resultRefs.current = [];
|
|
402
406
|
}}
|
|
403
|
-
placeholder=
|
|
404
|
-
aria-label=
|
|
407
|
+
placeholder={t.searchPlaceholder}
|
|
408
|
+
aria-label={t.searchInput}
|
|
405
409
|
role="combobox"
|
|
406
410
|
aria-expanded="true"
|
|
407
411
|
aria-autocomplete="list"
|
|
@@ -410,7 +414,7 @@ export function SiteHeader() {
|
|
|
410
414
|
activeResult >= 0 ? `site-search-result-${activeResult}` : undefined
|
|
411
415
|
}
|
|
412
416
|
/>
|
|
413
|
-
<button onClick={closeSearch} aria-label=
|
|
417
|
+
<button onClick={closeSearch} aria-label={t.closeSearch}>
|
|
414
418
|
<X size={19} />
|
|
415
419
|
</button>
|
|
416
420
|
</div>
|
|
@@ -418,11 +422,11 @@ export function SiteHeader() {
|
|
|
418
422
|
className="search-results"
|
|
419
423
|
id="site-search-results"
|
|
420
424
|
role="listbox"
|
|
421
|
-
aria-label=
|
|
425
|
+
aria-label={t.searchResults}
|
|
422
426
|
>
|
|
423
427
|
{query && engine && resultTotal === 0 ? null : (
|
|
424
428
|
<p className="search-label">
|
|
425
|
-
{query ?
|
|
429
|
+
{query ? t.resultCount(resultTotal) : t.recent}
|
|
426
430
|
</p>
|
|
427
431
|
)}
|
|
428
432
|
{results.map((result, index) => {
|
|
@@ -464,13 +468,13 @@ export function SiteHeader() {
|
|
|
464
468
|
{results.length === 0 && (
|
|
465
469
|
<p className="empty-state">
|
|
466
470
|
{/* The query is a React text child, so it is escaped. */}
|
|
467
|
-
{engine ?
|
|
471
|
+
{engine ? t.noResults(query.trim()) : engineFailed ? t.searchFailed : t.loading}
|
|
468
472
|
</p>
|
|
469
473
|
)}
|
|
470
474
|
</div>
|
|
471
475
|
<footer className="search-footer">
|
|
472
|
-
<span
|
|
473
|
-
<span><kbd>ESC</kbd>
|
|
476
|
+
<span>{t.fuzzy}</span>
|
|
477
|
+
<span><kbd>ESC</kbd> {t.escToClose}</span>
|
|
474
478
|
</footer>
|
|
475
479
|
</section>
|
|
476
480
|
</div>
|