hd-garden 0.5.11 → 0.5.12

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,7 @@ 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
18
  import { createIdbStore, loadFullIndex, saveDataOn, whenIdleAfterLoad } from "../../ui/garden/search-full.mjs";
18
19
  import { siteConfig } from "../site.config";
19
20
 
@@ -106,38 +107,9 @@ function HighlightedText({
106
107
  ranges: readonly MatchRange[];
107
108
  }) {
108
109
  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
110
  return (
139
111
  <>
140
- {parts.map((part, index) =>
112
+ {highlightParts(text, ranges).map((part, index) =>
141
113
  part.matched ? (
142
114
  <mark className="search-match" key={`${part.text}-${index}`}>
143
115
  {part.text}
@@ -150,55 +122,23 @@ function HighlightedText({
150
122
  );
151
123
  }
152
124
 
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
- }
125
+ type SearchPreview = { prefix: string; text: string; suffix: string; ranges: readonly MatchRange[] };
186
126
 
187
127
  function searchPreview(
188
128
  item: SearchRecord,
189
129
  matches: readonly SearchMatch[] | undefined,
190
- ): { text: string; ranges: readonly MatchRange[] } {
130
+ ): SearchPreview {
191
131
  const descriptionRanges = rangesFor(matches, "description");
192
132
  if (descriptionRanges.length) {
193
- return cropMatchedText(item.description, descriptionRanges);
133
+ return cropMatchedText(item.description, descriptionRanges) as SearchPreview;
194
134
  }
195
135
 
196
136
  const contentRanges = rangesFor(matches, "content");
197
137
  if (contentRanges.length) {
198
- return cropMatchedText(item.content, contentRanges);
138
+ return cropMatchedText(item.content, contentRanges) as SearchPreview;
199
139
  }
200
140
 
201
- return { text: item.description, ranges: [] };
141
+ return { prefix: "", text: item.description, suffix: "", ranges: [] };
202
142
  }
203
143
 
204
144
  export function SiteHeader() {
@@ -495,7 +435,10 @@ export function SiteHeader() {
495
435
  />
496
436
  </strong>
497
437
  <small>
438
+ {/* The 「…」 sit outside the highlight. */}
439
+ {preview.prefix}
498
440
  <HighlightedText text={preview.text} ranges={preview.ranges} />
441
+ {preview.suffix}
499
442
  </small>
500
443
  </Link>
501
444
  );
package/app/globals.css CHANGED
@@ -3718,6 +3718,13 @@ 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);
@@ -0,0 +1,41 @@
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
+ const TARGET = /[\\/]vinext[\\/]dist[\\/]server[\\/]app-rsc-cache-busting\.js$/u;
12
+ const ADDRESS = "return `${pathname.length > 1 && pathname.endsWith(\"/\") ? pathname.slice(0, -1) : pathname}.rsc${query}`;";
13
+ const EXPORTED = "return `${pathname === \"/\" ? \"/index\" : pathname.length > 1 && pathname.endsWith(\"/\") ? pathname.slice(0, -1) : pathname}.rsc${query}`;";
14
+
15
+ /** Rewrite vinext's RSC address builder; throws if vinext changed shape. */
16
+ export function exportRscAddress(code, id = "app-rsc-cache-busting.js") {
17
+ if (!code.includes("function toRscRequestPath(") || !code.includes(ADDRESS)) {
18
+ 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.`);
19
+ }
20
+ return code.replace(ADDRESS, EXPORTED);
21
+ }
22
+
23
+ export default function staticRscPath({ enabled = process.env.STATIC_EXPORT === "1" } = {}) {
24
+ let seen = false;
25
+ return {
26
+ name: "garden-static-rsc-path",
27
+ apply: "build",
28
+ transform(code, id) {
29
+ // 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 };
33
+ },
34
+ buildEnd(error) {
35
+ // 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.");
38
+ }
39
+ },
40
+ };
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -0,0 +1,69 @@
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
+ }
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"] },