hd-garden 0.5.8 → 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>
@@ -111,7 +111,7 @@ function CompareLine({ line }: { line: string }) {
111
111
  if (!split) return <p><Rich text={line} /></p>;
112
112
  return (
113
113
  <p className="g-cmp-line">
114
- <span className="g-cmp-t"><Rich text={split.title} />{split.separator}</span>
114
+ <span className="g-cmp-h"><span className="g-cmp-t"><Rich text={split.title} /></span><span className="g-cmp-sep">{split.separator}</span></span>
115
115
  <Rich text={split.text} />
116
116
  </p>
117
117
  );
package/app/globals.css CHANGED
@@ -1521,6 +1521,11 @@ button {
1521
1521
  border: 1px solid var(--line);
1522
1522
  }
1523
1523
 
1524
+ /* A thought opened from a link: only the border changes, in the ink accent. */
1525
+ .thought-card:target {
1526
+ border-color: var(--accent);
1527
+ }
1528
+
1524
1529
  .thought-card > header {
1525
1530
  display: flex;
1526
1531
  justify-content: space-between;
@@ -3530,6 +3535,8 @@ html[data-theme="dark"] .article-references {
3530
3535
  font-size: 12px;
3531
3536
  }
3532
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. */
3533
3540
  .search-backdrop {
3534
3541
  position: fixed;
3535
3542
  inset: 0;
@@ -3538,92 +3545,173 @@ html[data-theme="dark"] .article-references {
3538
3545
  display: flex;
3539
3546
  justify-content: center;
3540
3547
  align-items: flex-start;
3541
- background: rgb(20 21 22 / 72%);
3542
- 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%);
3543
3554
  }
3544
3555
 
3545
3556
  .search-panel {
3546
3557
  width: min(640px, 100%);
3547
- max-height: 78vh;
3558
+ max-height: min(600px, 80vh);
3559
+ display: flex;
3560
+ flex-direction: column;
3548
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 {
3549
3571
  background: var(--card);
3550
- border: 1px solid var(--line-strong);
3551
- 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%);
3552
3573
  }
3553
3574
 
3554
3575
  .search-input-wrap {
3555
- min-height: 58px;
3556
- padding: 0 16px;
3576
+ flex: none;
3577
+ padding: 10px 10px 4px 20px;
3557
3578
  display: grid;
3558
- grid-template-columns: 24px 1fr 30px;
3579
+ grid-template-columns: 20px 1fr 36px;
3559
3580
  align-items: center;
3560
- gap: 8px;
3561
- border-bottom: 1px solid var(--line);
3581
+ gap: 12px;
3582
+ color: var(--muted);
3562
3583
  }
3563
3584
 
3564
3585
  .search-input-wrap input {
3565
3586
  width: 100%;
3566
- height: 57px;
3587
+ min-width: 0;
3588
+ height: 48px;
3589
+ padding: 0;
3567
3590
  border: 0;
3568
3591
  background: transparent;
3569
3592
  outline: 0;
3570
- 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);
3571
3600
  }
3572
3601
 
3573
3602
  .search-input-wrap button {
3574
- padding: 5px;
3603
+ width: 36px;
3604
+ height: 36px;
3605
+ padding: 0;
3575
3606
  display: grid;
3576
3607
  place-items: center;
3577
- background: transparent;
3578
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;
3579
3623
  }
3580
3624
 
3581
3625
  .search-results {
3582
- max-height: 58vh;
3583
- 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;
3584
3632
  overflow-y: auto;
3633
+ overscroll-behavior: contain;
3585
3634
  }
3586
3635
 
3587
3636
  .search-label {
3588
- margin: 5px 8px 9px;
3589
- color: var(--faint);
3590
- font-size: 9px;
3637
+ margin: 6px 10px 6px;
3638
+ color: var(--muted);
3639
+ font: 400 var(--fs-meta) / 1.4 var(--sans);
3591
3640
  }
3592
3641
 
3593
3642
  .search-result {
3594
- padding: 12px;
3643
+ padding: 10px 12px;
3595
3644
  display: grid;
3596
- grid-template-columns: 88px 1fr;
3597
- gap: 2px 10px;
3598
- 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;
3599
3653
  }
3600
3654
 
3601
3655
  .search-result:hover,
3602
- .search-result:focus-visible,
3603
3656
  .search-result.active {
3604
- background: var(--paper-deep);
3657
+ background: var(--card-hover);
3658
+ box-shadow: var(--card-shadow);
3659
+ text-decoration: none;
3605
3660
  }
3606
3661
 
3607
- .search-result > span {
3608
- grid-row: 1 / 3;
3609
- color: var(--accent);
3610
- 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);
3611
3668
  }
