hd-garden 0.5.11 → 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.
- package/app/components/SiteHeader.tsx +37 -78
- package/app/globals.css +13 -0
- package/app/styles/shell.css +8 -3
- package/build/static-rsc-path-plugin.mjs +78 -0
- package/package.json +1 -1
- package/ui/garden/search-preview.mjs +75 -0
- package/ui/garden/theme.mjs +15 -0
- package/vite.config.ts +2 -0
|
@@ -14,6 +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, noResultsText } from "../../ui/garden/search-preview.mjs";
|
|
18
|
+
import { resolveDark, themeSwitchProps } from "../../ui/garden/theme.mjs";
|
|
17
19
|
import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
|
|
18
20
|
import { siteConfig } from "../site.config";
|
|
19
21
|
|
|
@@ -106,38 +108,9 @@ function HighlightedText({
|
|
|
106
108
|
ranges: readonly MatchRange[];
|
|
107
109
|
}) {
|
|
108
110
|
if (!ranges.length) return text;
|
|
109
|
-
|
|
110
|
-
const merged = [...ranges]
|
|
111
|
-
.sort((left, right) => left[0] - right[0])
|
|
112
|
-
.reduce<MatchRange[]>((result, range) => {
|
|
113
|
-
const previous = result.at(-1);
|
|
114
|
-
if (previous && range[0] <= previous[1] + 1) {
|
|
115
|
-
result[result.length - 1] = [
|
|
116
|
-
previous[0],
|
|
117
|
-
Math.max(previous[1], range[1]),
|
|
118
|
-
];
|
|
119
|
-
} else {
|
|
120
|
-
result.push(range);
|
|
121
|
-
}
|
|
122
|
-
return result;
|
|
123
|
-
}, []);
|
|
124
|
-
|
|
125
|
-
const parts: Array<{ text: string; matched: boolean }> = [];
|
|
126
|
-
let cursor = 0;
|
|
127
|
-
merged.forEach(([start, end]) => {
|
|
128
|
-
if (start > cursor) {
|
|
129
|
-
parts.push({ text: text.slice(cursor, start), matched: false });
|
|
130
|
-
}
|
|
131
|
-
parts.push({ text: text.slice(start, end + 1), matched: true });
|
|
132
|
-
cursor = end + 1;
|
|
133
|
-
});
|
|
134
|
-
if (cursor < text.length) {
|
|
135
|
-
parts.push({ text: text.slice(cursor), matched: false });
|
|
136
|
-
}
|
|
137
|
-
|
|
138
111
|
return (
|
|
139
112
|
<>
|
|
140
|
-
{
|
|
113
|
+
{highlightParts(text, ranges).map((part, index) =>
|
|
141
114
|
part.matched ? (
|
|
142
115
|
<mark className="search-match" key={`${part.text}-${index}`}>
|
|
143
116
|
{part.text}
|
|
@@ -150,55 +123,23 @@ function HighlightedText({
|
|
|
150
123
|
);
|
|
151
124
|
}
|
|
152
125
|
|
|
153
|
-
|
|
154
|
-
if (!ranges.length) return { text, ranges };
|
|
155
|
-
|
|
156
|
-
const [focusStart, focusEnd] = ranges[0];
|
|
157
|
-
let start = Math.max(0, focusStart - 42);
|
|
158
|
-
let end = Math.min(text.length, Math.max(focusEnd + 52, start + 112));
|
|
159
|
-
|
|
160
|
-
if (start > 0) {
|
|
161
|
-
const nextSpace = text.indexOf(" ", start);
|
|
162
|
-
if (nextSpace !== -1 && nextSpace < focusStart) start = nextSpace + 1;
|
|
163
|
-
}
|
|
164
|
-
if (end < text.length) {
|
|
165
|
-
const previousSpace = text.lastIndexOf(" ", end);
|
|
166
|
-
if (previousSpace > focusEnd) end = previousSpace;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const prefix = start > 0 ? "…" : "";
|
|
170
|
-
const suffix = end < text.length ? "…" : "";
|
|
171
|
-
const visibleRanges = ranges
|
|
172
|
-
.filter(([rangeStart, rangeEnd]) => rangeEnd >= start && rangeStart < end)
|
|
173
|
-
.map(
|
|
174
|
-
([rangeStart, rangeEnd]) =>
|
|
175
|
-
[
|
|
176
|
-
Math.max(rangeStart, start) - start + prefix.length,
|
|
177
|
-
Math.min(rangeEnd, end - 1) - start + prefix.length,
|
|
178
|
-
] as MatchRange,
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
text: `${prefix}${text.slice(start, end)}${suffix}`,
|
|
183
|
-
ranges: visibleRanges,
|
|
184
|
-
};
|
|
185
|
-
}
|
|
126
|
+
type SearchPreview = { prefix: string; text: string; suffix: string; ranges: readonly MatchRange[] };
|
|
186
127
|
|
|
187
128
|
function searchPreview(
|
|
188
129
|
item: SearchRecord,
|
|
189
130
|
matches: readonly SearchMatch[] | undefined,
|
|
190
|
-
):
|
|
131
|
+
): SearchPreview {
|
|
191
132
|
const descriptionRanges = rangesFor(matches, "description");
|
|
192
133
|
if (descriptionRanges.length) {
|
|
193
|
-
return cropMatchedText(item.description, descriptionRanges);
|
|
134
|
+
return cropMatchedText(item.description, descriptionRanges) as SearchPreview;
|
|
194
135
|
}
|
|
195
136
|
|
|
196
137
|
const contentRanges = rangesFor(matches, "content");
|
|
197
138
|
if (contentRanges.length) {
|
|
198
|
-
return cropMatchedText(item.content, contentRanges);
|
|
139
|
+
return cropMatchedText(item.content, contentRanges) as SearchPreview;
|
|
199
140
|
}
|
|
200
141
|
|
|
201
|
-
return { text: item.description, ranges: [] };
|
|
142
|
+
return { prefix: "", text: item.description, suffix: "", ranges: [] };
|
|
202
143
|
}
|
|
203
144
|
|
|
204
145
|
export function SiteHeader() {
|
|
@@ -247,14 +188,22 @@ export function SiteHeader() {
|
|
|
247
188
|
});
|
|
248
189
|
}, []);
|
|
249
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.
|
|
250
193
|
useEffect(() => {
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
stored
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
+
};
|
|
258
207
|
}, []);
|
|
259
208
|
|
|
260
209
|
useEffect(() => {
|
|
@@ -411,6 +360,10 @@ export function SiteHeader() {
|
|
|
411
360
|
{item.label}
|
|
412
361
|
</Link>
|
|
413
362
|
))}
|
|
363
|
+
<button className="sh-menu-theme" {...themeSwitchProps(dark, toggleTheme)}>
|
|
364
|
+
<span>深色模式</span>
|
|
365
|
+
<span className="sh-switch" aria-hidden="true" />
|
|
366
|
+
</button>
|
|
414
367
|
</nav>
|
|
415
368
|
</header>
|
|
416
369
|
|
|
@@ -464,9 +417,11 @@ export function SiteHeader() {
|
|
|
464
417
|
role="listbox"
|
|
465
418
|
aria-label="搜索结果"
|
|
466
419
|
>
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
420
|
+
{query && engine && resultTotal === 0 ? null : (
|
|
421
|
+
<p className="search-label">
|
|
422
|
+
{query ? `${resultTotal} 篇相关文章` : "最近内容"}
|
|
423
|
+
</p>
|
|
424
|
+
)}
|
|
470
425
|
{results.map((result, index) => {
|
|
471
426
|
const preview = searchPreview(result.item, result.matches);
|
|
472
427
|
return (
|
|
@@ -495,14 +450,18 @@ export function SiteHeader() {
|
|
|
495
450
|
/>
|
|
496
451
|
</strong>
|
|
497
452
|
<small>
|
|
453
|
+
{/* The 「…」 sit outside the highlight. */}
|
|
454
|
+
{preview.prefix}
|
|
498
455
|
<HighlightedText text={preview.text} ranges={preview.ranges} />
|
|
456
|
+
{preview.suffix}
|
|
499
457
|
</small>
|
|
500
458
|
</Link>
|
|
501
459
|
);
|
|
502
460
|
})}
|
|
503
461
|
{results.length === 0 && (
|
|
504
462
|
<p className="empty-state">
|
|
505
|
-
{
|
|
463
|
+
{/* The query is a React text child, so it is escaped. */}
|
|
464
|
+
{engine ? noResultsText(query) : engineFailed ? "搜索暂时打不开,请稍后再试。" : "正在载入…"}
|
|
506
465
|
</p>
|
|
507
466
|
)}
|
|
508
467
|
</div>
|
package/app/globals.css
CHANGED
|
@@ -3718,11 +3718,24 @@ html[data-theme="dark"] .search-panel {
|
|
|
3718
3718
|
-webkit-box-decoration-break: clone;
|
|
3719
3719
|
}
|
|
3720
3720
|
|
|
3721
|
+
/* The excerpt is one line cut by text-overflow. Inside it a highlight is
|
|
3722
|
+
atomic: it shows whole, or the line's 「…」 replaces it whole and is drawn
|
|
3723
|
+
with the excerpt's colours, never inside the highlight. */
|
|
3724
|
+
.search-result small .search-match {
|
|
3725
|
+
display: inline-block;
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3721
3728
|
.empty-state {
|
|
3722
3729
|
padding: 32px 12px;
|
|
3723
3730
|
color: var(--muted);
|
|
3724
3731
|
font-size: var(--fs-small);
|
|
3725
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;
|
|
3726
3739
|
}
|
|
3727
3740
|
|
|
3728
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}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Static export: request the home page's RSC payload where the export writes it.
|
|
2
|
+
//
|
|
3
|
+
// vinext's client builds every RSC address as `<pathname>.rsc` (trailing slash
|
|
4
|
+
// dropped), so the home page becomes `/.rsc`. The static export writes that
|
|
5
|
+
// payload to `index.rsc` (vinext's getRscOutputPath maps "/" there). A static
|
|
6
|
+
// host has no server to bridge the two, so every <Link href="/"> prefetch, and
|
|
7
|
+
// any client navigation home, hit a 404. With output: "export" this plugin
|
|
8
|
+
// changes the address builder (toRscRequestPath) so "/" asks for `/index.rsc`.
|
|
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.
|
|
22
|
+
|
|
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 = "/";';
|
|
31
|
+
const ADDRESS = "return `${pathname.length > 1 && pathname.endsWith(\"/\") ? pathname.slice(0, -1) : pathname}.rsc${query}`;";
|
|
32
|
+
const EXPORTED = "return `${pathname === \"/\" ? \"/index\" : pathname.length > 1 && pathname.endsWith(\"/\") ? pathname.slice(0, -1) : pathname}.rsc${query}`;";
|
|
33
|
+
|
|
34
|
+
/** Rewrite vinext's RSC address builder; throws if vinext changed shape. */
|
|
35
|
+
export function exportRscAddress(code, id = "app-rsc-cache-busting.js") {
|
|
36
|
+
if (!code.includes("function toRscRequestPath(") || !code.includes(ADDRESS)) {
|
|
37
|
+
throw new Error(`garden static RSC path: could not find vinext's RSC address builder in ${id}. vinext changed; update build/static-rsc-path-plugin.mjs so the home page asks for /index.rsc.`);
|
|
38
|
+
}
|
|
39
|
+
return code.replace(ADDRESS, EXPORTED);
|
|
40
|
+
}
|
|
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
|
+
|
|
52
|
+
export default function staticRscPath({ enabled = process.env.STATIC_EXPORT === "1" } = {}) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
return {
|
|
55
|
+
name: "garden-static-rsc-path",
|
|
56
|
+
apply: "build",
|
|
57
|
+
transform(code, id) {
|
|
58
|
+
// Browser bundle only: the prerender server still answers "/.rsc" itself.
|
|
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;
|
|
70
|
+
},
|
|
71
|
+
buildEnd(error) {
|
|
72
|
+
// The client build always pulls the address builder in; not seeing it means vinext moved it.
|
|
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.");
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Search result excerpts: crop around the first match and split into
|
|
2
|
+
// highlighted parts. Match ranges are inclusive [start, end] offsets.
|
|
3
|
+
|
|
4
|
+
/** Sort and join ranges that touch or overlap. */
|
|
5
|
+
export function mergeRanges(ranges) {
|
|
6
|
+
return [...ranges]
|
|
7
|
+
.sort((left, right) => left[0] - right[0])
|
|
8
|
+
.reduce((result, range) => {
|
|
9
|
+
const previous = result.at(-1);
|
|
10
|
+
if (previous && range[0] <= previous[1] + 1) result[result.length - 1] = [previous[0], Math.max(previous[1], range[1])];
|
|
11
|
+
else result.push([range[0], range[1]]);
|
|
12
|
+
return result;
|
|
13
|
+
}, []);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A term cut by the edge is kept whole when it adds at most this many characters.
|
|
17
|
+
const KEEP_WHOLE = 16;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Crop `text` to a window around the first match. The 「…」 marks come back
|
|
21
|
+
* apart from the text, so they are never inside a highlight, and a match the
|
|
22
|
+
* window would cut is either kept whole or left out whole.
|
|
23
|
+
* @returns {{ prefix: string, text: string, suffix: string, ranges: Array<[number, number]> }}
|
|
24
|
+
*/
|
|
25
|
+
export function cropMatchedText(text, ranges) {
|
|
26
|
+
const merged = mergeRanges(ranges).filter(([start, end]) => end >= start && start < text.length);
|
|
27
|
+
if (!merged.length) return { prefix: "", text, suffix: "", ranges: [] };
|
|
28
|
+
|
|
29
|
+
const [focusStart, focusEnd] = merged[0];
|
|
30
|
+
let start = Math.max(0, focusStart - 42);
|
|
31
|
+
let end = Math.min(text.length, Math.max(focusEnd + 52, start + 112));
|
|
32
|
+
|
|
33
|
+
if (start > 0) {
|
|
34
|
+
const nextSpace = text.indexOf(" ", start);
|
|
35
|
+
if (nextSpace !== -1 && nextSpace < focusStart) start = nextSpace + 1;
|
|
36
|
+
}
|
|
37
|
+
if (end < text.length) {
|
|
38
|
+
const previousSpace = text.lastIndexOf(" ", end);
|
|
39
|
+
if (previousSpace > focusEnd) end = previousSpace;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// No match may straddle a cut: widen to take it whole, or stop before it.
|
|
43
|
+
for (const [rangeStart, rangeEnd] of merged) {
|
|
44
|
+
if (rangeStart < start && rangeEnd >= start) start = start - rangeStart <= KEEP_WHOLE ? rangeStart : rangeEnd + 1;
|
|
45
|
+
if (rangeStart < end && rangeEnd >= end) end = rangeEnd + 1 - end <= KEEP_WHOLE ? Math.min(text.length, rangeEnd + 1) : rangeStart;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
prefix: start > 0 ? "…" : "",
|
|
50
|
+
text: text.slice(start, end),
|
|
51
|
+
suffix: end < text.length ? "…" : "",
|
|
52
|
+
ranges: merged
|
|
53
|
+
.filter(([rangeStart, rangeEnd]) => rangeStart >= start && rangeEnd < end)
|
|
54
|
+
.map(([rangeStart, rangeEnd]) => [rangeStart - start, rangeEnd - start]),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Split text into plain and matched parts. */
|
|
59
|
+
export function highlightParts(text, ranges) {
|
|
60
|
+
const parts = [];
|
|
61
|
+
let cursor = 0;
|
|
62
|
+
for (const [start, end] of mergeRanges(ranges)) {
|
|
63
|
+
if (start > cursor) parts.push({ text: text.slice(cursor, start), matched: false });
|
|
64
|
+
parts.push({ text: text.slice(start, end + 1), matched: true });
|
|
65
|
+
cursor = end + 1;
|
|
66
|
+
}
|
|
67
|
+
if (cursor < text.length) parts.push({ text: text.slice(cursor), matched: false });
|
|
68
|
+
return parts;
|
|
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
|
+
}
|
package/vite.config.ts
CHANGED
|
@@ -5,6 +5,7 @@ import vinext from "vinext";
|
|
|
5
5
|
import { defineConfig } from "vite";
|
|
6
6
|
import hostingConfig from "./.openai/hosting.json";
|
|
7
7
|
import { sites } from "./build/sites-vite-plugin";
|
|
8
|
+
import staticRscPath from "./build/static-rsc-path-plugin.mjs";
|
|
8
9
|
import { gardenPackageRoot, gardenSiteRoot } from "./scripts/garden-paths.mjs";
|
|
9
10
|
|
|
10
11
|
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
|
|
@@ -121,6 +122,7 @@ export default defineConfig(async () => {
|
|
|
121
122
|
plugins: [
|
|
122
123
|
gardenContent(),
|
|
123
124
|
vinext(),
|
|
125
|
+
staticRscPath(),
|
|
124
126
|
sites(),
|
|
125
127
|
cloudflare({
|
|
126
128
|
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
|