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.
@@ -1,5 +1,7 @@
1
- import { tagFilterHref } from "../lib/tag-filter";
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
- <a className="tag-chip p-category" href={tagFilterHref([tag])}>
12
+ <Link className="tag-chip p-category" href={articleTagHref(tag)}>
11
13
  #{tag}
12
- </a>
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 stored = window.localStorage.getItem("theme");
192
- const useDark =
193
- stored === "dark" ||
194
- (!stored && window.matchMedia("(prefers-color-scheme: dark)").matches);
195
- document.documentElement.dataset.theme = useDark ? "dark" : "light";
196
- const frame = window.requestAnimationFrame(() => setDark(useDark));
197
- return () => window.cancelAnimationFrame(frame);
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
- <p className="search-label">
408
- {query ? `${resultTotal} 篇相关文章` : "最近内容"}
409
- </p>
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
- {engine ? "没有找到,换个更宽松的关键词试试。" : engineFailed ? "搜索暂时打不开,请稍后再试。" : "正在载入…"}
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?t=X`. */
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 {
@@ -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} 篇`;
@@ -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
- .sh-menu .theme-row{display:flex;align-items:center;justify-content:space-between;min-height:56px;font-size:15px;color:var(--soft)}
276
- .sh-menu .theme-row button{font:400 15px/1 var(--sans);color:var(--ink);background:var(--card);border:1px solid var(--rule);border-radius:var(--r);padding:0 14px;min-height:40px;cursor:pointer}
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 { tagCounts, tagFilterHref } from "../lib/tag-filter";
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={tagFilterHref([tag])}>
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
- let seen = false;
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" || !TARGET.test(id.split("?")[0])) return null;
31
- seen = true;
32
- return { code: exportRscAddress(code, id), map: null };
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" && !seen) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -23,44 +23,9 @@ import { configuredSiteAddress } from "../ui/garden/same-site.mjs";
23
23
 
24
24
  const escapeHtml = (value) => String(value).replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
25
25
 
26
- /** Valid `{ from, to }` pairs from the config; anything malformed is reported and skipped. */
27
- export function readRedirects(config = {}, warn = () => {}) {
28
- const map = config?.redirects;
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
+ }
@@ -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
+ }