hd-garden 0.5.12 → 0.5.14
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/components/ArticleTags.tsx +5 -3
- package/app/components/MarkdownArticle.tsx +29 -0
- package/app/components/SiteHeader.tsx +40 -26
- package/app/components/TagRedirect.tsx +3 -3
- package/app/globals.css +6 -0
- package/app/lib/tag-filter.ts +8 -0
- package/app/styles/shell.css +27 -4
- package/app/tags/page.tsx +2 -2
- package/build/static-rsc-path-plugin.mjs +43 -6
- package/package.json +1 -1
- package/scripts/redirects.mjs +3 -38
- package/ui/garden/internal-links.mjs +111 -0
- package/ui/garden/same-site.mjs +27 -0
- package/ui/garden/search-preview.mjs +6 -0
- package/ui/garden/site-nav.mjs +25 -0
- package/ui/garden/theme.mjs +15 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { articleTagHref } from "../lib/tag-filter";
|
|
2
3
|
|
|
4
|
+
/** Tags under an article title. Link, so following one is a client navigation like the rest of the site. */
|
|
3
5
|
export function ArticleTags({ tags }: { tags?: string[] }) {
|
|
4
6
|
const list = (tags ?? []).filter(Boolean);
|
|
5
7
|
if (!list.length) return null;
|
|
@@ -7,9 +9,9 @@ export function ArticleTags({ tags }: { tags?: string[] }) {
|
|
|
7
9
|
<ul className="article-tagrow" aria-label="标签">
|
|
8
10
|
{list.map((tag) => (
|
|
9
11
|
<li key={tag}>
|
|
10
|
-
<
|
|
12
|
+
<Link className="tag-chip p-category" href={articleTagHref(tag)}>
|
|
11
13
|
#{tag}
|
|
12
|
-
</
|
|
14
|
+
</Link>
|
|
13
15
|
</li>
|
|
14
16
|
))}
|
|
15
17
|
</ul>
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ReactNode,
|
|
10
10
|
} from "react";
|
|
11
11
|
import { articleHeadingBase, articleUsesHeadingNumbers, displayHeadingLevel, splitHeadingNumber } from "../lib/content";
|
|
12
|
+
import Link from "next/link";
|
|
12
13
|
import ReactMarkdown from "react-markdown";
|
|
13
14
|
import rehypeRaw from "rehype-raw";
|
|
14
15
|
import rehypeKatex from "rehype-katex";
|
|
@@ -24,6 +25,8 @@ import { imageSizes, linkAssets } from "@garden/generated-content";
|
|
|
24
25
|
import { imageDimensions, lookupImageSize } from "../../ui/garden/image-dims.mjs";
|
|
25
26
|
import { siteConfig, siteLinks } from "@garden/site-config";
|
|
26
27
|
import { articleLink, sameSiteMatcher } from "../../ui/garden/same-site.mjs";
|
|
28
|
+
import { columnRedirects, internalLinkResolver, readRedirects } from "../../ui/garden/internal-links.mjs";
|
|
29
|
+
import { columnHref, columns, postHref, posts, thoughts } from "../lib/content";
|
|
27
30
|
import { LinkCardRefresh } from "./LinkCardRefresh";
|
|
28
31
|
import { linkHost } from "../../scripts/link-meta-core.mjs";
|
|
29
32
|
import { cardDate } from "../../ui/garden/card-refresh.mjs";
|
|
@@ -31,6 +34,22 @@ import { cardDate } from "../../ui/garden/card-refresh.mjs";
|
|
|
31
34
|
// Full URLs back to this site are internal links, written as root-relative paths.
|
|
32
35
|
const sameSite = sameSiteMatcher(siteLinks?.address ?? "", siteLinks?.basePath ?? "");
|
|
33
36
|
|
|
37
|
+
// Links to other pages of the site render as Link. A link to an old address listed under
|
|
38
|
+
// redirects is rewritten to the page it moved to; a link that lands on no known page stays a plain anchor.
|
|
39
|
+
const internalRoute = internalLinkResolver({
|
|
40
|
+
redirects: [
|
|
41
|
+
...readRedirects(siteConfig),
|
|
42
|
+
...columnRedirects({ columns: [...new Set(columns.map((entry) => entry.column).filter(Boolean))].map((slug) => ({ slug })) }),
|
|
43
|
+
],
|
|
44
|
+
routes: [
|
|
45
|
+
"/", "/blog", "/columns", "/thoughts", "/tags", "/about",
|
|
46
|
+
...posts.map(postHref),
|
|
47
|
+
...columns.map(columnHref),
|
|
48
|
+
...thoughts.map((thought) => `/thoughts/${thought.slug}`),
|
|
49
|
+
],
|
|
50
|
+
basePath: siteLinks?.basePath ?? "",
|
|
51
|
+
});
|
|
52
|
+
|
|
34
53
|
/** The site's favicon, downloaded at build time; nothing when there is none. */
|
|
35
54
|
function Favicon({ href }: { href: string }) {
|
|
36
55
|
const src = linkAssets?.favicons?.[linkHost(href)];
|
|
@@ -262,6 +281,16 @@ export function MarkdownArticle({ content }: { content: string }) {
|
|
|
262
281
|
const { href, external } = articleLink(written, sameSite);
|
|
263
282
|
const domProps = { ...props };
|
|
264
283
|
Reflect.deleteProperty(domProps, "node");
|
|
284
|
+
// A link to another page of the site navigates in place, like the header and lists do.
|
|
285
|
+
// No viewport prefetch: a long article can link to many pages.
|
|
286
|
+
const route = external || domProps.target || domProps.download !== undefined ? null : internalRoute(href);
|
|
287
|
+
if (route) {
|
|
288
|
+
return (
|
|
289
|
+
<Link href={route} prefetch={false} {...domProps}>
|
|
290
|
+
{children}
|
|
291
|
+
</Link>
|
|
292
|
+
);
|
|
293
|
+
}
|
|
265
294
|
return (
|
|
266
295
|
<a
|
|
267
296
|
href={href}
|
|
@@ -14,7 +14,9 @@ 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 } from "../../ui/garden/search-preview.mjs";
|
|
17
|
+
import { cropMatchedText, highlightParts, noResultsText } from "../../ui/garden/search-preview.mjs";
|
|
18
|
+
import { resolveDark, themeSwitchProps } from "../../ui/garden/theme.mjs";
|
|
19
|
+
import { SITE_NAV, isCurrentNav } from "../../ui/garden/site-nav.mjs";
|
|
18
20
|
import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
|
|
19
21
|
import { siteConfig } from "../site.config";
|
|
20
22
|
|
|
@@ -84,13 +86,7 @@ function loadSearchEngine() {
|
|
|
84
86
|
return engineLoad;
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
const nav =
|
|
88
|
-
{ href: "/blog", label: "文章" },
|
|
89
|
-
{ href: "/thoughts", label: "随想" },
|
|
90
|
-
{ href: "/columns", label: "专栏" },
|
|
91
|
-
{ href: "/tags", label: "标签" },
|
|
92
|
-
{ href: "/about", label: "关于" },
|
|
93
|
-
];
|
|
89
|
+
const nav = SITE_NAV;
|
|
94
90
|
|
|
95
91
|
function rangesFor(
|
|
96
92
|
matches: readonly SearchMatch[] | undefined,
|
|
@@ -187,14 +183,22 @@ export function SiteHeader() {
|
|
|
187
183
|
});
|
|
188
184
|
}, []);
|
|
189
185
|
|
|
186
|
+
// One theme for the desktop button and the mobile menu switch. Until the
|
|
187
|
+
// reader picks one, it follows the system, also when the system changes.
|
|
190
188
|
useEffect(() => {
|
|
191
|
-
const
|
|
192
|
-
const
|
|
193
|
-
stored
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
189
|
+
const system = window.matchMedia("(prefers-color-scheme: dark)");
|
|
190
|
+
const apply = () => {
|
|
191
|
+
const stored = window.localStorage.getItem("theme");
|
|
192
|
+
const useDark = resolveDark(stored, system.matches);
|
|
193
|
+
document.documentElement.dataset.theme = useDark ? "dark" : "light";
|
|
194
|
+
setDark(useDark);
|
|
195
|
+
};
|
|
196
|
+
const frame = window.requestAnimationFrame(apply);
|
|
197
|
+
system.addEventListener("change", apply);
|
|
198
|
+
return () => {
|
|
199
|
+
window.cancelAnimationFrame(frame);
|
|
200
|
+
system.removeEventListener("change", apply);
|
|
201
|
+
};
|
|
198
202
|
}, []);
|
|
199
203
|
|
|
200
204
|
useEffect(() => {
|
|
@@ -293,16 +297,11 @@ export function SiteHeader() {
|
|
|
293
297
|
window.localStorage.setItem("theme", next ? "dark" : "light");
|
|
294
298
|
};
|
|
295
299
|
|
|
296
|
-
const current = (href: string) =>
|
|
297
|
-
if (href === "/blog") {
|
|
298
|
-
return pathname === "/blog" || pathname.startsWith("/blog/") || pathname.startsWith("/a2/");
|
|
299
|
-
}
|
|
300
|
-
return pathname === href || pathname.startsWith(`${href}/`);
|
|
301
|
-
};
|
|
300
|
+
const current = (href: string) => isCurrentNav(pathname, href);
|
|
302
301
|
|
|
303
302
|
return (
|
|
304
303
|
<>
|
|
305
|
-
<header className="sh-header">
|
|
304
|
+
<header className="sh-header" id="site-header">
|
|
306
305
|
<div className="sh-bar">
|
|
307
306
|
<Link href="/" className="sh-word">
|
|
308
307
|
{/* Build-time SVG from brand.logo, cleaned of scripts and handlers. */}
|
|
@@ -343,6 +342,14 @@ export function SiteHeader() {
|
|
|
343
342
|
<button className="sh-icon sh-menu-btn" type="button" aria-expanded={menuOpen} aria-controls="sh-menu" aria-label={menuOpen ? "关闭菜单" : "打开菜单"} onClick={() => setMenuOpen((open) => !open)}>
|
|
344
343
|
{menuOpen ? <X size={18} /> : <span aria-hidden="true">☰</span>}
|
|
345
344
|
</button>
|
|
345
|
+
{/* Without JavaScript the button above cannot open anything: these two links take its place
|
|
346
|
+
and open or close the menu through :target, like the TOC drawer (shell.css). */}
|
|
347
|
+
<a className="sh-icon sh-menu-nojs" id="sh-menu-open" href="#sh-menu" aria-label="打开菜单">
|
|
348
|
+
<span aria-hidden="true">☰</span>
|
|
349
|
+
</a>
|
|
350
|
+
<a className="sh-icon sh-menu-nojs-close" href="#site-header" aria-label="关闭菜单">
|
|
351
|
+
<X size={18} />
|
|
352
|
+
</a>
|
|
346
353
|
</div>
|
|
347
354
|
</div>
|
|
348
355
|
<nav className={`sh-menu${menuOpen ? " is-open" : ""}`} id="sh-menu" aria-label="站点导航(移动端)">
|
|
@@ -351,6 +358,10 @@ export function SiteHeader() {
|
|
|
351
358
|
{item.label}
|
|
352
359
|
</Link>
|
|
353
360
|
))}
|
|
361
|
+
<button className="sh-menu-theme" {...themeSwitchProps(dark, toggleTheme)}>
|
|
362
|
+
<span>深色模式</span>
|
|
363
|
+
<span className="sh-switch" aria-hidden="true" />
|
|
364
|
+
</button>
|
|
354
365
|
</nav>
|
|
355
366
|
</header>
|
|
356
367
|
|
|
@@ -404,9 +415,11 @@ export function SiteHeader() {
|
|
|
404
415
|
role="listbox"
|
|
405
416
|
aria-label="搜索结果"
|
|
406
417
|
>
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
418
|
+
{query && engine && resultTotal === 0 ? null : (
|
|
419
|
+
<p className="search-label">
|
|
420
|
+
{query ? `${resultTotal} 篇相关文章` : "最近内容"}
|
|
421
|
+
</p>
|
|
422
|
+
)}
|
|
410
423
|
{results.map((result, index) => {
|
|
411
424
|
const preview = searchPreview(result.item, result.matches);
|
|
412
425
|
return (
|
|
@@ -445,7 +458,8 @@ export function SiteHeader() {
|
|
|
445
458
|
})}
|
|
446
459
|
{results.length === 0 && (
|
|
447
460
|
<p className="empty-state">
|
|
448
|
-
{
|
|
461
|
+
{/* The query is a React text child, so it is escaped. */}
|
|
462
|
+
{engine ? noResultsText(query) : engineFailed ? "搜索暂时打不开,请稍后再试。" : "正在载入…"}
|
|
449
463
|
</p>
|
|
450
464
|
)}
|
|
451
465
|
</div>
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
4
4
|
import { useEffect } from "react";
|
|
5
|
-
import { TAG_PARAM, selectedTags, tagFilterHref } from "../lib/tag-filter";
|
|
5
|
+
import { ARTICLE_TAG_LIST, TAG_PARAM, selectedTags, tagFilterHref } from "../lib/tag-filter";
|
|
6
6
|
|
|
7
|
-
/** Old `/tags?t=X` links open the same filter on `/blog
|
|
7
|
+
/** Old `/tags?t=X` links open the same filter on `/blog/?t=X`. */
|
|
8
8
|
export function TagRedirect() {
|
|
9
9
|
const router = useRouter();
|
|
10
10
|
const params = useSearchParams();
|
|
11
11
|
const tags = selectedTags(params.getAll(TAG_PARAM));
|
|
12
|
-
const target = tags.length ? tagFilterHref(tags) : "";
|
|
12
|
+
const target = tags.length ? tagFilterHref(tags, ARTICLE_TAG_LIST) : "";
|
|
13
13
|
useEffect(() => {
|
|
14
14
|
if (target) router.replace(target);
|
|
15
15
|
}, [router, target]);
|
package/app/globals.css
CHANGED
|
@@ -3730,6 +3730,12 @@ html[data-theme="dark"] .search-panel {
|
|
|
3730
3730
|
color: var(--muted);
|
|
3731
3731
|
font-size: var(--fs-small);
|
|
3732
3732
|
text-align: center;
|
|
3733
|
+
overflow-wrap: anywhere;
|
|
3734
|
+
}
|
|
3735
|
+
|
|
3736
|
+
/* No results: the one line sits in the middle of the list area. */
|
|
3737
|
+
.search-results:has(> .empty-state:only-child) {
|
|
3738
|
+
align-content: center;
|
|
3733
3739
|
}
|
|
3734
3740
|
|
|
3735
3741
|
.search-footer {
|
package/app/lib/tag-filter.ts
CHANGED
|
@@ -145,6 +145,14 @@ export function tagFilterHref(selected: readonly string[], pathname = "/blog") {
|
|
|
145
145
|
return query ? `${pathname}?${query}` : pathname;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Where a tag link points (tags on an article, the /tags page): the list page at its exported address
|
|
150
|
+
* (`/blog/`, with the slash, so the static host does not answer /blog?t=… with
|
|
151
|
+
* a 301 first), filtered to that one tag. The tag is URL-encoded.
|
|
152
|
+
*/
|
|
153
|
+
export const ARTICLE_TAG_LIST = "/blog/";
|
|
154
|
+
export const articleTagHref = (tag: string) => tagFilterHref([tag], ARTICLE_TAG_LIST);
|
|
155
|
+
|
|
148
156
|
/** 「共 N 篇」, or 「2 个标签,共 5 篇」 with a selection. */
|
|
149
157
|
export function tagSummary(selectedCount: number, resultCount: number) {
|
|
150
158
|
return selectedCount ? `${selectedCount} 个标签,共 ${resultCount} 篇` : `共 ${resultCount} 篇`;
|
package/app/styles/shell.css
CHANGED
|
@@ -65,10 +65,9 @@ html[data-mod="other"] .sh-search .kbd-other{display:inline}
|
|
|
65
65
|
.sh-icon{display:inline-grid;place-items:center;width:40px;height:40px;border:0;background:transparent;color:var(--soft);border-radius:var(--r);cursor:pointer;transition:background-color .15s ease,color .15s ease}
|
|
66
66
|
@media (hover:hover){.sh-icon:hover{background:var(--tint);color:var(--ink)}}
|
|
67
67
|
.sh-menu-btn,.sh-search-m{display:none}
|
|
68
|
+
.sh-menu-nojs,.sh-menu-nojs-close{display:none}
|
|
68
69
|
.i-sun,html[data-theme="dark"] .i-moon{display:none}
|
|
69
70
|
html[data-theme="dark"] .i-sun{display:inline}
|
|
70
|
-
/* No JavaScript: the theme cannot be switched, the page follows the system (build/dark-fallback-postcss.mjs). */
|
|
71
|
-
@media (scripting:none){.sh-theme{display:none}}
|
|
72
71
|
.sh-menu{display:none}
|
|
73
72
|
|
|
74
73
|
/* ---------- page frame ---------- */
|
|
@@ -272,8 +271,13 @@ html[data-theme="dark"] .sh-pal-scrim{background:rgba(0,0,0,.55)}
|
|
|
272
271
|
.sh-menu.is-open{opacity:1;transform:none;visibility:visible;transition:opacity .18s ease,transform .18s var(--ease)}
|
|
273
272
|
.sh-menu a{display:flex;align-items:center;min-height:52px;font-size:var(--fs-lead);color:var(--text);text-decoration:none;border-bottom:1px solid var(--rule)}
|
|
274
273
|
.sh-menu a[aria-current="page"],.sh-menu a[aria-current="true"]{color:var(--blue);font-weight:600}
|
|
275
|
-
|
|
276
|
-
.sh-menu
|
|
274
|
+
/* Theme switch at the bottom of the menu: the same toggle as the desktop button. */
|
|
275
|
+
.sh-menu a:last-of-type{border-bottom:0}
|
|
276
|
+
.sh-menu-theme{display:flex;align-items:center;justify-content:space-between;width:100%;min-height:44px;margin-top:8px;padding:0;border:0;border-top:1px solid var(--rule);background:none;font:400 var(--fs-body)/1 var(--sans);color:var(--text);cursor:pointer;text-align:left}
|
|
277
|
+
.sh-switch{position:relative;flex:none;width:40px;height:24px;border-radius:12px;background:var(--rule-strong);transition:background-color .18s ease}
|
|
278
|
+
.sh-switch::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:var(--card);box-shadow:0 1px 2px rgb(20 20 19 / 18%);transition:transform .18s var(--ease)}
|
|
279
|
+
.sh-menu-theme[aria-checked="true"] .sh-switch{background:var(--ink)}
|
|
280
|
+
.sh-menu-theme[aria-checked="true"] .sh-switch::after{transform:translateX(16px);background:var(--paper)}
|
|
277
281
|
.sh-theme{display:none}
|
|
278
282
|
.sh-main{padding:0 20px}
|
|
279
283
|
.sh-head{padding:40px 0 24px}
|
|
@@ -299,6 +303,25 @@ html[data-theme="dark"] .sh-pal-scrim{background:rgba(0,0,0,.55)}
|
|
|
299
303
|
.sh-pal-foot{display:none}
|
|
300
304
|
.sh-pal-input{font-size:17px;height:56px}
|
|
301
305
|
}
|
|
306
|
+
/* No JavaScript: the theme cannot be switched, the page follows the system (build/dark-fallback-postcss.mjs).
|
|
307
|
+
Keep this after every rule that shows the toggles (the 820px block above sets .sh-menu-theme to flex);
|
|
308
|
+
tests/header-nojs.test.mjs checks the order in the built CSS. */
|
|
309
|
+
@media (scripting:none){.sh-theme,.sh-menu-theme{display:none}}
|
|
310
|
+
/* Search needs JavaScript too: without it the desktop 「搜索」 button would do nothing. */
|
|
311
|
+
@media (scripting:none){.sh-search{display:none}}
|
|
312
|
+
/* No JavaScript: the ☰ button cannot open the menu, so a ☰ link in the same place opens it through :target,
|
|
313
|
+
as the TOC drawer does, and ✕ closes it by targeting the header. With JavaScript none of this applies. */
|
|
314
|
+
@media (max-width:820px) and (scripting:none){
|
|
315
|
+
.sh-menu-btn{display:none}
|
|
316
|
+
/* Search needs JavaScript (the index loads on demand); without it the button would do nothing. */
|
|
317
|
+
.sh-search-m{display:none}
|
|
318
|
+
.sh-menu-nojs{display:inline-grid;width:44px;height:44px;text-decoration:none}
|
|
319
|
+
/* The menu sits right under the sticky header: opening it should not scroll the page. */
|
|
320
|
+
.sh-menu{scroll-margin-top:var(--hdr)}
|
|
321
|
+
.sh-menu:target{opacity:1;transform:none;visibility:visible;transition:opacity .18s ease,transform .18s var(--ease)}
|
|
322
|
+
body:has(#sh-menu:target) .sh-menu-nojs{display:none}
|
|
323
|
+
body:has(#sh-menu:target) .sh-menu-nojs-close{display:inline-grid;width:44px;height:44px;text-decoration:none}
|
|
324
|
+
}
|
|
302
325
|
@media (prefers-reduced-motion:reduce){
|
|
303
326
|
*,*::before,*::after{animation:none!important;transition:none!important}
|
|
304
327
|
html{scroll-behavior:auto!important}
|
package/app/tags/page.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import { Suspense } from "react";
|
|
3
3
|
import { posts } from "../lib/content";
|
|
4
|
-
import {
|
|
4
|
+
import { articleTagHref, tagCounts } from "../lib/tag-filter";
|
|
5
5
|
import { TagRedirect } from "../components/TagRedirect";
|
|
6
6
|
import { siteDocumentTitle } from "../site.config";
|
|
7
7
|
|
|
@@ -25,7 +25,7 @@ export default function TagsPage() {
|
|
|
25
25
|
<ul className="sh-tagchips sh-tagindex" aria-label="全部标签">
|
|
26
26
|
{tags.map(([tag, count]) => (
|
|
27
27
|
<li key={tag}>
|
|
28
|
-
<Link className="sh-tagchip" href={
|
|
28
|
+
<Link className="sh-tagchip" href={articleTagHref(tag)}>
|
|
29
29
|
#{tag}<span className="n num">{count}</span>
|
|
30
30
|
</Link>
|
|
31
31
|
</li>
|
|
@@ -7,8 +7,27 @@
|
|
|
7
7
|
// any client navigation home, hit a 404. With output: "export" this plugin
|
|
8
8
|
// changes the address builder (toRscRequestPath) so "/" asks for `/index.rsc`.
|
|
9
9
|
// Every other path already matches the exported file. Prefetching stays on.
|
|
10
|
+
//
|
|
11
|
+
// Static hosts do not know the .rsc extension either: GitHub Pages serves it as
|
|
12
|
+
// application/octet-stream, and vinext only accepts text/x-component, so every
|
|
13
|
+
// client navigation fell back to a full page load, aimed at the payload URL
|
|
14
|
+
// minus ".rsc" (for home that is "/index"). In the browser navigation code the
|
|
15
|
+
// export also accepts application/octet-stream, and a fallback to "/index"
|
|
16
|
+
// goes to "/". Only a 2xx response with a body counts: vinext's own
|
|
17
|
+
// `!navResponse.ok` check stays in front, and the build stops if it is gone.
|
|
18
|
+
//
|
|
19
|
+
// Rewrites vinext 0.0.50. Remove this plugin once vinext fixes it upstream
|
|
20
|
+
// (draft issue: /workspace/dev/vinext-issue.md); a vinext whose code no longer
|
|
21
|
+
// matches stops the build.
|
|
10
22
|
|
|
11
23
|
const TARGET = /[\\/]vinext[\\/]dist[\\/]server[\\/]app-rsc-cache-busting\.js$/u;
|
|
24
|
+
const NAVIGATION = /[\\/]vinext[\\/]dist[\\/]server[\\/]app-browser-entry\.js$/u;
|
|
25
|
+
const RSC_CHECK = 'const isRscResponse = (navResponse.headers.get("content-type") ?? "").startsWith("text/x-component");';
|
|
26
|
+
const RSC_CHECK_EXPORTED = 'const isRscResponse = /^(text\\/x-component|application\\/octet-stream)/.test(navResponse.headers.get("content-type") ?? "");';
|
|
27
|
+
const FALLBACK = "let pathname = stripRscSuffix(parsed.pathname);";
|
|
28
|
+
// Must stay: any non-2xx response is a failure whatever its content-type.
|
|
29
|
+
const FAILURE = "if (!navResponse.ok || !isRscResponse || !navResponse.body) {";
|
|
30
|
+
const FALLBACK_EXPORTED = 'let pathname = stripRscSuffix(parsed.pathname); if (pathname === "/index") pathname = "/";';
|
|
12
31
|
const ADDRESS = "return `${pathname.length > 1 && pathname.endsWith(\"/\") ? pathname.slice(0, -1) : pathname}.rsc${query}`;";
|
|
13
32
|
const EXPORTED = "return `${pathname === \"/\" ? \"/index\" : pathname.length > 1 && pathname.endsWith(\"/\") ? pathname.slice(0, -1) : pathname}.rsc${query}`;";
|
|
14
33
|
|
|
@@ -20,21 +39,39 @@ export function exportRscAddress(code, id = "app-rsc-cache-busting.js") {
|
|
|
20
39
|
return code.replace(ADDRESS, EXPORTED);
|
|
21
40
|
}
|
|
22
41
|
|
|
42
|
+
/** Rewrite vinext's client navigation for static hosts; throws if vinext changed shape. */
|
|
43
|
+
export function exportNavigation(code, id = "app-browser-entry.js") {
|
|
44
|
+
for (const needle of [RSC_CHECK, FALLBACK, FAILURE]) {
|
|
45
|
+
if (code.split(needle).length !== 2) {
|
|
46
|
+
throw new Error(`garden static RSC path: could not find \`${needle}\` exactly once in ${id}. vinext changed; update build/static-rsc-path-plugin.mjs (or remove it if vinext fixed static hosting).`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return code.replace(RSC_CHECK, RSC_CHECK_EXPORTED).replace(FALLBACK, FALLBACK_EXPORTED);
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
export default function staticRscPath({ enabled = process.env.STATIC_EXPORT === "1" } = {}) {
|
|
24
|
-
|
|
53
|
+
const seen = new Set();
|
|
25
54
|
return {
|
|
26
55
|
name: "garden-static-rsc-path",
|
|
27
56
|
apply: "build",
|
|
28
57
|
transform(code, id) {
|
|
29
58
|
// Browser bundle only: the prerender server still answers "/.rsc" itself.
|
|
30
|
-
if (!enabled || this.environment?.name !== "client"
|
|
31
|
-
|
|
32
|
-
|
|
59
|
+
if (!enabled || this.environment?.name !== "client") return null;
|
|
60
|
+
const file = id.split("?")[0];
|
|
61
|
+
if (TARGET.test(file)) {
|
|
62
|
+
seen.add("address");
|
|
63
|
+
return { code: exportRscAddress(code, id), map: null };
|
|
64
|
+
}
|
|
65
|
+
if (NAVIGATION.test(file)) {
|
|
66
|
+
seen.add("navigation");
|
|
67
|
+
return { code: exportNavigation(code, id), map: null };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
33
70
|
},
|
|
34
71
|
buildEnd(error) {
|
|
35
72
|
// The client build always pulls the address builder in; not seeing it means vinext moved it.
|
|
36
|
-
if (enabled && !error && this.environment?.name === "client" &&
|
|
37
|
-
this.error("garden static RSC path: vinext's app-rsc-cache-busting.js was not part of the client build; update build/static-rsc-path-plugin.mjs.");
|
|
73
|
+
if (enabled && !error && this.environment?.name === "client" && seen.size < 2) {
|
|
74
|
+
this.error("garden static RSC path: vinext's app-rsc-cache-busting.js or app-browser-entry.js was not part of the client build; update build/static-rsc-path-plugin.mjs.");
|
|
38
75
|
}
|
|
39
76
|
},
|
|
40
77
|
};
|
package/package.json
CHANGED
package/scripts/redirects.mjs
CHANGED
|
@@ -23,44 +23,9 @@ import { configuredSiteAddress } from "../ui/garden/same-site.mjs";
|
|
|
23
23
|
|
|
24
24
|
const escapeHtml = (value) => String(value).replace(/&/gu, "&").replace(/"/gu, """).replace(/</gu, "<").replace(/>/gu, ">");
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
if (!map) return [];
|
|
30
|
-
if (typeof map !== "object" || Array.isArray(map)) {
|
|
31
|
-
warn("redirects 应写成「旧路径: 新地址」的映射,已忽略。");
|
|
32
|
-
return [];
|
|
33
|
-
}
|
|
34
|
-
const pairs = [];
|
|
35
|
-
for (const [rawFrom, rawTo] of Object.entries(map)) {
|
|
36
|
-
let from = String(rawFrom).trim();
|
|
37
|
-
const to = String(rawTo ?? "").trim();
|
|
38
|
-
try {
|
|
39
|
-
from = decodeURI(from);
|
|
40
|
-
} catch {
|
|
41
|
-
// keep as written
|
|
42
|
-
}
|
|
43
|
-
if (!from.startsWith("/") || from.startsWith("//") || from === "/" || /(^|\/)\.\.?(\/|$)/u.test(from) || /[?#]/u.test(from)) {
|
|
44
|
-
warn(`redirects:旧路径 ${rawFrom} 必须是站内路径(以 / 开头,不带 ?、# 和 ..),已跳过。`);
|
|
45
|
-
continue;
|
|
46
|
-
}
|
|
47
|
-
if (!(to.startsWith("/") && !to.startsWith("//")) && !/^https?:\/\//iu.test(to)) {
|
|
48
|
-
warn(`redirects:${rawFrom} 的新地址 ${rawTo} 应以 / 开头或是完整网址,已跳过。`);
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
pairs.push({ from, to });
|
|
52
|
-
}
|
|
53
|
-
return pairs;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** `/columns/<slug>/` → `/columns/#<slug>` for every valid column slug in columns.yaml. */
|
|
57
|
-
export function columnRedirects(columnsConfig = {}) {
|
|
58
|
-
const columns = Array.isArray(columnsConfig?.columns) ? columnsConfig.columns : [];
|
|
59
|
-
return columns
|
|
60
|
-
.map((column) => (column && typeof column.slug === "string" ? column.slug.trim() : ""))
|
|
61
|
-
.filter((slug) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug))
|
|
62
|
-
.map((slug) => ({ from: `/columns/${slug}/`, to: `/columns/#${slug}` }));
|
|
63
|
-
}
|
|
26
|
+
// Shared with the article renderer, which rewrites links to old addresses (ui/garden/internal-links.mjs).
|
|
27
|
+
export { columnRedirects, readRedirects } from "../ui/garden/internal-links.mjs";
|
|
28
|
+
import { columnRedirects, readRedirects } from "../ui/garden/internal-links.mjs";
|
|
64
29
|
|
|
65
30
|
/** The file an old path is served from: `/a/b/` and `/a/b` → a/b/index.html, `/a/b.html` → a/b.html. */
|
|
66
31
|
export function redirectFile(from) {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-site links in article bodies.
|
|
3
|
+
*
|
|
4
|
+
* `redirects` in site.config.yaml maps old addresses to where pages live now,
|
|
5
|
+
* and a static export answers the old address with a small meta-refresh page
|
|
6
|
+
* (scripts/redirects.mjs). A client navigation cannot follow that page: there
|
|
7
|
+
* is no RSC payload at the old address. So when an article links to an old
|
|
8
|
+
* address, the rendered link points straight at the new one, keeping the
|
|
9
|
+
* link's own ?query and #hash.
|
|
10
|
+
*
|
|
11
|
+
* `internalRoute` returns the address to give the framework Link, or null to
|
|
12
|
+
* keep a plain anchor: a link that leaves the site, ends on a file, runs into a
|
|
13
|
+
* redirect loop or an external target, or lands on no known page.
|
|
14
|
+
*/
|
|
15
|
+
import { clientRoute } from "./same-site.mjs";
|
|
16
|
+
|
|
17
|
+
/** Valid `{ from, to }` pairs from the config; anything malformed is reported and skipped. */
|
|
18
|
+
export function readRedirects(config = {}, warn = () => {}) {
|
|
19
|
+
const map = config?.redirects;
|
|
20
|
+
if (!map) return [];
|
|
21
|
+
if (typeof map !== "object" || Array.isArray(map)) {
|
|
22
|
+
warn("redirects 应写成「旧路径: 新地址」的映射,已忽略。");
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const pairs = [];
|
|
26
|
+
for (const [rawFrom, rawTo] of Object.entries(map)) {
|
|
27
|
+
let from = String(rawFrom).trim();
|
|
28
|
+
const to = String(rawTo ?? "").trim();
|
|
29
|
+
try {
|
|
30
|
+
from = decodeURI(from);
|
|
31
|
+
} catch {
|
|
32
|
+
// keep as written
|
|
33
|
+
}
|
|
34
|
+
if (!from.startsWith("/") || from.startsWith("//") || from === "/" || /(^|\/)\.\.?(\/|$)/u.test(from) || /[?#]/u.test(from)) {
|
|
35
|
+
warn(`redirects:旧路径 ${rawFrom} 必须是站内路径(以 / 开头,不带 ?、# 和 ..),已跳过。`);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!(to.startsWith("/") && !to.startsWith("//")) && !/^https?:\/\//iu.test(to)) {
|
|
39
|
+
warn(`redirects:${rawFrom} 的新地址 ${rawTo} 应以 / 开头或是完整网址,已跳过。`);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
pairs.push({ from, to });
|
|
43
|
+
}
|
|
44
|
+
return pairs;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `/columns/<slug>/` → `/columns/#<slug>` for every valid column slug in columns.yaml. */
|
|
48
|
+
export function columnRedirects(columnsConfig = {}) {
|
|
49
|
+
const columns = Array.isArray(columnsConfig?.columns) ? columnsConfig.columns : [];
|
|
50
|
+
return columns
|
|
51
|
+
.map((column) => (column && typeof column.slug === "string" ? column.slug.trim() : ""))
|
|
52
|
+
.filter((slug) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug))
|
|
53
|
+
.map((slug) => ({ from: `/columns/${slug}/`, to: `/columns/#${slug}` }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One key per page address: decoded, without a trailing slash (`/` stays `/`). */
|
|
57
|
+
export function routeKey(pathname) {
|
|
58
|
+
let value = String(pathname ?? "");
|
|
59
|
+
try {
|
|
60
|
+
value = decodeURI(value);
|
|
61
|
+
} catch {
|
|
62
|
+
// keep as written
|
|
63
|
+
}
|
|
64
|
+
return value.length > 1 ? value.replace(/\/+$/u, "") || "/" : value;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const splitHref = (href) => {
|
|
68
|
+
const hashAt = href.indexOf("#");
|
|
69
|
+
const beforeHash = hashAt < 0 ? href : href.slice(0, hashAt);
|
|
70
|
+
const hash = hashAt < 0 ? "" : href.slice(hashAt);
|
|
71
|
+
const queryAt = beforeHash.indexOf("?");
|
|
72
|
+
return { pathname: queryAt < 0 ? beforeHash : beforeHash.slice(0, queryAt), search: queryAt < 0 ? "" : beforeHash.slice(queryAt), hash };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the resolver once per render.
|
|
77
|
+
* - redirects: `{ from, to }` pairs (readRedirects + columnRedirects).
|
|
78
|
+
* - routes: every page address the site builds, without the base path.
|
|
79
|
+
* - basePath: NEXT_PUBLIC_BASE_PATH; Link adds it back.
|
|
80
|
+
*/
|
|
81
|
+
export function internalLinkResolver({ redirects = [], routes = [], basePath = "" } = {}) {
|
|
82
|
+
const moved = new Map();
|
|
83
|
+
for (const { from, to } of redirects) if (!moved.has(routeKey(from))) moved.set(routeKey(from), to);
|
|
84
|
+
const pages = new Set([...routes].map(routeKey));
|
|
85
|
+
const base = String(basePath ?? "").trim().replace(/\/+$/u, "");
|
|
86
|
+
return function internalRoute(href) {
|
|
87
|
+
if (typeof href !== "string") return null;
|
|
88
|
+
let value = href.trim();
|
|
89
|
+
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
|
90
|
+
if (base) {
|
|
91
|
+
if (value !== base && !value.startsWith(`${base}/`) && !value.startsWith(`${base}?`) && !value.startsWith(`${base}#`)) return null;
|
|
92
|
+
value = value.slice(base.length) || "/";
|
|
93
|
+
if (!value.startsWith("/")) value = `/${value}`;
|
|
94
|
+
}
|
|
95
|
+
const own = splitHref(value);
|
|
96
|
+
let target = own;
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
while (moved.has(routeKey(target.pathname))) {
|
|
99
|
+
const key = routeKey(target.pathname);
|
|
100
|
+
if (seen.has(key) || seen.size > 16) return null;
|
|
101
|
+
seen.add(key);
|
|
102
|
+
const to = moved.get(key);
|
|
103
|
+
if (!to.startsWith("/") || to.startsWith("//")) return null;
|
|
104
|
+
const next = splitHref(to);
|
|
105
|
+
// The link's own ?query and #hash win; otherwise the redirect target's.
|
|
106
|
+
target = { pathname: next.pathname || "/", search: own.search || next.search, hash: own.hash || next.hash };
|
|
107
|
+
}
|
|
108
|
+
if (!pages.has(routeKey(target.pathname))) return null;
|
|
109
|
+
return clientRoute(`${target.pathname}${target.search}${target.hash}`);
|
|
110
|
+
};
|
|
111
|
+
}
|
package/ui/garden/same-site.mjs
CHANGED
|
@@ -61,3 +61,30 @@ export function articleLink(href, sameSite) {
|
|
|
61
61
|
if (local !== null) return { href: local, external: false };
|
|
62
62
|
return { href, external: /^(https?:)?\/\//iu.test(href) };
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/** Static files the site serves next to its pages; these always load as files. */
|
|
66
|
+
const FILE_PREFIXES = ["/api/", "/assets/", "/images/", "/media/", "/link-assets/", "/_next/", "/fonts/"];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The route to hand to the framework Link for an in-article link, or null to
|
|
70
|
+
* keep a plain anchor. Only a root-relative page address qualifies: no
|
|
71
|
+
* protocol or host, not just a hash, no file extension in the last segment,
|
|
72
|
+
* none of the static file folders. Link adds the base path itself, so it is
|
|
73
|
+
* taken off here; a path outside the base path stays a plain anchor.
|
|
74
|
+
*/
|
|
75
|
+
export function clientRoute(href, basePath = "") {
|
|
76
|
+
if (typeof href !== "string") return null;
|
|
77
|
+
const value = href.trim();
|
|
78
|
+
if (!value.startsWith("/") || value.startsWith("//")) return null;
|
|
79
|
+
const base = normalizeBase(basePath);
|
|
80
|
+
const [pathname] = value.split(/[?#]/u);
|
|
81
|
+
let route = pathname;
|
|
82
|
+
if (base) {
|
|
83
|
+
if (route !== base && !route.startsWith(`${base}/`)) return null;
|
|
84
|
+
route = route.slice(base.length) || "/";
|
|
85
|
+
}
|
|
86
|
+
if (FILE_PREFIXES.some((prefix) => route.startsWith(prefix) || `${route}/` === prefix)) return null;
|
|
87
|
+
const last = route.split("/").filter(Boolean).at(-1) ?? "";
|
|
88
|
+
if (/\.[a-z0-9]{1,8}$/iu.test(last)) return null;
|
|
89
|
+
return `${route}${value.slice(pathname.length)}`;
|
|
90
|
+
}
|
|
@@ -67,3 +67,9 @@ export function highlightParts(text, ranges) {
|
|
|
67
67
|
if (cursor < text.length) parts.push({ text: text.slice(cursor), matched: false });
|
|
68
68
|
return parts;
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/** The one line shown when a search finds nothing. Render it as text (React escapes it). */
|
|
72
|
+
export function noResultsText(query) {
|
|
73
|
+
const value = String(query ?? "").trim();
|
|
74
|
+
return value ? `没有找到和「${value}」相关的文章` : "还没有可以搜索的内容";
|
|
75
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The header's site navigation. Hrefs carry the trailing slash: a static
|
|
3
|
+
* export serves these pages at /blog/ and so on, and the host answers /blog
|
|
4
|
+
* with a 301 first (new tab, no JavaScript, crawlers).
|
|
5
|
+
*/
|
|
6
|
+
export const SITE_NAV = [
|
|
7
|
+
{ href: "/blog/", label: "文章" },
|
|
8
|
+
{ href: "/thoughts/", label: "随想" },
|
|
9
|
+
{ href: "/columns/", label: "专栏" },
|
|
10
|
+
{ href: "/tags/", label: "标签" },
|
|
11
|
+
{ href: "/about/", label: "关于" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const trim = (value) => (value.length > 1 ? value.replace(/\/+$/u, "") : value);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a nav item is the current section. The pathname may come with or
|
|
18
|
+
* without the trailing slash; articles count as 文章 (/blog/…, /a2/…).
|
|
19
|
+
*/
|
|
20
|
+
export function isCurrentNav(pathname, href) {
|
|
21
|
+
const path = trim(pathname || "/");
|
|
22
|
+
const section = trim(href);
|
|
23
|
+
if (section === "/blog") return path === "/blog" || path.startsWith("/blog/") || path.startsWith("/a2/");
|
|
24
|
+
return path === section || path.startsWith(`${section}/`);
|
|
25
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Theme logic shared by the desktop button and the mobile menu switch.
|
|
2
|
+
|
|
3
|
+
/** Dark when the reader chose dark, or chose nothing and the system is dark. */
|
|
4
|
+
export function resolveDark(stored, systemDark) {
|
|
5
|
+
return stored === "dark" || (!stored && Boolean(systemDark));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Props for the menu's 「深色模式」 row: a native button, so the whole row is
|
|
10
|
+
* clickable and Space and Enter toggle it; role="switch" with aria-checked
|
|
11
|
+
* taken from the same state on every render.
|
|
12
|
+
*/
|
|
13
|
+
export function themeSwitchProps(dark, onToggle) {
|
|
14
|
+
return { type: "button", role: "switch", "aria-checked": Boolean(dark), onClick: onToggle };
|
|
15
|
+
}
|