3612
3669
 
3613
3670
  .search-result strong {
3614
- 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);
3615
3694
  }
3616
3695
 
3617
3696
  .search-result small {
3697
+ grid-column: 2;
3698
+ grid-row: 2;
3699
+ min-width: 0;
3618
3700
  color: var(--muted);
3619
3701
  overflow: hidden;
3620
- font-size: 10px;
3702
+ font: 400 var(--fs-meta) / 1.6 var(--sans);
3621
3703
  text-overflow: ellipsis;
3622
3704
  white-space: nowrap;
3623
3705
  }
3624
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
+
3625
3712
  .search-match {
3626
3713
  padding: 0 1px;
3714
+ border-radius: 3px;
3627
3715
  background: var(--selection);
3628
3716
  color: var(--ink);
3629
3717
  box-decoration-break: clone;
@@ -3631,19 +3719,86 @@ html[data-theme="dark"] .article-references {
3631
3719
  }
3632
3720
 
3633
3721
  .empty-state {
3634
- padding: 30px 12px;
3722
+ padding: 32px 12px;
3635
3723
  color: var(--muted);
3636
- font-size: 11px;
3724
+ font-size: var(--fs-small);
3637
3725
  text-align: center;
3638
3726
  }
3639
3727
 
3640
3728
  .search-footer {
3641
- padding: 9px 16px;
3729
+ flex: none;
3730
+ padding: 10px 20px;
3642
3731
  display: flex;
3643
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 {
3644
3741
  background: var(--paper-deep);
3645
- color: var(--faint);
3646
- 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
+ }
3647
3802
  }
3648
3803
 
3649
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,
@@ -806,8 +805,10 @@ html[data-theme="dark"] .markdown-body .markdown-alert code {
806
805
  }
807
806
 
808
807
  /* A hidden TOC slides just past the right edge; clip it there so it does not
809
- widen the page. clip (unlike hidden) keeps the sticky TOC working. */
810
- html[data-toc-yield] #main-content {
808
+ widen the page. clip (unlike hidden) keeps the sticky TOC working. Not tied
809
+ to data-toc-yield: the attribute goes as the TOC starts sliding back, and
810
+ the page would widen for that 0.3s. */
811
+ #main-content:has(.toc) {
811
812
  overflow-x: clip;
812
813
  }
813
814
  }
@@ -997,8 +998,9 @@ html[data-theme="dark"] .markdown-body .markdown-alert code {
997
998
  margin-bottom: 0;
998
999
  }
999
1000
 
