hd-garden 0.5.9 → 0.5.10

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.
@@ -276,11 +276,17 @@ export function SiteHeader() {
276
276
 
277
277
  // Excerpts first, full text once it is here. Typos fall back to Fuse's fuzzy match.
278
278
  const matcher = useMemo(() => (engine ? createSearch(engine.records, fullText) : null), [engine, fullText]);
279
- const results: SearchHit[] = useMemo(() => {
280
- if (!engine || !matcher) return [];
281
- if (!query.trim()) return engine.records.slice(0, 5).map((item) => ({ item, matches: [] }));
282
- const exact = matcher.search(query, 6) as unknown as SearchHit[];
283
- return exact.length ? exact : engine.fuse.search(query).slice(0, 6);
279
+ // The list shows the best few; the label counts every match.
280
+ const { hits: results, total: resultTotal } = useMemo((): { hits: SearchHit[]; total: number } => {
281
+ if (!engine || !matcher) return { hits: [], total: 0 };
282
+ if (!query.trim()) {
283
+ const recent = engine.records.slice(0, 5).map((item) => ({ item, matches: [] }));
284
+ return { hits: recent, total: recent.length };
285
+ }
286
+ const exact = matcher.find(query, 6) as unknown as { hits: SearchHit[]; total: number };
287
+ if (exact.total) return exact;
288
+ const fuzzy = engine.fuse.search(query);
289
+ return { hits: fuzzy.slice(0, 6), total: fuzzy.length };
284
290
  }, [engine, matcher, query]);
285
291
 
286
292
  useEffect(() => {
@@ -459,7 +465,7 @@ export function SiteHeader() {
459
465
  aria-label="搜索结果"
460
466
  >
461
467
  <p className="search-label">
462
- {query ? `${results.length} 个相关结果` : "最近内容"}
468
+ {query ? `${resultTotal} 篇相关文章` : "最近内容"}
463
469
  </p>
464
470
  {results.map((result, index) => {
465
471
  const preview = searchPreview(result.item, result.matches);
@@ -477,9 +483,11 @@ export function SiteHeader() {
477
483
  aria-selected={activeResult === index}
478
484
  className={`search-result${activeResult === index ? " active" : ""}`}
479
485
  >
480
- <span>
481
- {result.item.section}
482
- </span>
486
+ {result.item.section ? (
487
+ <span className="search-chip">
488
+ {result.item.section}
489
+ </span>
490
+ ) : null}
483
491
  <strong>
484
492
  <HighlightedText
485
493
  text={result.item.title}
@@ -500,7 +508,7 @@ export function SiteHeader() {
500
508
  </div>
501
509
  <footer className="search-footer">
502
510
  <span>支持模糊匹配</span>
503
- <span>ESC 关闭</span>
511
+ <span><kbd>ESC</kbd> 关闭</span>
504
512
  </footer>
505
513
  </section>
506
514
  </div>
package/app/globals.css CHANGED
@@ -3535,6 +3535,8 @@ html[data-theme="dark"] .article-references {
3535
3535
  font-size: 12px;
3536
3536
  }
3537
3537
 
3538
+ /* Search dialog (A2): a paper sheet with soft depth. No hard rules inside:
3539
+ rows are separated by space, the active row by a warm tint. */
3538
3540
  .search-backdrop {
3539
3541
  position: fixed;
3540
3542
  inset: 0;
@@ -3543,92 +3545,173 @@ html[data-theme="dark"] .article-references {
3543
3545
  display: flex;
3544
3546
  justify-content: center;
3545
3547
  align-items: flex-start;
3546
- background: rgb(20 21 22 / 72%);
3547
- backdrop-filter: blur(4px);
3548
+ background: rgb(20 19 18 / 32%);
3549
+ animation: search-fade 0.14s ease-out;
3550
+ }
3551
+
3552
+ html[data-theme="dark"] .search-backdrop {
3553
+ background: rgb(0 0 0 / 55%);
3548
3554
  }
3549
3555
 
3550
3556
  .search-panel {
3551
3557
  width: min(640px, 100%);
3552
- max-height: 78vh;
3558
+ max-height: min(600px, 80vh);
3559
+ display: flex;
3560
+ flex-direction: column;
3553
3561
  overflow: hidden;
3562
+ background: var(--paper);
3563
+ border: 1px solid var(--rule);
3564
+ border-radius: 16px;
3565
+ box-shadow: 0 18px 48px rgb(27 26 24 / 16%), 0 2px 8px rgb(27 26 24 / 6%);
3566
+ color: var(--text);
3567
+ animation: search-fade 0.14s ease-out;
3568
+ }
3569
+
3570
+ html[data-theme="dark"] .search-panel {
3554
3571
  background: var(--card);
3555
- border: 1px solid var(--line-strong);
3556
- box-shadow: 0 24px 80px rgb(0 0 0 / 25%);
3572
+ box-shadow: 0 18px 48px rgb(0 0 0 / 45%), 0 2px 8px rgb(0 0 0 / 25%);
3557
3573
  }
3558
3574
 
3559
3575
  .search-input-wrap {
3560
- min-height: 58px;
3561
- padding: 0 16px;
3576
+ flex: none;
3577
+ padding: 10px 10px 4px 20px;
3562
3578
  display: grid;
3563
- grid-template-columns: 24px 1fr 30px;
3579
+ grid-template-columns: 20px 1fr 36px;
3564
3580
  align-items: center;
3565
- gap: 8px;
3566
- border-bottom: 1px solid var(--line);
3581
+ gap: 12px;
3582
+ color: var(--muted);
3567
3583
  }
3568
3584
 
3569
3585
  .search-input-wrap input {
3570
3586
  width: 100%;
3571
- height: 57px;
3587
+ min-width: 0;
3588
+ height: 48px;
3589
+ padding: 0;
3572
3590
  border: 0;
3573
3591
  background: transparent;
3574
3592
  outline: 0;
3575
- font-size: 13px;
3593
+ color: var(--ink);
3594
+ caret-color: var(--ink);
3595
+ font: 400 17px / 1.4 var(--sans);
3596
+ }
3597
+
3598
+ .search-input-wrap input::placeholder {
3599
+ color: var(--muted);
3576
3600
  }
3577
3601
 
3578
3602
  .search-input-wrap button {
3579
- padding: 5px;
3603
+ width: 36px;
3604
+ height: 36px;
3605
+ padding: 0;
3580
3606
  display: grid;
3581
3607
  place-items: center;
3582
- background: transparent;
3583
3608
  border: 0;
3609
+ border-radius: 10px;
3610
+ background: transparent;
3611
+ color: var(--muted);
3612
+ cursor: pointer;
3613
+ transition: background-color 0.15s ease, color 0.15s ease;
3614
+ }
3615
+
3616
+ .search-input-wrap button:hover {
3617
+ background: var(--tint);
3618
+ color: var(--ink);
3619
+ }
3620
+
3621
+ .search-input-wrap button:focus-visible {
3622
+ border-radius: 10px;
3584
3623
  }
3585
3624
 
3586
3625
  .search-results {
3587
- max-height: 58vh;
3588
- padding: 12px;
3626
+ flex: 1 1 auto;
3627
+ min-height: 0;
3628
+ padding: 2px 10px 10px;
3629
+ display: grid;
3630
+ align-content: start;
3631
+ gap: 2px;
3589
3632
  overflow-y: auto;
3633
+ overscroll-behavior: contain;
3590
3634
  }
3591
3635
 
3592
3636
  .search-label {
3593
- margin: 5px 8px 9px;
3594
- color: var(--faint);
3595
- font-size: 9px;
3637
+ margin: 6px 10px 6px;
3638
+ color: var(--muted);
3639
+ font: 400 var(--fs-meta) / 1.4 var(--sans);
3596
3640
  }
3597
3641
 
3598
3642
  .search-result {
3599
- padding: 12px;
3643
+ padding: 10px 12px;
3600
3644
  display: grid;
3601
- grid-template-columns: 88px 1fr;
3602
- gap: 2px 10px;
3603
- border-top: 1px solid var(--line);
3645
+ grid-template-columns: auto minmax(0, 1fr);
3646
+ align-items: center;
3647
+ gap: 4px 8px;
3648
+ border-radius: 10px;
3649
+ color: var(--text);
3650
+ text-decoration: none;
3651
+ scroll-margin: 8px;
3652
+ transition: background-color 0.12s ease, box-shadow 0.12s ease;
3604
3653
  }
3605
3654
 
3606
3655
  .search-result:hover,
3607
- .search-result:focus-visible,
3608
3656
  .search-result.active {
3609
- background: var(--paper-deep);
3657
+ background: var(--card-hover);
3658
+ box-shadow: var(--card-shadow);
3659
+ text-decoration: none;
3610
3660
  }
3611
3661
 
3612
- .search-result > span {
3613
- grid-row: 1 / 3;
3614
- color: var(--accent);
3615
- font-size: 9px;
3662
+ /* Keyboard focus keeps the row's radius and adds a faint ink ring. */
3663
+ .search-result:focus-visible {
3664
+ outline: none;
3665
+ border-radius: 10px;
3666
+ background: var(--card-hover);
3667
+ box-shadow: var(--card-shadow), inset 0 0 0 1px var(--blue-line);
3616
3668
  }
3617
3669
 
3618
3670
  .search-result strong {
3619
- font-size: 12px;
3671
+ grid-column: 1 / -1;
3672
+ grid-row: 1;
3673
+ color: var(--ink);
3674
+ font: 600 15px / 1.5 var(--sans);
3675
+ }
3676
+
3677
+ .search-result > .search-chip {
3678
+ grid-column: 1;
3679
+ grid-row: 2;
3680
+ max-width: 12em;
3681
+ padding: 1px 8px;
3682
+ overflow: hidden;
3683
+ border-radius: 999px;
3684
+ background: var(--chip);
3685
+ color: var(--soft);
3686
+ font: 400 12px / 1.6 var(--sans);
3687
+ text-overflow: ellipsis;
3688
+ white-space: nowrap;
3689
+ }
3690
+
3691
+ .search-result:hover > .search-chip,
3692
+ .search-result.active > .search-chip {
3693
+ background: var(--chip-hover);
3620
3694
  }
3621
3695
 
3622
3696
  .search-result small {
3697
+ grid-column: 2;
3698
+ grid-row: 2;
3699
+ min-width: 0;
3623
3700
  color: var(--muted);
3624
3701
  overflow: hidden;
3625
- font-size: 10px;
3702
+ font: 400 var(--fs-meta) / 1.6 var(--sans);
3626
3703
  text-overflow: ellipsis;
3627
3704
  white-space: nowrap;
3628
3705
  }
3629
3706
 
3707
+ /* A post in no column has no chip: its excerpt takes the whole line. */
3708
+ .search-result:not(:has(> .search-chip)) small {
3709
+ grid-column: 1 / -1;
3710
+ }
3711
+
3630
3712
  .search-match {
3631
3713
  padding: 0 1px;
3714
+ border-radius: 3px;
3632
3715
  background: var(--selection);
3633
3716
  color: var(--ink);
3634
3717
  box-decoration-break: clone;
@@ -3636,19 +3719,86 @@ html[data-theme="dark"] .article-references {
3636
3719
  }
3637
3720
 
3638
3721
  .empty-state {
3639
- padding: 30px 12px;
3722
+ padding: 32px 12px;
3640
3723
  color: var(--muted);
3641
- font-size: 11px;
3724
+ font-size: var(--fs-small);
3642
3725
  text-align: center;
3643
3726
  }
3644
3727
 
3645
3728
  .search-footer {
3646
- padding: 9px 16px;
3729
+ flex: none;
3730
+ padding: 10px 20px;
3647
3731
  display: flex;
3648
3732
  justify-content: space-between;
3733
+ gap: 16px;
3734
+ border-radius: 0 0 15px 15px;
3735
+ background: var(--card-hover);
3736
+ color: var(--muted);
3737
+ font: 400 12px / 1.5 var(--sans);
3738
+ }
3739
+
3740
+ html[data-theme="dark"] .search-footer {
3649
3741
  background: var(--paper-deep);
3650
- color: var(--faint);
3651
- font-size: 9px;
3742
+ }
3743
+
3744
+ .search-footer kbd {
3745
+ padding: 1px 6px;
3746
+ border-radius: 6px;
3747
+ background: var(--chip);
3748
+ color: var(--soft);
3749
+ font: inherit;
3750
+ }
3751
+
3752
+ @keyframes search-fade {
3753
+ from {
3754
+ opacity: 0;
3755
+ }
3756
+ }
3757
+
3758
+ /* Phones: a sheet from the bottom, nearly full height, rounded on top. */
3759
+ @media (max-width: 640px) {
3760
+ .search-backdrop {
3761
+ padding: 0;
3762
+ align-items: flex-end;
3763
+ }
3764
+
3765
+ .search-panel {
3766
+ width: 100%;
3767
+ height: calc(100dvh - 16px);
3768
+ max-height: none;
3769
+ border-bottom: 0;
3770
+ border-radius: 16px 16px 0 0;
3771
+ }
3772
+
3773
+ .search-input-wrap {
3774
+ padding: 8px 8px 2px 18px;
3775
+ grid-template-columns: 20px 1fr 44px;
3776
+ }
3777
+
3778
+ .search-input-wrap button {
3779
+ width: 44px;
3780
+ height: 44px;
3781
+ }
3782
+
3783
+ .search-results {
3784
+ padding: 2px 8px calc(12px + env(safe-area-inset-bottom));
3785
+ }
3786
+
3787
+ .search-footer {
3788
+ display: none;
3789
+ }
3790
+ }
3791
+
3792
+ @media (prefers-reduced-motion: reduce) {
3793
+ .search-backdrop,
3794
+ .search-panel {
3795
+ animation: none;
3796
+ }
3797
+
3798
+ .search-result,
3799
+ .search-input-wrap button {
3800
+ transition: none;
3801
+ }
3652
3802
  }
3653
3803
 
3654
3804
  .site-footer {
package/app/styles/a2.css CHANGED
@@ -62,7 +62,6 @@ body {
62
62
  }
63
63
 
64
64
  .icon-button,
65
- .search-panel,
66
65
  .column-feature,
67
66
  .archive-card,
68
67
  .thought-card,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "private": false,
5
5
  "description": "用 Markdown 写作、用 GitHub Pages 发布的个人博客引擎。",
6
6
  "license": "MIT",
@@ -58,8 +58,18 @@ const excerpt = (text, length = EXCERPT_LENGTH) => {
58
58
  const columnEntryPath = (entry) =>
59
59
  entry.column && entry.path.startsWith(`${entry.column}/`) ? entry.path.slice(entry.column.length + 1) : entry.path;
60
60
 
61
- /** Records in the same order the header has always listed them: posts, column entries, thoughts. */
62
- export function buildSearchIndex({ posts = [], columns = [], thoughts = [] }, { length = EXCERPT_LENGTH } = {}) {
61
+ const entryKey = (item) => item.sourcePath ?? item.path;
62
+ const columnKey = (item) => item.sourcePath ?? columnEntryPath(item);
63
+
64
+ /**
65
+ * One search entry per piece of writing, as `{ item, record }`, in the order
66
+ * the header lists them: posts, then column entries that are not also posts,
67
+ * then thoughts. A post that sits in a column is listed once, at its /blog/
68
+ * URL, with the column's title as its section; a post in no column has an
69
+ * empty section. A column entry is only listed on its own when no post has
70
+ * the same source (a column-only page).
71
+ */
72
+ function searchEntries({ posts = [], columns = [], thoughts = [] }, { length = EXCERPT_LENGTH } = {}) {
63
73
  const record = (item, extra) => {
64
74
  const text = searchableText(item.content ?? "");
65
75
  return {
@@ -71,25 +81,37 @@ export function buildSearchIndex({ posts = [], columns = [], thoughts = [] }, {
71
81
  ...extra(text),
72
82
  };
73
83
  };
84
+ const postKeys = new Set(posts.map(entryKey));
85
+ const columnOf = new Map();
86
+ for (const item of columns) {
87
+ const key = columnKey(item);
88
+ if (postKeys.has(key) && !columnOf.has(key)) columnOf.set(key, item.columnTitle ?? "专栏");
89
+ }
74
90
  return [
75
- ...posts.map((item) => record(item, () => ({ section: "文章", path: `/blog/${item.path}` }))),
76
- ...columns.map((item) => record(item, () => ({ section: item.columnTitle ?? "专栏", path: `/columns/${item.column}/${columnEntryPath(item)}` }))),
77
- ...thoughts.map((item) => record(item, (text) => ({ section: "随想", path: `/thoughts/${item.slug}`, description: excerpt(text, length) }))),
91
+ ...posts.map((item) => ({ item, record: record(item, () => ({ section: columnOf.get(entryKey(item)) ?? "", path: `/blog/${item.path}` })) })),
92
+ ...columns
93
+ .filter((item) => !postKeys.has(columnKey(item)))
94
+ .map((item) => ({ item, record: record(item, () => ({ section: item.columnTitle ?? "专栏", path: `/columns/${item.column}/${columnEntryPath(item)}` })) })),
95
+ ...thoughts.map((item) => ({ item, record: record(item, (text) => ({ section: "随想", path: `/thoughts/${item.slug}`, description: excerpt(text, length) })) })),
78
96
  ];
79
97
  }
80
98
 
99
+ /** The small index: one record per piece of writing (see searchEntries). */
100
+ export function buildSearchIndex(content, options) {
101
+ return searchEntries(content, options).map(({ record }) => record);
102
+ }
103
+
81
104
  /**
82
105
  * The full-text file: `{ hash, texts, docs }`, where docs is
83
- * `[[path, textIndex], ...]` in the small index's order. A post that also
84
- * sits in a column has one body under two paths, so texts are stored once.
106
+ * `[[path, textIndex], ...]` in the small index's order. Identical bodies
107
+ * are stored once.
85
108
  * The hash is of the contents, so the file name changes exactly when they do.
86
109
  */
87
110
  export function buildFullIndex({ posts = [], columns = [], thoughts = [] }) {
88
- const items = [...posts, ...columns, ...thoughts];
89
111
  const texts = [];
90
112
  const seen = new Map();
91
- const docs = buildSearchIndex({ posts, columns, thoughts }).map((record, index) => {
92
- const text = fullText(items[index].content ?? "");
113
+ const docs = searchEntries({ posts, columns, thoughts }).map(({ item, record }) => {
114
+ const text = fullText(item.content ?? "");
93
115
  if (!seen.has(text)) {
94
116
  seen.set(text, texts.length);
95
117
  texts.push(text);
@@ -55,9 +55,10 @@ export function createSearch(records, bodies = null) {
55
55
  };
56
56
  });
57
57
 
58
- function search(query, limit = 6) {
58
+ /** The best `limit` hits, plus how many records match in all. */
59
+ function find(query, limit = 6) {
59
60
  const terms = queryTerms(query);
60
- if (!terms.length) return [];
61
+ if (!terms.length) return { hits: [], total: 0 };
61
62
  const hits = [];
62
63
  for (const doc of docs) {
63
64
  let score = 0;
@@ -89,8 +90,10 @@ export function createSearch(records, bodies = null) {
89
90
  hits.push({ score, order: doc.order, item: doc.item, matches });
90
91
  }
91
92
  hits.sort((a, b) => b.score - a.score || a.order - b.order);
92
- return hits.slice(0, limit).map(({ item, matches }) => ({ item, matches }));
93
+ return { hits: hits.slice(0, limit).map(({ item, matches }) => ({ item, matches })), total: hits.length };
93
94
  }
94
95
 
95
- return { full: Boolean(bodies), search };
96
+ const search = (query, limit = 6) => find(query, limit).hits;
97
+
98
+ return { full: Boolean(bodies), find, search };
96
99
  }