hd-garden 0.5.12 → 0.5.13

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.
@@ -14,7 +14,8 @@ 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";
18
19
  import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
19
20
  import { siteConfig } from "../site.config";
20
21
 
@@ -187,14 +188,22 @@ export function SiteHeader() {
187
188
  });
188
189
  }, []);
189
190
 
191
+ // One theme for the desktop button and the mobile menu switch. Until the
192
+ // reader picks one, it follows the system, also when the system changes.
190
193
  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);
194
+ const system = window.matchMedia("(prefers-color-scheme: dark)");
195
+ const apply = () => {
196
+ const stored = window.localStorage.getItem("theme");
197
+ const useDark = resolveDark(stored, system.matches);
198
+ document.documentElement.dataset.theme = useDark ? "dark" : "light";
199
+ setDark(useDark);
200
+ };
201
+ const frame = window.requestAnimationFrame(apply);
202
+ system.addEventListener("change", apply);
203
+ return () => {
204
+ window.cancelAnimationFrame(frame);
205
+ system.removeEventListener("change", apply);
206
+ };
198
207
  }, []);
199
208
 
200
209
  useEffect(() => {
@@ -351,6 +360,10 @@ export function SiteHeader() {
351
360
  {item.label}
352
361
  </Link>
353
362
  ))}
363
+ <button className="sh-menu-theme" {...themeSwitchProps(dark, toggleTheme)}>
364
+ <span>深色模式</span>
365
+ <span className="sh-switch" aria-hidden="true" />
366
+ </button>
354
367
  </nav>
355
368
  </header>
356
369
 
@@ -404,9 +417,11 @@ export function SiteHeader() {
404
417
  role="listbox"
405
418
  aria-label="搜索结果"
406
419
  >
407
- <p className="search-label">
408
- {query ? `${resultTotal} 篇相关文章` : "最近内容"}
409
- </p>
420
+ {query && engine && resultTotal === 0 ? null : (
421
+ <p className="search-label">
422
+ {query ? `${resultTotal} 篇相关文章` : "最近内容"}
423
+ </p>
424
+ )}
410
425
  {results.map((result, index) => {
411
426
  const preview = searchPreview(result.item, result.matches);
412
427
  return (
@@ -445,7 +460,8 @@ export function SiteHeader() {
445
460
  })}
446
461
  {results.length === 0 && (
447
462
  <p className="empty-state">
448
- {engine ? "没有找到,换个更宽松的关键词试试。" : engineFailed ? "搜索暂时打不开,请稍后再试。" : "正在载入…"}
463
+ {/* The query is a React text child, so it is escaped. */}
464
+ {engine ? noResultsText(query) : engineFailed ? "搜索暂时打不开,请稍后再试。" : "正在载入…"}
449
465
  </p>
450
466
  )}
451
467
  </div>
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 {
@@ -68,7 +68,7 @@ html[data-mod="other"] .sh-search .kbd-other{display:inline}
68
68
  .i-sun,html[data-theme="dark"] .i-moon{display:none}
69
69
  html[data-theme="dark"] .i-sun{display:inline}
70
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}}
71
+ @media (scripting:none){.sh-theme,.sh-menu-theme{display:none}}
72
72
  .sh-menu{display:none}
73
73
 
74
74
  /* ---------- page frame ---------- */
@@ -272,8 +272,13 @@ html[data-theme="dark"] .sh-pal-scrim{background:rgba(0,0,0,.55)}
272
272
  .sh-menu.is-open{opacity:1;transform:none;visibility:visible;transition:opacity .18s ease,transform .18s var(--ease)}
273
273
  .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
274
  .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}
275
+ /* Theme switch at the bottom of the menu: the same toggle as the desktop button. */
276
+ .sh-menu a:last-of-type{border-bottom:0}
277
+ .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}
278
+ .sh-switch{position:relative;flex:none;width:40px;height:24px;border-radius:12px;background:var(--rule-strong);transition:background-color .18s ease}
279
+ .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)}
280
+ .sh-menu-theme[aria-checked="true"] .sh-switch{background:var(--ink)}
281
+ .sh-menu-theme[aria-checked="true"] .sh-switch::after{transform:translateX(16px);background:var(--paper)}
277
282
  .sh-theme{display:none}
278
283
  .sh-main{padding:0 20px}
279
284
  .sh-head{padding:40px 0 24px}
@@ -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.13",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -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,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
+ }