1000
- /* One-line cell 「X:Y」: X sits on its own line like a title, colon kept. */
1001
- .g-cmp .g-cmp-t {
1001
+ /* One-line cell 「X:Y」: X sits on its own line like a title, colon kept.
1002
+ .g-cmp-t holds the title only; the colon and its spaces are .g-cmp-sep. */
1003
+ .g-cmp .g-cmp-h {
1002
1004
  display: block;
1003
1005
  margin: 0 0 8px;
1004
1006
  font: 600 16px / 1.4 var(--sans);
@@ -199,7 +199,6 @@ html[data-theme="dark"] .sh-rail{color:var(--muted)}
199
199
  .sh-prose ul{margin:0;padding-left:1.2em}
200
200
  .sh-prose li{margin:.35em 0}
201
201
  .sh-prose a{color:var(--blue);text-underline-offset:4px;text-decoration-thickness:1px}
202
- .sh-note{font-size:var(--fs-meta);color:var(--muted);margin-top:12px}
203
202
 
204
203
  /* ---------- 404 ---------- */
205
204
  .sh-404{padding:96px 0 24px;max-width:var(--list)}
@@ -325,38 +324,10 @@ html[data-theme="dark"] .sh-pal-scrim{background:rgba(0,0,0,.55)}
325
324
  .sh-notice strong{color:var(--ink)}
326
325
  .sh-month > h2{font:700 var(--fs-lead)/1.3 var(--sans);color:var(--ink);margin:40px 0 0;padding:0 0 12px;border-bottom:1px solid var(--rule-strong);display:flex;align-items:baseline;gap:10px}
327
326
  .sh-month > h2 .n{font:400 var(--fs-meta)/1 var(--sans);color:var(--muted)}
328
- .sh-note{padding:28px 0 26px;border-bottom:1px solid var(--rule)}
329
- .sh-note:target{background:linear-gradient(var(--blue-tint),var(--blue-tint)) no-repeat;background-size:100% 100%;box-shadow:-16px 0 0 var(--blue-tint),16px 0 0 var(--blue-tint)}
330
- .sh-note h3{margin:0 0 6px;font:700 var(--fs-body)/1.55 var(--sans);color:var(--ink)}
331
- .sh-note-body p{margin:0 0 .7em;font-size:var(--fs-body);line-height:1.8;color:var(--text)}
332
- .sh-note-body p:last-child{margin-bottom:0}
333
- .sh-note-imgs{display:grid;gap:6px;margin:16px 0 0;border-radius:var(--r);overflow:hidden}
334
- .sh-note-imgs img{display:block;width:100%;height:100%;object-fit:cover;background:var(--well)}
335
- .sh-note-imgs.one img{aspect-ratio:3/2}
336
- .sh-note-imgs.two{grid-template-columns:1fr 1fr}.sh-note-imgs.two img{aspect-ratio:1/1}
337
- .sh-note-imgs.three{grid-template-columns:2fr 1fr;grid-template-rows:1fr 1fr;aspect-ratio:3/2}
338
- .sh-note-imgs.three img:first-child{grid-row:1 / 3}
339
- .sh-note-video{display:block;width:100%;aspect-ratio:16/9;margin:16px 0 0;border-radius:var(--r);background:#000;object-fit:cover}
340
- .sh-note-audio{margin:16px 0 0;padding:12px 14px;border:1px solid var(--rule);border-radius:var(--r);background:var(--card)}
341
- .sh-note-audio figcaption{font-size:var(--fs-meta);color:var(--muted);margin:0 0 8px}
342
- .sh-note-audio audio{display:block;width:100%;height:36px}
343
- .sh-note-link{display:grid;gap:2px;margin:16px 0 0;padding:14px 16px;border:1px solid var(--rule);border-radius:var(--r);background:var(--card);text-decoration:none;transition:background-color .15s ease,box-shadow .15s ease}
344
- @media (hover:hover){.sh-note-link:hover{background:var(--card-hover);box-shadow:var(--card-shadow)}}
345
- .sh-note-link .h{font-size:var(--fs-meta);color:var(--muted)}
346
- .sh-note-link .t{font:600 15px/1.5 var(--sans);color:var(--ink)}
347
- .sh-note-link .t::after{content:" ↗";color:var(--muted);font-weight:400}
348
- .sh-note-link .d{font-size:var(--fs-small);line-height:1.6;color:var(--soft)}
349
- .sh-note footer{display:flex;flex-wrap:wrap;align-items:center;gap:4px 14px;margin-top:14px;font-size:var(--fs-meta);color:var(--muted)}
350
- .sh-note .when{color:var(--muted);text-decoration:none;font-variant-numeric:tabular-nums;padding:4px 0}
351
- .sh-note .when:hover{color:var(--blue);text-decoration:underline;text-underline-offset:3px}
352
- .sh-note .tag{color:var(--soft)}
353
- .sh-note .ex{font-size:12px;line-height:1;color:var(--muted);border:1px dashed var(--rule-strong);border-radius:6px;padding:3px 6px}
354
- html[data-theme="dark"] .sh-note-imgs img,html[data-theme="dark"] .sh-note-video{filter:brightness(.92)}
355
327
 
356
328
  @media (max-width:820px){
357
329
  .sh-subs{flex-wrap:nowrap;overflow-x:auto;margin:6px -20px 4px;padding:12px 20px 0;scrollbar-width:none}
358
330
  .sh-subs::-webkit-scrollbar{display:none}
359
- .sh-note:target{box-shadow:-20px 0 0 var(--blue-tint),20px 0 0 var(--blue-tint)}
360
331
  }
361
332
 
362
333
 
@@ -437,5 +408,5 @@ html[data-theme="dark"] .sh-repos > li.sk::after{background:linear-gradient(90de
437
408
 
438
409
  /* Cards answer hover with a warm fill and a faint shadow; nothing moves. */
439
410
  @media (prefers-reduced-motion:reduce){
440
- .sh-col,.sh-note-link,.sh-repos > li,.sh-tagchip{transition:none}
411
+ .sh-col,.sh-repos > li,.sh-tagchip{transition:none}
441
412
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hd-garden",
3
- "version": "0.5.8",
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
  }