hd-garden 0.5.9 → 0.5.11
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 +18 -10
- package/app/globals.css +186 -36
- package/app/styles/a2.css +0 -1
- package/app/styles/shell.css +4 -1
- package/build/dark-fallback-postcss.mjs +93 -0
- package/package.json +1 -1
- package/postcss.config.mjs +4 -3
- package/ui/garden/search-index.mjs +32 -10
- package/ui/garden/search-match.mjs +7 -4
|
@@ -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
|
-
|
|
280
|
-
|
|
281
|
-
if (!
|
|
282
|
-
|
|
283
|
-
|
|
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 ? `${
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
|
3547
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
3561
|
-
padding:
|
|
3576
|
+
flex: none;
|
|
3577
|
+
padding: 10px 10px 4px 20px;
|
|
3562
3578
|
display: grid;
|
|
3563
|
-
grid-template-columns:
|
|
3579
|
+
grid-template-columns: 20px 1fr 36px;
|
|
3564
3580
|
align-items: center;
|
|
3565
|
-
gap:
|
|
3566
|
-
|
|
3581
|
+
gap: 12px;
|
|
3582
|
+
color: var(--muted);
|
|
3567
3583
|
}
|
|
3568
3584
|
|
|
3569
3585
|
.search-input-wrap input {
|
|
3570
3586
|
width: 100%;
|
|
3571
|
-
|
|
3587
|
+
min-width: 0;
|
|
3588
|
+
height: 48px;
|
|
3589
|
+
padding: 0;
|
|
3572
3590
|
border: 0;
|
|
3573
3591
|
background: transparent;
|
|
3574
3592
|
outline: 0;
|
|
3575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3588
|
-
|
|
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:
|
|
3594
|
-
color: var(--
|
|
3595
|
-
font
|
|
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:
|
|
3602
|
-
|
|
3603
|
-
|
|
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(--
|
|
3657
|
+
background: var(--card-hover);
|
|
3658
|
+
box-shadow: var(--card-shadow);
|
|
3659
|
+
text-decoration: none;
|
|
3610
3660
|
}
|
|
3611
3661
|
|
|
3612
|
-
|
|
3613
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
3722
|
+
padding: 32px 12px;
|
|
3640
3723
|
color: var(--muted);
|
|
3641
|
-
font-size:
|
|
3724
|
+
font-size: var(--fs-small);
|
|
3642
3725
|
text-align: center;
|
|
3643
3726
|
}
|
|
3644
3727
|
|
|
3645
3728
|
.search-footer {
|
|
3646
|
-
|
|
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
|
-
|
|
3651
|
-
|
|
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
package/app/styles/shell.css
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/package.json
CHANGED
package/postcss.config.mjs
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
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: "
|
|
76
|
-
...columns
|
|
77
|
-
|
|
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.
|
|
84
|
-
*
|
|
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 =
|
|
92
|
-
const text = fullText(
|
|
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
|
-
|
|
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
|
-
|
|
96
|
+
const search = (query, limit = 6) => find(query, limit).hits;
|
|
97
|
+
|
|
98
|
+
return { full: Boolean(bodies), find, search };
|
|
96
99
|
}
|