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.
@@ -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
- {parts.map((part, index) =>
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
- function cropMatchedText(text: string, ranges: readonly MatchRange[]) {
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
- ): { text: string; ranges: readonly MatchRange[] } {
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 stored = window.localStorage.getItem("theme");
252
- const useDark =
253
- stored === "dark" ||
254
- (!stored && window.matchMedia("(prefers-color-scheme: dark)").matches);
255
- document.documentElement.dataset.theme = useDark ? "dark" : "light";
256
- const frame = window.requestAnimationFrame(() => setDark(useDark));
257
- 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
+ };
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
- <p className="search-label">
468
- {query ? `${resultTotal} 篇相关文章` : "最近内容"}
469
- </p>
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
- {engine ? "没有找到,换个更宽松的关键词试试。" : engineFailed ? "搜索暂时打不开,请稍后再试。" : "正在载入…"}
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 {
@@ -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}
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -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"] },