hd-garden 0.9.0 → 0.9.2
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/bookshelf/load.mjs +96 -0
- package/app/bookshelf/load.ts +12 -19
- package/app/bookshelf/page.tsx +43 -10
- package/app/components/SiteChrome.tsx +14 -6
- package/app/components/SiteHeader.tsx +3 -3
- package/app/components/bookshelf/Bookshelf.tsx +64 -9
- package/app/components/bookshelf/bookshelf.css +211 -44
- package/app/components/bookshelf/catalog.mjs +116 -0
- package/app/components/bookshelf/catalog.ts +23 -0
- package/app/components/bookshelf/engine.ts +402 -117
- package/app/components/bookshelf/schema.mjs +86 -0
- package/app/components/bookshelf/schema.ts +25 -87
- package/app/components/bookshelf/types.ts +20 -3
- package/app/garden-modules.d.ts +6 -0
- package/app/layout.tsx +2 -1
- package/app/lib/i18n.ts +2 -2
- package/app/sitemap.ts +3 -0
- package/docs/handbook/config.md +41 -2
- package/docs/handbook/en/config.md +41 -2
- package/package.json +1 -1
- package/scripts/build-content.mjs +17 -1
- package/scripts/handbook-facts.mjs +2 -1
- package/ui/garden/site-nav.mjs +12 -0
- package/vite.config.ts +12 -3
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
.bookshelf-demo .cover.is-extracting:hover,
|
|
7
7
|
.bookshelf-demo .spine.is-extracting,
|
|
8
8
|
.bookshelf-demo .spine.is-extracting:hover,
|
|
9
|
-
.bookshelf-demo .spine.is-extracting.is-expanded
|
|
9
|
+
.bookshelf-demo .spine.is-extracting.is-expanded,
|
|
10
|
+
.bookshelf-demo .spine.is-seat,
|
|
11
|
+
.bookshelf-demo .spine.is-seat:hover,
|
|
12
|
+
.bookshelf-demo .spine.is-seat:focus-visible {
|
|
10
13
|
transform: none !important;
|
|
11
14
|
box-shadow: none !important;
|
|
12
15
|
transition: none !important;
|
|
@@ -232,18 +235,22 @@
|
|
|
232
235
|
z-index: 2;
|
|
233
236
|
}
|
|
234
237
|
}
|
|
235
|
-
.bookshelf-demo .cover.is-placeholder
|
|
236
|
-
|
|
238
|
+
.bookshelf-demo .cover.is-placeholder,
|
|
239
|
+
.bookshelf-demo .spine.is-placeholder {
|
|
240
|
+
/* Empty seat while the flyer owns the motion: shelf surface only.
|
|
241
|
+
A bk-* fill here is the gray/lavender plate behind the real cover. */
|
|
237
242
|
opacity: 0 !important;
|
|
238
243
|
visibility: hidden !important;
|
|
239
244
|
background-color: transparent !important;
|
|
240
245
|
background-image: none !important;
|
|
241
|
-
filter: none;
|
|
246
|
+
filter: none !important;
|
|
242
247
|
pointer-events: none;
|
|
243
|
-
box-shadow: none;
|
|
248
|
+
box-shadow: none !important;
|
|
249
|
+
color: transparent !important;
|
|
244
250
|
transform: none !important;
|
|
245
251
|
}
|
|
246
|
-
.bookshelf-demo .cover.is-placeholder > .cover-art
|
|
252
|
+
.bookshelf-demo .cover.is-placeholder > .cover-art,
|
|
253
|
+
.bookshelf-demo .spine.is-placeholder > .cover-art {
|
|
247
254
|
visibility: hidden !important;
|
|
248
255
|
opacity: 0 !important;
|
|
249
256
|
}
|
|
@@ -339,15 +346,7 @@
|
|
|
339
346
|
z-index: 2;
|
|
340
347
|
}
|
|
341
348
|
}
|
|
342
|
-
.bookshelf-demo .spine.is-placeholder {
|
|
343
|
-
/* ux13: invisible seat — ban gray ghost plate */
|
|
344
|
-
opacity: 0;
|
|
345
|
-
filter: none;
|
|
346
|
-
pointer-events: none;
|
|
347
|
-
box-shadow: none;
|
|
348
|
-
transform: none !important;
|
|
349
|
-
}
|
|
350
|
-
.bookshelf-demo .spine.is-placeholder span { opacity: 0; }
|
|
349
|
+
.bookshelf-demo .spine.is-placeholder span { opacity: 0 !important; visibility: hidden !important; }
|
|
351
350
|
.bookshelf-demo .cover.is-placeholder.is-landing,
|
|
352
351
|
.bookshelf-demo .spine.is-placeholder.is-landing {
|
|
353
352
|
opacity: 0;
|
|
@@ -356,6 +355,25 @@
|
|
|
356
355
|
|
|
357
356
|
/* Mode C: temporary expand on spine wall */
|
|
358
357
|
/* resting spines: solid hue only; cover image only when expanded */
|
|
358
|
+
/* Expanded / extracting face must not fall back to bk-* (#513688 etc.).
|
|
359
|
+
The jacket is .cover-art. A missing image stays cream shelf, not a shell pad. */
|
|
360
|
+
.bookshelf-demo .spine.is-expanded,
|
|
361
|
+
.bookshelf-demo .spine.is-extracting {
|
|
362
|
+
background-color: transparent !important;
|
|
363
|
+
}
|
|
364
|
+
.bookshelf-demo .spine.is-expanded > .cover-art,
|
|
365
|
+
.bookshelf-demo .spine.is-extracting > .cover-art {
|
|
366
|
+
position: absolute;
|
|
367
|
+
inset: 0;
|
|
368
|
+
width: 100%;
|
|
369
|
+
height: 100%;
|
|
370
|
+
object-fit: contain;
|
|
371
|
+
object-position: center top;
|
|
372
|
+
display: block;
|
|
373
|
+
pointer-events: none;
|
|
374
|
+
z-index: 1;
|
|
375
|
+
border-radius: inherit;
|
|
376
|
+
}
|
|
359
377
|
.bookshelf-demo .spine[class*="img-"] {
|
|
360
378
|
background-image: none !important;
|
|
361
379
|
}
|
|
@@ -556,15 +574,32 @@
|
|
|
556
574
|
align-items: flex-start;
|
|
557
575
|
margin-bottom: 36px;
|
|
558
576
|
}
|
|
577
|
+
.bookshelf-demo .note-meta {
|
|
578
|
+
min-width: 0;
|
|
579
|
+
flex: 1 1 auto;
|
|
580
|
+
}
|
|
581
|
+
/* Settled note header: size the box to the picture. A fixed 2:3 frame with
|
|
582
|
+
background-size:cover center-crops landscape heroes (Obsidian → "sid"). */
|
|
559
583
|
.bookshelf-demo .note-cover {
|
|
560
|
-
width:
|
|
561
|
-
|
|
562
|
-
|
|
584
|
+
width: max-content;
|
|
585
|
+
max-width: min(420px, 46%);
|
|
586
|
+
height: auto;
|
|
587
|
+
flex: 0 0 auto;
|
|
563
588
|
border-radius: 2px 3px 0 0;
|
|
564
|
-
background-
|
|
565
|
-
background-
|
|
566
|
-
background-color: #2a2a2a;
|
|
589
|
+
background-color: transparent;
|
|
590
|
+
background-image: none;
|
|
567
591
|
box-shadow: 0 8px 20px rgba(40,30,20,0.12);
|
|
592
|
+
line-height: 0;
|
|
593
|
+
}
|
|
594
|
+
.bookshelf-demo .note-cover-img {
|
|
595
|
+
display: block;
|
|
596
|
+
width: auto;
|
|
597
|
+
height: auto;
|
|
598
|
+
max-width: 100%;
|
|
599
|
+
max-height: 180px;
|
|
600
|
+
object-fit: contain;
|
|
601
|
+
object-position: center;
|
|
602
|
+
border-radius: inherit;
|
|
568
603
|
}
|
|
569
604
|
.bookshelf-demo .note-cat {
|
|
570
605
|
margin: 0 0 8px;
|
|
@@ -674,7 +709,7 @@
|
|
|
674
709
|
contain: layout style;
|
|
675
710
|
}
|
|
676
711
|
.bookshelf-demo .flyer.is-ready {
|
|
677
|
-
will-change:
|
|
712
|
+
will-change: translate, scale, opacity;
|
|
678
713
|
}
|
|
679
714
|
|
|
680
715
|
/* Soft oval foot shadow — no filter:blur (blur+anim = jitter) */
|
|
@@ -710,21 +745,16 @@
|
|
|
710
745
|
backface-visibility: hidden;
|
|
711
746
|
}
|
|
712
747
|
|
|
713
|
-
/*
|
|
748
|
+
/* ux27 plate ban: this used to be a full-spread shell (fallback #6a7a98 / #5a6a88,
|
|
749
|
+
and shell-hi of dark covers reads the same gray-lavender). It sat wider than
|
|
750
|
+
the jacket during lift and put-back. Fore-edge stays on .flyer-thickness. */
|
|
714
751
|
.bookshelf-demo .flyer-board {
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
var(--shell-hi, #6a7a98) 0%,
|
|
722
|
-
var(--shell, #5a6a88) 55%,
|
|
723
|
-
var(--shell-lo, #3a4a68) 100%
|
|
724
|
-
);
|
|
725
|
-
box-shadow: 0 1px 0 rgba(255,255,255,0.06) inset;
|
|
726
|
-
transform: translateZ(-8px);
|
|
727
|
-
z-index: 0;
|
|
752
|
+
display: none !important;
|
|
753
|
+
background: none !important;
|
|
754
|
+
background-color: transparent !important;
|
|
755
|
+
box-shadow: none !important;
|
|
756
|
+
opacity: 0 !important;
|
|
757
|
+
visibility: hidden !important;
|
|
728
758
|
}
|
|
729
759
|
|
|
730
760
|
.bookshelf-demo .flyer-leaf {
|
|
@@ -756,7 +786,7 @@
|
|
|
756
786
|
/* Cover on BACK of left leaf — camera-facing when leaf folded (180°) */
|
|
757
787
|
.bookshelf-demo .flyer-face-cover {
|
|
758
788
|
transform: rotateY(180deg);
|
|
759
|
-
background-color: transparent; /* ux24:
|
|
789
|
+
background-color: transparent !important; /* ux24/ux27: bk-* must not fill behind the img */
|
|
760
790
|
/* No background-image here — .img-* / inline applyCoverImage owns the photo.
|
|
761
791
|
Gradients-only used to override img classes → flat brown/gray close flash. */
|
|
762
792
|
box-shadow:
|
|
@@ -779,17 +809,18 @@
|
|
|
779
809
|
}
|
|
780
810
|
.bookshelf-demo .flyer-spine-label { pointer-events: none; }
|
|
781
811
|
|
|
782
|
-
/* ux15 plate kept inert; ux16 uses full-height .flyer-spine-slot instead
|
|
812
|
+
/* ux15 plate kept inert; ux16 uses full-height .flyer-spine-slot instead.
|
|
813
|
+
Never paint the lavender shell fallback if a leftover plate node is mounted. */
|
|
783
814
|
.bookshelf-demo .flyer-spine-land-plate {
|
|
784
815
|
position: absolute;
|
|
785
816
|
inset: 0;
|
|
786
817
|
pointer-events: none;
|
|
787
818
|
border-radius: inherit;
|
|
788
819
|
z-index: 1;
|
|
789
|
-
display: none;
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
820
|
+
display: none !important;
|
|
821
|
+
background: none !important;
|
|
822
|
+
background-color: transparent !important;
|
|
823
|
+
opacity: 0 !important;
|
|
793
824
|
}
|
|
794
825
|
|
|
795
826
|
/* ux16: exact homeRect standing spine — full height, spine width, slides out/in */
|
|
@@ -999,6 +1030,18 @@
|
|
|
999
1030
|
overflow: auto;
|
|
1000
1031
|
}
|
|
1001
1032
|
|
|
1033
|
+
/* Close: flyer owns the motion. The note used to stay at z-index 75, so the
|
|
1034
|
+
article sat on top of the return for the whole fade and the shrink was hidden. */
|
|
1035
|
+
.bookshelf-demo.is-return .stage { z-index: 80; }
|
|
1036
|
+
.bookshelf-demo.is-return .note-view,
|
|
1037
|
+
.bookshelf-demo.is-return .note-view.is-preparing,
|
|
1038
|
+
.bookshelf-demo.is-return .note-view.is-open { z-index: 60; }
|
|
1039
|
+
.bookshelf-demo.is-site.is-return .stage { z-index: 36; }
|
|
1040
|
+
.bookshelf-demo.is-site.is-return .open-backdrop { z-index: 30; }
|
|
1041
|
+
.bookshelf-demo.is-site.is-return .note-view,
|
|
1042
|
+
.bookshelf-demo.is-site.is-return .note-view.is-preparing,
|
|
1043
|
+
.bookshelf-demo.is-site.is-return .note-view.is-open { z-index: 31; }
|
|
1044
|
+
|
|
1002
1045
|
.bookshelf-demo .shelf-view.is-dim {
|
|
1003
1046
|
/* Backdrop covers shelf — dim is unused during open; keep inert */
|
|
1004
1047
|
opacity: 1;
|
|
@@ -1056,7 +1099,7 @@
|
|
|
1056
1099
|
inset: 0;
|
|
1057
1100
|
border-radius: inherit;
|
|
1058
1101
|
overflow: hidden;
|
|
1059
|
-
background-color: transparent; /* ux24: no
|
|
1102
|
+
background-color: transparent !important; /* ux24/ux27: no solid shell behind the img */
|
|
1060
1103
|
background-size: cover;
|
|
1061
1104
|
background-position: center top;
|
|
1062
1105
|
background-repeat: no-repeat;
|
|
@@ -1159,9 +1202,133 @@
|
|
|
1159
1202
|
.bookshelf-demo .spine.is-expanded.is-collapsing.s-mid { width: 24px; height: 134px; }
|
|
1160
1203
|
.bookshelf-demo .spine.is-expanded.is-collapsing.s-short { width: 20px; height: 120px; }
|
|
1161
1204
|
.bookshelf-demo .note-hero { flex-direction: column; gap: 16px; }
|
|
1162
|
-
.bookshelf-demo .note-cover { width:
|
|
1205
|
+
.bookshelf-demo .note-cover { width: max-content; max-width: 100%; }
|
|
1206
|
+
.bookshelf-demo .note-cover-img {
|
|
1207
|
+
width: auto;
|
|
1208
|
+
max-width: 100%;
|
|
1209
|
+
max-height: 180px;
|
|
1210
|
+
height: auto;
|
|
1211
|
+
}
|
|
1163
1212
|
.bookshelf-demo .qa-hint { width: 100%; margin-left: 0; margin-top: 4px; }
|
|
1164
1213
|
}
|
|
1165
1214
|
|
|
1166
1215
|
/* Reading path: hide demo/QA chrome unless ?debug=1 */
|
|
1167
1216
|
body:not(.debug-on) .bookshelf-demo .is-debug { display: none !important; }
|
|
1217
|
+
|
|
1218
|
+
/* Site shelf: same column, type, and header as the blog. Motion rules above stay. */
|
|
1219
|
+
.bookshelf-page.sh-main {
|
|
1220
|
+
display: flex;
|
|
1221
|
+
flex-direction: column;
|
|
1222
|
+
align-items: center;
|
|
1223
|
+
}
|
|
1224
|
+
.bookshelf-page .sh-head {
|
|
1225
|
+
width: 100%;
|
|
1226
|
+
max-width: var(--col);
|
|
1227
|
+
margin-inline: auto;
|
|
1228
|
+
}
|
|
1229
|
+
.bookshelf-page .sh-kicker {
|
|
1230
|
+
margin: 0 0 12px;
|
|
1231
|
+
font-size: var(--fs-meta);
|
|
1232
|
+
letter-spacing: 0.08em;
|
|
1233
|
+
color: var(--muted);
|
|
1234
|
+
}
|
|
1235
|
+
.bookshelf-demo.is-site {
|
|
1236
|
+
--site-sans: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
|
|
1237
|
+
width: 100%;
|
|
1238
|
+
max-width: var(--col);
|
|
1239
|
+
margin-inline: auto;
|
|
1240
|
+
background: transparent;
|
|
1241
|
+
min-height: 0;
|
|
1242
|
+
color: inherit;
|
|
1243
|
+
font-family: var(--site-sans);
|
|
1244
|
+
font-size: inherit;
|
|
1245
|
+
line-height: inherit;
|
|
1246
|
+
}
|
|
1247
|
+
.bookshelf-demo.is-site .page {
|
|
1248
|
+
width: 100%;
|
|
1249
|
+
max-width: none;
|
|
1250
|
+
margin: 0;
|
|
1251
|
+
padding: 0 0 8px;
|
|
1252
|
+
}
|
|
1253
|
+
.bookshelf-demo.is-site .top { display: none; }
|
|
1254
|
+
.bookshelf-page .sh-head h1,
|
|
1255
|
+
.bookshelf-page .sh-deck,
|
|
1256
|
+
.bookshelf-page .sh-kicker,
|
|
1257
|
+
body:has(.bookshelf-demo.is-site) .sh-header,
|
|
1258
|
+
body:has(.bookshelf-demo.is-site) .sh-footer {
|
|
1259
|
+
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
|
|
1260
|
+
}
|
|
1261
|
+
body:has(.bookshelf-demo.is-site) .sh-header .sh-word {
|
|
1262
|
+
font-family: "Songti SC", "STSong", "Source Serif 4", Georgia, serif;
|
|
1263
|
+
}
|
|
1264
|
+
/* Overlays stay under the sticky header so open/close does not cover it. */
|
|
1265
|
+
.bookshelf-demo.is-site .open-backdrop {
|
|
1266
|
+
top: var(--hdr);
|
|
1267
|
+
z-index: 30;
|
|
1268
|
+
background: var(--paper);
|
|
1269
|
+
}
|
|
1270
|
+
.bookshelf-demo.is-site .stage { z-index: 32; }
|
|
1271
|
+
.bookshelf-demo.is-site .note-view {
|
|
1272
|
+
top: var(--hdr);
|
|
1273
|
+
background: var(--paper);
|
|
1274
|
+
color: var(--text);
|
|
1275
|
+
}
|
|
1276
|
+
.bookshelf-demo.is-site .note-view.is-preparing,
|
|
1277
|
+
.bookshelf-demo.is-site .note-view.is-open { z-index: 34; }
|
|
1278
|
+
.bookshelf-demo.is-site .open-veil { z-index: 36; }
|
|
1279
|
+
.bookshelf-demo.is-site .note {
|
|
1280
|
+
width: min(100%, var(--col));
|
|
1281
|
+
margin: 0 auto;
|
|
1282
|
+
padding: 28px 0 80px;
|
|
1283
|
+
}
|
|
1284
|
+
.bookshelf-demo.is-site .back-btn,
|
|
1285
|
+
.bookshelf-demo.is-site .note-hero,
|
|
1286
|
+
.bookshelf-demo.is-site .note-body,
|
|
1287
|
+
.bookshelf-demo.is-site .note-read {
|
|
1288
|
+
max-width: none;
|
|
1289
|
+
font-family: var(--site-sans);
|
|
1290
|
+
}
|
|
1291
|
+
.bookshelf-demo.is-site .note-body {
|
|
1292
|
+
font-size: var(--fs-body);
|
|
1293
|
+
line-height: 1.75;
|
|
1294
|
+
color: var(--text);
|
|
1295
|
+
}
|
|
1296
|
+
.bookshelf-demo.is-site .note-body h2 {
|
|
1297
|
+
font-family: inherit;
|
|
1298
|
+
font-size: var(--fs-h2);
|
|
1299
|
+
font-weight: 700;
|
|
1300
|
+
line-height: 1.3;
|
|
1301
|
+
color: var(--ink);
|
|
1302
|
+
}
|
|
1303
|
+
.bookshelf-demo.is-site .note-meta h1 {
|
|
1304
|
+
font-family: inherit;
|
|
1305
|
+
font-weight: 700;
|
|
1306
|
+
font-size: var(--fs-display);
|
|
1307
|
+
line-height: 1.2;
|
|
1308
|
+
color: var(--ink);
|
|
1309
|
+
}
|
|
1310
|
+
.bookshelf-demo .flyer-page-more {
|
|
1311
|
+
pointer-events: auto;
|
|
1312
|
+
align-self: flex-start;
|
|
1313
|
+
margin-top: 8px;
|
|
1314
|
+
font-family: system-ui, sans-serif;
|
|
1315
|
+
font-size: 9px;
|
|
1316
|
+
font-weight: 600;
|
|
1317
|
+
letter-spacing: 0.04em;
|
|
1318
|
+
color: var(--ink, #2a261e);
|
|
1319
|
+
text-decoration: underline;
|
|
1320
|
+
text-underline-offset: 2px;
|
|
1321
|
+
}
|
|
1322
|
+
.bookshelf-demo .note-read {
|
|
1323
|
+
display: inline-block;
|
|
1324
|
+
margin: 12px 0 0;
|
|
1325
|
+
font-size: 14px;
|
|
1326
|
+
color: var(--ink);
|
|
1327
|
+
text-decoration: underline;
|
|
1328
|
+
text-underline-offset: 3px;
|
|
1329
|
+
}
|
|
1330
|
+
.bookshelf-demo .note-read[hidden] { display: none; }
|
|
1331
|
+
.bookshelf-demo.is-site .note-read { color: var(--blue); }
|
|
1332
|
+
@media (max-width: 820px) {
|
|
1333
|
+
.bookshelf-demo.is-site .note { padding: 16px 20px 64px; }
|
|
1334
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { marked } from "marked";
|
|
2
|
+
import { BookshelfConfigError } from "./schema.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Site shelf for `<Bookshelf catalog notes />`.
|
|
6
|
+
* Built only from `content/bookshelf.yaml` and the posts it names.
|
|
7
|
+
* The package demo is a separate path: this module never reads it.
|
|
8
|
+
*
|
|
9
|
+
* `post` is a `content/posts` path without `.md`. Its front matter description
|
|
10
|
+
* leads the note HTML so the open book's right page is that summary.
|
|
11
|
+
* A cover file name is published at `/media/covers/<file>` (same stem cleanup
|
|
12
|
+
* as content sync). A full URL or a path starting with `/` is kept.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const WIDGET_FENCE = /```(?:garden|ui-error|ui|intent|widget)(?:[ \t]+[^\n]*)?\r?\n[\s\S]*?```/gu;
|
|
16
|
+
|
|
17
|
+
export function resolveBookshelfCover(cover, where = "cover") {
|
|
18
|
+
if (!cover?.trim()) return undefined;
|
|
19
|
+
const value = cover.trim();
|
|
20
|
+
if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//iu.test(value) || value.startsWith("/") || value.startsWith("data:")) {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
let relative = value.replace(/^\.\//u, "");
|
|
24
|
+
if (relative.startsWith("covers/")) relative = relative.slice("covers/".length);
|
|
25
|
+
const parts = relative.split("/");
|
|
26
|
+
if (!relative || parts.some((segment) => segment === "" || segment === "." || segment === "..")) {
|
|
27
|
+
throw new BookshelfConfigError("cover", `${where} 的 cover 必须是文件名或完整 URL。`);
|
|
28
|
+
}
|
|
29
|
+
return `/media/covers/${parts.map(publishCoverSegment).join("/")}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `content/posts` path, without `.md`. Rejects anything that leaves that directory. */
|
|
33
|
+
export function normalizeBookshelfPostPath(value) {
|
|
34
|
+
const relative = value
|
|
35
|
+
.trim()
|
|
36
|
+
.replaceAll("\\", "/")
|
|
37
|
+
.replace(/^(?:content\/)?posts\//u, "")
|
|
38
|
+
.replace(/\.md$/u, "");
|
|
39
|
+
if (
|
|
40
|
+
!relative
|
|
41
|
+
|| relative.startsWith("/")
|
|
42
|
+
|| relative.split("/").some((segment) => segment === "" || segment === "." || segment === "..")
|
|
43
|
+
) {
|
|
44
|
+
throw new BookshelfConfigError("post", `post「${value}」必须是 content/posts 下的路径,不带 .md。`);
|
|
45
|
+
}
|
|
46
|
+
return relative;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function bookshelfMountFromFile(file, posts) {
|
|
50
|
+
const byPath = new Map(posts.map((post) => [normalizeBookshelfPostPath(post.path), post]));
|
|
51
|
+
const notes = {};
|
|
52
|
+
const catalog = file.shelves.map((shelf) => ({
|
|
53
|
+
id: shelf.id,
|
|
54
|
+
name: shelf.name,
|
|
55
|
+
books: shelf.books.map((book) => toCatalogBook(book, byPath, notes)),
|
|
56
|
+
}));
|
|
57
|
+
return {
|
|
58
|
+
title: file.title,
|
|
59
|
+
description: file.description,
|
|
60
|
+
eyebrow: file.eyebrow,
|
|
61
|
+
catalog,
|
|
62
|
+
notes,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toCatalogBook(book, byPath, notes) {
|
|
67
|
+
const post = book.post ? byPath.get(normalizeBookshelfPostPath(book.post)) : undefined;
|
|
68
|
+
if (book.post && !post) {
|
|
69
|
+
throw new BookshelfConfigError("post", `书架引用的博文 ${book.post} 没有读到。`);
|
|
70
|
+
}
|
|
71
|
+
const cover = resolveBookshelfCover(book.cover, book.id) ?? (post ? resolveBookshelfCover(post.cover, book.id) : undefined);
|
|
72
|
+
const body = post
|
|
73
|
+
? renderNoteHtml(post.content, post.description)
|
|
74
|
+
: book.note
|
|
75
|
+
? renderNoteHtml(book.note)
|
|
76
|
+
: "";
|
|
77
|
+
const note = body ? book.id : null;
|
|
78
|
+
if (note) notes[book.id] = { title: book.title, body, summary: post?.description };
|
|
79
|
+
return {
|
|
80
|
+
id: book.id,
|
|
81
|
+
title: book.title,
|
|
82
|
+
author: book.author ?? "",
|
|
83
|
+
spine: book.spine ?? book.title,
|
|
84
|
+
size: book.size ?? (book.filler ? "thin" : "s-mid"),
|
|
85
|
+
cover: cover ?? false,
|
|
86
|
+
color: book.color,
|
|
87
|
+
filler: book.filler,
|
|
88
|
+
post: book.post ? normalizeBookshelfPostPath(book.post) : undefined,
|
|
89
|
+
note,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function renderNoteHtml(markdown, description) {
|
|
94
|
+
const prose = markdown.replace(WIDGET_FENCE, "\n").trim();
|
|
95
|
+
const parsed = prose ? marked.parse(prose, { async: false }).trim() : "";
|
|
96
|
+
const summary = description?.trim();
|
|
97
|
+
if (!summary || prose.includes(summary)) return parsed;
|
|
98
|
+
return `<p>${escapeHtml(summary)}</p>${parsed ? `\n${parsed}` : ""}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function publishCoverSegment(segment, index, all) {
|
|
102
|
+
const isFile = index === all.length - 1;
|
|
103
|
+
if (!isFile) return encodeURIComponent(segment.replace(/[^\w\u3400-\u9fff.-]+/gu, "-"));
|
|
104
|
+
const dot = segment.lastIndexOf(".");
|
|
105
|
+
const ext = dot >= 0 ? segment.slice(dot).toLowerCase() : "";
|
|
106
|
+
const stem = dot >= 0 ? segment.slice(0, dot) : segment;
|
|
107
|
+
return encodeURIComponent(`${stem.replace(/[^\w\u3400-\u9fff.-]+/gu, "-")}${ext}`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function escapeHtml(value) {
|
|
111
|
+
return value
|
|
112
|
+
.replaceAll("&", "&")
|
|
113
|
+
.replaceAll("<", "<")
|
|
114
|
+
.replaceAll(">", ">")
|
|
115
|
+
.replaceAll('"', """);
|
|
116
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { BookshelfNote, BookshelfShelf } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Site shelf ready for `<Bookshelf catalog notes />`.
|
|
5
|
+
* The runtime lives in `catalog.mjs` (node tests and the page share it).
|
|
6
|
+
* The package demo is not an input.
|
|
7
|
+
*/
|
|
8
|
+
export type BookshelfMount = {
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
eyebrow?: string;
|
|
12
|
+
catalog: BookshelfShelf[];
|
|
13
|
+
notes: Record<string, BookshelfNote>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** A post the shelf can turn into a note. `path` is relative to `content/posts`, without `.md`. */
|
|
17
|
+
export type BookshelfPostSource = {
|
|
18
|
+
path: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
cover?: string;
|
|
22
|
+
content: string;
|
|
23
|
+
};
|