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
|
|
192
|
-
const
|
|
193
|
-
stored
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
{
|
|
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 {
|
package/app/styles/shell.css
CHANGED
|
@@ -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
|
-
|
|
276
|
-
.sh-menu
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|