hd-garden 0.5.10 → 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);
@@ -65,7 +65,10 @@ html[data-mod="other"] .sh-search .kbd-other{display:inline}
65
65
  .sh-icon{display:inline-grid;place-items:center;width:40px;height:40px;border:0;background:transparent;color:var(--soft);border-radius:var(--r);cursor:pointer;transition:background-color .15s ease,color .15s ease}
66
66
  @media (hover:hover){.sh-icon:hover{background:var(--tint);color:var(--ink)}}
67
67
  .sh-menu-btn,.sh-search-m{display:none}
68
- html[data-theme="dark"] .i-moon,html:not([data-theme="dark"]) .i-sun{display:none}
68
+ .i-sun,html[data-theme="dark"] .i-moon{display:none}
69
+ html[data-theme="dark"] .i-sun{display:inline}
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}}
69
72
  .sh-menu{display:none}
70
73
 
71
74
  /* ---------- page frame ---------- */
@@ -0,0 +1,93 @@
1
+ // Dark fallback for pages without JavaScript.
2
+ //
3
+ // The theme script sets <html data-theme> before first paint. With scripting
4
+ // off nothing sets it, so the page would stay light on a dark system. Every
5
+ // rule written for `:root[data-theme="dark"]` or `html[data-theme="dark"]`
6
+ // gets a twin right after it, inside `@media (prefers-color-scheme: dark)`,
7
+ // that matches the root when it has no data-theme at all. The dark styles are
8
+ // written once; a theme the user picked (data-theme set) always wins, and
9
+ // the twin keeps the original specificity and source order.
10
+ //
11
+ // Any other use of data-theme that would leave a no-JS dark page wrong stops
12
+ // the build with an error naming the selector, instead of being skipped.
13
+
14
+ export const MEDIA = "(prefers-color-scheme: dark)";
15
+ const DARK_ROOT = /^(:root|html)\[data-theme=(["']?)dark\2\]/;
16
+ const ANY_THEME = /\[data-theme(?:([~|^$*]?=)\s*(["']?)([^"'\]]*)\2)?\s*\]/g;
17
+
18
+ /**
19
+ * Sort one selector (no commas):
20
+ * - "dark": starts with the dark root, gets a twin;
21
+ * - "other": no data-theme on the page root's dark side (light root, scoped
22
+ * components such as `.garden-ui-preview[data-theme="dark"]`), left alone;
23
+ * - throws for forms the twin cannot cover.
24
+ * `siblings` are the other selectors of the same rule.
25
+ */
26
+ export function classify(selector, siblings = []) {
27
+ const value = selector.trim();
28
+ if (!value.includes("data-theme")) return "other";
29
+ const uses = [...value.matchAll(ANY_THEME)];
30
+ let dark = false;
31
+ for (const use of uses) {
32
+ const [whole, op, , theme] = use;
33
+ const before = value.slice(0, use.index);
34
+ const compound = before.match(/[^\s>+~(]*$/)[0];
35
+ if (op && op !== "=") throw new Error(`unsupported data-theme operator "${whole}" in "${value}"`);
36
+ if (!op) throw new Error(`data-theme without a value ("${whole}") in "${value}"`);
37
+ if (/:not\($/.test(before)) throw new Error(`negated theme "${whole}" in "${value}": write the dark case as a [data-theme="dark"] rule instead`);
38
+ if (theme !== "dark" && theme !== "light") throw new Error(`unknown theme "${theme}" in "${value}"`);
39
+ const isRoot = compound === ":root" || compound === "html";
40
+ if (isRoot) {
41
+ if (before !== compound) throw new Error(`root theme selector must start the selector: "${value}"`);
42
+ if (theme === "dark") dark = true;
43
+ continue;
44
+ }
45
+ if (compound === "") {
46
+ // Bare `[data-theme="dark"] .x` also matches the page root. Allowed only
47
+ // when the same rule lists the root form, whose twin covers the page.
48
+ const rest = value.slice(use.index + whole.length);
49
+ const covered = siblings.some((other) => other !== value && DARK_ROOT.test(other.trim()) && other.trim().replace(DARK_ROOT, "") === rest);
50
+ if (theme === "dark" && !covered) throw new Error(`bare "${whole}" in "${value}" also matches the page root; list ":root${whole}${rest}" in the same rule`);
51
+ continue;
52
+ }
53
+ if (/^[.#]/.test(compound)) continue; // scoped component with its own data-theme
54
+ throw new Error(`unsupported data-theme selector "${compound}${whole}" in "${value}"`);
55
+ }
56
+ return dark ? "dark" : "other";
57
+ }
58
+
59
+ /** `html[data-theme="dark"] .x` → `html:not([data-theme]) .x`. */
60
+ export function fallbackSelector(selector) {
61
+ const value = selector.trim();
62
+ return DARK_ROOT.test(value) ? value.replace(DARK_ROOT, (_, root) => `${root}:not([data-theme])`) : null;
63
+ }
64
+
65
+ export default function darkFallback() {
66
+ return {
67
+ postcssPlugin: "garden-dark-fallback",
68
+ OnceExit(root, { AtRule }) {
69
+ root.walkRules((rule) => {
70
+ if (rule.parent?.type === "atrule" && rule.parent.name === "media" && rule.parent.params === MEDIA) return;
71
+ if (!rule.selector.includes("data-theme")) return;
72
+ const parts = rule.selectors;
73
+ const twins = [];
74
+ for (const part of parts) {
75
+ let kind;
76
+ try {
77
+ kind = classify(part, parts);
78
+ } catch (error) {
79
+ throw rule.error(`garden dark fallback: ${error.message}`, { word: part.trim() });
80
+ }
81
+ if (kind === "dark") twins.push(fallbackSelector(part));
82
+ }
83
+ if (!twins.length) return;
84
+ const next = rule.next();
85
+ if (next?.type === "atrule" && next.name === "media" && next.params === MEDIA) return; // already has its twin
86
+ const media = new AtRule({ name: "media", params: MEDIA });
87
+ media.append(rule.clone({ selectors: twins }));
88
+ rule.after(media);
89
+ });
90
+ },
91
+ };
92
+ }
93
+ darkFallback.postcss = true;
@@ -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.10",
3
+ "version": "0.5.12",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -1,7 +1,8 @@
1
+ import tailwind from "@tailwindcss/postcss";
2
+ import darkFallback from "./build/dark-fallback-postcss.mjs";
3
+
1
4
  const config = {
2
- plugins: {
3
- "@tailwindcss/postcss": {},
4
- },
5
+ plugins: [tailwind(), darkFallback()],
5
6
  };
6
7
 
7
8
  export default config;
@@ -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"] },