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.
- package/app/components/SiteHeader.tsx +10 -67
- package/app/globals.css +7 -0
- package/build/static-rsc-path-plugin.mjs +41 -0
- package/package.json +1 -1
- package/ui/garden/search-preview.mjs +69 -0
- package/vite.config.ts +2 -0
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|
-
):
|
|
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
|
@@ -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"] },
|