@jant/core 0.6.1 → 0.6.3
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/dist/app-BX2XKxq0.js +6 -0
- package/dist/{app-DYQdDMs8.js → app-CyysIxj_.js} +464 -240
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BMPMuwvV.css +2 -0
- package/dist/client/_assets/client-CTrEFM5W.js +275 -0
- package/dist/client/_assets/{client-auth-CSNcTJwP.js → client-auth-LBSZxqNC.js} +24 -24
- package/dist/{env-C7e2Nlnt.js → env-CoSe-1y4.js} +1 -1
- package/dist/{export-Bbn86HmS.js → export-CzuQyg5h.js} +32 -19
- package/dist/{github-api-Bh0PH3zr.js → github-api-UD4u_7fa.js} +1 -1
- package/dist/{github-app-D0GvNnqp.js → github-app-DeX6Td1O.js} +1 -1
- package/dist/{github-sync-dXsiZa_e.js → github-sync-CerNYCAn.js} +3 -3
- package/dist/{github-sync-CBQPRZ8H.js → github-sync-Dbrb1DS5.js} +7 -4
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-umUptr5z.js → url-XF0GbKGO.js} +22 -1
- package/package.json +1 -1
- package/src/__tests__/export-service.test.ts +127 -0
- package/src/client/__tests__/image-processor.test.ts +64 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +0 -42
- package/src/client/components/__tests__/jant-media-lightbox.test.ts +79 -8
- package/src/client/components/collection-manager-types.ts +0 -2
- package/src/client/components/jant-collection-directory.ts +0 -23
- package/src/client/components/jant-compose-editor.ts +2 -2
- package/src/client/components/jant-media-lightbox.ts +33 -5
- package/src/client/image-processor.ts +89 -30
- package/src/client/media-scroll-hint.ts +62 -9
- package/src/i18n/coverage.generated.ts +2 -2
- package/src/i18n/locales/public/en.po +0 -12
- package/src/i18n/locales/public/zh-Hans.po +0 -12
- package/src/i18n/locales/public/zh-Hant.po +0 -12
- package/src/i18n/locales/settings/zh-Hans.po +24 -24
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +24 -24
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/structured-data.test.ts +87 -0
- package/src/lib/github-sync-site-config.ts +4 -2
- package/src/lib/post-display.ts +78 -1
- package/src/lib/render.tsx +28 -0
- package/src/lib/structured-data.ts +113 -0
- package/src/lib/url.ts +26 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +65 -0
- package/src/routes/api/internal/sites.ts +19 -0
- package/src/routes/pages/home.tsx +21 -1
- package/src/routes/pages/page.tsx +53 -2
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +30 -29
- package/src/services/export-theme/layouts/partials/media-gallery.html +16 -7
- package/src/services/export-theme/styles/main.css +4 -3
- package/src/services/export.ts +47 -17
- package/src/services/github-sync.ts +8 -2
- package/src/services/site-admin.ts +53 -1
- package/src/styles/site-media.css +70 -24
- package/src/styles/ui.css +30 -48
- package/src/ui/layouts/BaseLayout.tsx +110 -16
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +146 -0
- package/src/ui/pages/CollectionsPage.tsx +0 -22
- package/src/ui/shared/CollectionsManager.tsx +6 -41
- package/src/ui/shared/MediaGallery.tsx +50 -7
- package/src/ui/shared/__tests__/media-gallery.test.ts +31 -0
- package/dist/app-CMSW_AYG.js +0 -6
- package/dist/client/_assets/client-BRTh1ii1.js +0 -274
- package/dist/client/_assets/client-CO4b-RKd.css +0 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { and, eq, sql } from "drizzle-orm";
|
|
1
|
+
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
2
2
|
import {
|
|
3
3
|
executeStatement,
|
|
4
4
|
type Database,
|
|
@@ -87,6 +87,11 @@ export interface ManagedSiteMediaUsageResult {
|
|
|
87
87
|
siteId: string;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
export interface ManagedSitePostCountResult {
|
|
91
|
+
publishedPostCount: number;
|
|
92
|
+
siteId: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
90
95
|
export interface ManagedSiteKeyAvailabilityResult {
|
|
91
96
|
available: boolean;
|
|
92
97
|
key: string;
|
|
@@ -114,6 +119,15 @@ export interface SiteAdminService {
|
|
|
114
119
|
getManagedSiteMediaUsage(
|
|
115
120
|
siteId: string,
|
|
116
121
|
): Promise<ManagedSiteMediaUsageResult>;
|
|
122
|
+
/**
|
|
123
|
+
* Batch published-post counts for hosted sites, keyed by site id. Used by the
|
|
124
|
+
* control-plane admin site list to show how much content each blog has.
|
|
125
|
+
* Unknown site ids resolve to a zero count instead of an error so a stale
|
|
126
|
+
* control-plane pointer never fails the whole lookup.
|
|
127
|
+
*/
|
|
128
|
+
getManagedSitePostCounts(
|
|
129
|
+
siteIds: string[],
|
|
130
|
+
): Promise<ManagedSitePostCountResult[]>;
|
|
117
131
|
suspendManagedSite(siteId: string): Promise<Site>;
|
|
118
132
|
resumeManagedSite(siteId: string): Promise<Site>;
|
|
119
133
|
deleteManagedSite(
|
|
@@ -588,6 +602,40 @@ export function createSiteAdminService(
|
|
|
588
602
|
};
|
|
589
603
|
}
|
|
590
604
|
|
|
605
|
+
async function getManagedSitePostCounts(
|
|
606
|
+
siteIds: string[],
|
|
607
|
+
): Promise<ManagedSitePostCountResult[]> {
|
|
608
|
+
const normalizedSiteIds = [
|
|
609
|
+
...new Set(siteIds.map((siteId) => siteId.trim()).filter(Boolean)),
|
|
610
|
+
];
|
|
611
|
+
if (normalizedSiteIds.length === 0) {
|
|
612
|
+
return [];
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const rows = await db
|
|
616
|
+
.select({
|
|
617
|
+
publishedPostCount: sql<number>`cast(count(*) as integer)`,
|
|
618
|
+
siteId: posts.siteId,
|
|
619
|
+
})
|
|
620
|
+
.from(posts)
|
|
621
|
+
.where(
|
|
622
|
+
and(
|
|
623
|
+
inArray(posts.siteId, normalizedSiteIds),
|
|
624
|
+
eq(posts.status, "published"),
|
|
625
|
+
),
|
|
626
|
+
)
|
|
627
|
+
.groupBy(posts.siteId);
|
|
628
|
+
|
|
629
|
+
const countBySiteId = new Map(
|
|
630
|
+
rows.map((row) => [row.siteId, Number(row.publishedPostCount ?? 0)]),
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
return normalizedSiteIds.map((siteId) => ({
|
|
634
|
+
publishedPostCount: countBySiteId.get(siteId) ?? 0,
|
|
635
|
+
siteId,
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
|
|
591
639
|
async function mutateSiteDomains(
|
|
592
640
|
siteId: string,
|
|
593
641
|
mutate: (targetDb: Database, normalizedSiteId: string) => Promise<void>,
|
|
@@ -637,6 +685,10 @@ export function createSiteAdminService(
|
|
|
637
685
|
assertManagedSiteOperationsEnabled();
|
|
638
686
|
return getManagedSiteMediaUsage(siteId);
|
|
639
687
|
},
|
|
688
|
+
async getManagedSitePostCounts(siteIds) {
|
|
689
|
+
assertManagedSiteOperationsEnabled();
|
|
690
|
+
return getManagedSitePostCounts(siteIds);
|
|
691
|
+
},
|
|
640
692
|
async exportManagedSite(siteId, deps) {
|
|
641
693
|
assertManagedSiteOperationsEnabled();
|
|
642
694
|
const normalizedSiteId = siteId.trim();
|
|
@@ -79,6 +79,10 @@
|
|
|
79
79
|
animation: lightbox-scale-in 0.28s cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
.media-lightbox-img-zoomable {
|
|
83
|
+
cursor: zoom-in;
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
.media-lightbox-img-scroll {
|
|
83
87
|
width: min(100%, 44rem);
|
|
84
88
|
max-width: none;
|
|
@@ -87,6 +91,10 @@
|
|
|
87
91
|
margin: 0 auto;
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
.media-lightbox-img-zoomable.media-lightbox-img-scroll {
|
|
95
|
+
cursor: zoom-out;
|
|
96
|
+
}
|
|
97
|
+
|
|
90
98
|
.media-lightbox-close {
|
|
91
99
|
position: fixed;
|
|
92
100
|
top: 16px;
|
|
@@ -794,36 +802,74 @@ button.media-gallery-card {
|
|
|
794
802
|
background: transparent;
|
|
795
803
|
}
|
|
796
804
|
|
|
797
|
-
/* --- Gallery
|
|
805
|
+
/* --- Gallery scroll arrows --------------------------------------------- */
|
|
798
806
|
|
|
799
807
|
.media-gallery-scroll-wrap {
|
|
800
|
-
/*
|
|
801
|
-
|
|
802
|
-
--_mask-left: black;
|
|
803
|
-
--_mask-right: black;
|
|
808
|
+
/* Positioning context for the prev/next scroll arrows. */
|
|
809
|
+
position: relative;
|
|
804
810
|
}
|
|
811
|
+
/* The strip is trackpad/touch-friendly, but a plain mouse has no visible way
|
|
812
|
+
to scroll it (the scrollbar is hidden). These buttons give mouse users an
|
|
813
|
+
affordance; they appear on hover over whichever side still has hidden
|
|
814
|
+
content. Keyboard users scroll the focusable strip with Arrow/Home/End keys
|
|
815
|
+
instead — wired up in media-scroll-hint.ts. */
|
|
805
816
|
|
|
806
|
-
.media-gallery-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
);
|
|
817
|
+
.media-gallery-nav {
|
|
818
|
+
position: absolute;
|
|
819
|
+
top: 50%;
|
|
820
|
+
z-index: 2;
|
|
821
|
+
transform: translateY(-50%);
|
|
822
|
+
display: flex;
|
|
823
|
+
align-items: center;
|
|
824
|
+
justify-content: center;
|
|
825
|
+
width: 34px;
|
|
826
|
+
height: 34px;
|
|
827
|
+
padding: 0;
|
|
828
|
+
border: none;
|
|
829
|
+
border-radius: 50%;
|
|
830
|
+
background-color: #00000080;
|
|
831
|
+
-webkit-backdrop-filter: blur(8px);
|
|
832
|
+
backdrop-filter: blur(8px);
|
|
833
|
+
color: #fff;
|
|
834
|
+
cursor: pointer;
|
|
835
|
+
opacity: 0;
|
|
836
|
+
pointer-events: none;
|
|
837
|
+
transition:
|
|
838
|
+
opacity 0.15s ease,
|
|
839
|
+
background-color 0.15s ease;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
.media-gallery-nav:hover {
|
|
843
|
+
background-color: #000000b3;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
.media-gallery-nav svg {
|
|
847
|
+
display: block;
|
|
848
|
+
width: 18px;
|
|
849
|
+
height: 18px;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
.media-gallery-nav-prev {
|
|
853
|
+
left: 8px;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
.media-gallery-nav-next {
|
|
857
|
+
right: 8px;
|
|
821
858
|
}
|
|
822
859
|
|
|
823
|
-
|
|
824
|
-
|
|
860
|
+
/* Reveal only with a real mouse (not touch), and only for the side that can
|
|
861
|
+
actually scroll — mirrors the can-scroll-* state used by the edge fade. */
|
|
862
|
+
@media (hover: hover) and (pointer: fine) {
|
|
863
|
+
.media-gallery-scroll-wrap.can-scroll-start:hover .media-gallery-nav-prev,
|
|
864
|
+
.media-gallery-scroll-wrap.can-scroll-end:hover .media-gallery-nav-next {
|
|
865
|
+
opacity: 1;
|
|
866
|
+
pointer-events: auto;
|
|
867
|
+
}
|
|
825
868
|
}
|
|
826
869
|
|
|
827
|
-
.
|
|
828
|
-
|
|
870
|
+
/* Visible focus ring when a keyboard user tabs onto the scrollable strip. */
|
|
871
|
+
.media-gallery-scroll-wrap > [data-post-media]:focus-visible {
|
|
872
|
+
outline: 2px solid var(--site-text-primary);
|
|
873
|
+
outline-offset: 2px;
|
|
874
|
+
border-radius: 4px;
|
|
829
875
|
}
|
package/src/styles/ui.css
CHANGED
|
@@ -611,12 +611,12 @@
|
|
|
611
611
|
}
|
|
612
612
|
|
|
613
613
|
.site-logo-avatar {
|
|
614
|
-
|
|
615
|
-
|
|
614
|
+
box-sizing: border-box;
|
|
615
|
+
width: calc(var(--avatar-size) + 4px);
|
|
616
|
+
height: calc(var(--avatar-size) + 4px);
|
|
616
617
|
border-radius: var(--avatar-radius);
|
|
617
618
|
object-fit: cover;
|
|
618
|
-
|
|
619
|
-
color-mix(in srgb, var(--site-divider) 82%, transparent);
|
|
619
|
+
border: 1px solid color-mix(in srgb, var(--site-divider) 82%, transparent);
|
|
620
620
|
}
|
|
621
621
|
|
|
622
622
|
.jant-brand-mark {
|
|
@@ -1115,6 +1115,10 @@
|
|
|
1115
1115
|
flex: 1 1 18rem;
|
|
1116
1116
|
}
|
|
1117
1117
|
|
|
1118
|
+
.collections-page-heading .page-intro-title-row {
|
|
1119
|
+
align-items: center;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1118
1122
|
.collections-page-actions {
|
|
1119
1123
|
display: flex;
|
|
1120
1124
|
align-items: center;
|
|
@@ -1129,7 +1133,7 @@
|
|
|
1129
1133
|
}
|
|
1130
1134
|
|
|
1131
1135
|
.collections-page-action-group[data-collections-toolbar] {
|
|
1132
|
-
gap: 0.
|
|
1136
|
+
gap: 0.25rem;
|
|
1133
1137
|
}
|
|
1134
1138
|
|
|
1135
1139
|
.collections-page-toolbar-button {
|
|
@@ -1137,13 +1141,13 @@
|
|
|
1137
1141
|
display: inline-flex;
|
|
1138
1142
|
align-items: center;
|
|
1139
1143
|
justify-content: center;
|
|
1140
|
-
width:
|
|
1141
|
-
height:
|
|
1144
|
+
width: 2.25rem;
|
|
1145
|
+
height: 2.25rem;
|
|
1142
1146
|
padding: 0;
|
|
1143
1147
|
border: none;
|
|
1144
1148
|
border-radius: 999px;
|
|
1145
1149
|
background: transparent;
|
|
1146
|
-
color: var(--
|
|
1150
|
+
color: var(--site-text-secondary);
|
|
1147
1151
|
cursor: pointer;
|
|
1148
1152
|
transition:
|
|
1149
1153
|
background-color 0.16s ease,
|
|
@@ -1152,41 +1156,15 @@
|
|
|
1152
1156
|
border-color 0.16s ease;
|
|
1153
1157
|
}
|
|
1154
1158
|
|
|
1155
|
-
.collections-page-toolbar-button:hover
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
.collections-page-toolbar-button:focus-visible {
|
|
1160
|
-
outline: none;
|
|
1161
|
-
background: color-mix(in srgb, var(--accent) 46%, transparent);
|
|
1162
|
-
box-shadow: 0 0 0 3px
|
|
1163
|
-
color-mix(in srgb, var(--site-accent) 12%, transparent);
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
.collections-page-more-btn {
|
|
1167
|
-
min-width: 2.1rem;
|
|
1168
|
-
width: 2.1rem;
|
|
1169
|
-
height: 2.1rem;
|
|
1170
|
-
border: 1px solid color-mix(in srgb, var(--border) 76%, transparent);
|
|
1171
|
-
background: color-mix(
|
|
1172
|
-
in srgb,
|
|
1173
|
-
var(--background) 90%,
|
|
1174
|
-
var(--site-page-bg) 10%
|
|
1175
|
-
);
|
|
1176
|
-
color: var(--site-text-secondary);
|
|
1177
|
-
box-shadow: 0 12px 24px -24px rgba(15, 23, 42, 0.26);
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
.collections-page-more-btn:hover,
|
|
1181
|
-
.collections-page-more-btn[aria-expanded="true"] {
|
|
1182
|
-
border-color: color-mix(in srgb, var(--site-accent) 18%, var(--border));
|
|
1183
|
-
background: color-mix(in srgb, var(--accent) 34%, var(--background));
|
|
1159
|
+
.collections-page-toolbar-button:hover,
|
|
1160
|
+
.collections-page-toolbar-button[aria-expanded="true"] {
|
|
1161
|
+
background: var(--accent);
|
|
1184
1162
|
color: var(--foreground);
|
|
1185
1163
|
}
|
|
1186
1164
|
|
|
1187
|
-
.collections-page-
|
|
1165
|
+
.collections-page-toolbar-button:focus-visible {
|
|
1188
1166
|
outline: none;
|
|
1189
|
-
|
|
1167
|
+
background: var(--accent);
|
|
1190
1168
|
box-shadow: 0 0 0 3px
|
|
1191
1169
|
color-mix(in srgb, var(--site-accent) 12%, transparent);
|
|
1192
1170
|
}
|
|
@@ -1377,7 +1355,6 @@
|
|
|
1377
1355
|
line-height: var(--collection-directory-title-line-height);
|
|
1378
1356
|
letter-spacing: -0.02em;
|
|
1379
1357
|
text-wrap: pretty;
|
|
1380
|
-
user-select: all;
|
|
1381
1358
|
}
|
|
1382
1359
|
|
|
1383
1360
|
.collection-directory-title-marker {
|
|
@@ -2210,7 +2187,6 @@
|
|
|
2210
2187
|
|
|
2211
2188
|
.feed-link-title-link {
|
|
2212
2189
|
text-decoration: none;
|
|
2213
|
-
user-select: all;
|
|
2214
2190
|
}
|
|
2215
2191
|
|
|
2216
2192
|
.feed-link-title-link:hover {
|
|
@@ -2228,10 +2204,6 @@
|
|
|
2228
2204
|
text-wrap: pretty;
|
|
2229
2205
|
}
|
|
2230
2206
|
|
|
2231
|
-
.feed-note-title a {
|
|
2232
|
-
user-select: all;
|
|
2233
|
-
}
|
|
2234
|
-
|
|
2235
2207
|
.feed-continue-link {
|
|
2236
2208
|
display: inline-block;
|
|
2237
2209
|
margin-top: 0.5rem;
|
|
@@ -5267,11 +5239,16 @@
|
|
|
5267
5239
|
flex-shrink: 0;
|
|
5268
5240
|
gap: 4px;
|
|
5269
5241
|
align-items: center;
|
|
5270
|
-
|
|
5271
|
-
padding-left: 2px;
|
|
5242
|
+
padding: 4px 20px 6px;
|
|
5272
5243
|
animation: compose-fade-up 0.28s cubic-bezier(0.22, 1, 0.36, 1) both;
|
|
5273
5244
|
}
|
|
5274
5245
|
|
|
5246
|
+
@media (min-width: 700px) {
|
|
5247
|
+
.compose-star-rating {
|
|
5248
|
+
padding: 6px 24px 6px;
|
|
5249
|
+
}
|
|
5250
|
+
}
|
|
5251
|
+
|
|
5275
5252
|
.compose-star {
|
|
5276
5253
|
border: none;
|
|
5277
5254
|
background: transparent;
|
|
@@ -6997,10 +6974,15 @@
|
|
|
6997
6974
|
opacity: 0.82;
|
|
6998
6975
|
}
|
|
6999
6976
|
|
|
7000
|
-
/* Single image: constrain to container width
|
|
6977
|
+
/* Single image: constrain to container width. min-width keeps the preview
|
|
6978
|
+
wide enough for the remove button; a very long image overflows it and is
|
|
6979
|
+
cropped from the top (object-fit: cover) instead of shrinking to a sliver. */
|
|
7001
6980
|
.compose-attachment:only-child .compose-attachment-img {
|
|
7002
6981
|
max-width: 100%;
|
|
7003
6982
|
max-height: min(200px, 22dvh);
|
|
6983
|
+
min-width: 48px;
|
|
6984
|
+
object-fit: cover;
|
|
6985
|
+
object-position: center top;
|
|
7004
6986
|
}
|
|
7005
6987
|
|
|
7006
6988
|
.compose-attachment:only-child .compose-attachment-preview-fallback {
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "../../lib/asset-path.js";
|
|
20
20
|
import { getJantIconHref } from "../../lib/jant-branding.js";
|
|
21
21
|
import { getThemeBrowserColors, resolveBuiltinTheme } from "../../lib/theme.js";
|
|
22
|
-
import {
|
|
22
|
+
import { toAbsoluteAssetUrl, toPublicPath } from "../../lib/url.js";
|
|
23
23
|
import {
|
|
24
24
|
CLIENT_AUTH_JS_FILE,
|
|
25
25
|
CLIENT_CJK_CSS_FILE,
|
|
@@ -52,6 +52,26 @@ export interface BaseLayoutProps {
|
|
|
52
52
|
faviconUrl?: string;
|
|
53
53
|
faviconVersion?: string;
|
|
54
54
|
socialImageUrl?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Alt text describing an explicitly provided `socialImageUrl`. Ignored when
|
|
57
|
+
* the social image falls back to the site avatar or the Jant default.
|
|
58
|
+
*/
|
|
59
|
+
socialImageAlt?: string;
|
|
60
|
+
/** Pixel width of an explicitly provided `socialImageUrl`, when known. */
|
|
61
|
+
socialImageWidth?: number;
|
|
62
|
+
/** Pixel height of an explicitly provided `socialImageUrl`, when known. */
|
|
63
|
+
socialImageHeight?: number;
|
|
64
|
+
/**
|
|
65
|
+
* JSON-LD structured data object (or array of objects) rendered as a
|
|
66
|
+
* `<script type="application/ld+json">`. Skipped when the page is noindex.
|
|
67
|
+
*/
|
|
68
|
+
jsonLd?: unknown;
|
|
69
|
+
/** Open Graph object type. Defaults to "website". */
|
|
70
|
+
ogType?: "website" | "article";
|
|
71
|
+
/** ISO 8601 publish time, rendered as `article:published_time` for articles. */
|
|
72
|
+
articlePublishedTime?: string;
|
|
73
|
+
/** ISO 8601 modified time, rendered as `article:modified_time` for articles. */
|
|
74
|
+
articleModifiedTime?: string;
|
|
55
75
|
/**
|
|
56
76
|
* Absolute canonical URL for the current page. Rendered as
|
|
57
77
|
* `<link rel="canonical">` when set. Use on pages whose primary content is
|
|
@@ -75,6 +95,13 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
75
95
|
faviconUrl,
|
|
76
96
|
faviconVersion,
|
|
77
97
|
socialImageUrl,
|
|
98
|
+
socialImageAlt,
|
|
99
|
+
socialImageWidth,
|
|
100
|
+
socialImageHeight,
|
|
101
|
+
jsonLd,
|
|
102
|
+
ogType,
|
|
103
|
+
articlePublishedTime,
|
|
104
|
+
articleModifiedTime,
|
|
78
105
|
canonicalHref,
|
|
79
106
|
noindex,
|
|
80
107
|
isAuthenticated = false,
|
|
@@ -91,10 +118,13 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
91
118
|
|
|
92
119
|
// Read favicon/noindex from appConfig when not provided as prop
|
|
93
120
|
const appConfig = c ? c.get("appConfig") : undefined;
|
|
121
|
+
// Use `||` instead of `??` so empty strings (the unset state for
|
|
122
|
+
// `appConfig.siteAvatarUrl`) fall through to the Jant default; otherwise
|
|
123
|
+
// sites without a custom avatar render no og:image / twitter:image at all.
|
|
94
124
|
const resolvedSocialImagePath =
|
|
95
|
-
socialImageUrl
|
|
96
|
-
faviconUrl
|
|
97
|
-
appConfig?.siteAvatarUrl
|
|
125
|
+
socialImageUrl ||
|
|
126
|
+
faviconUrl ||
|
|
127
|
+
appConfig?.siteAvatarUrl ||
|
|
98
128
|
getJantIconHref("socialImage", appConfig?.sitePathPrefix || "");
|
|
99
129
|
const resolvedFaviconVersion =
|
|
100
130
|
faviconVersion ?? (appConfig?.faviconVersion || undefined);
|
|
@@ -179,16 +209,34 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
179
209
|
sitePathPrefix,
|
|
180
210
|
)
|
|
181
211
|
: toPublicPath("/apple-touch-icon.png", sitePathPrefix));
|
|
182
|
-
const socialImageHref =
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
212
|
+
const socialImageHref = resolvedSocialImagePath
|
|
213
|
+
? toAbsoluteAssetUrl(
|
|
214
|
+
resolvedSocialImagePath,
|
|
215
|
+
appConfig?.siteUrl || "",
|
|
216
|
+
sitePathPrefix,
|
|
217
|
+
)
|
|
218
|
+
: "";
|
|
219
|
+
// Dimensions / alt only describe an explicitly provided social image. The
|
|
220
|
+
// fallbacks (site avatar, Jant default) are square branding marks, so they
|
|
221
|
+
// keep the small `summary` card and a generic site-name alt.
|
|
222
|
+
const hasExplicitSocialImage = Boolean(socialImageUrl);
|
|
223
|
+
const socialImageAltText = hasExplicitSocialImage
|
|
224
|
+
? socialImageAlt
|
|
225
|
+
: siteName || undefined;
|
|
226
|
+
const socialImageWidthValue = hasExplicitSocialImage
|
|
227
|
+
? socialImageWidth
|
|
228
|
+
: undefined;
|
|
229
|
+
const socialImageHeightValue = hasExplicitSocialImage
|
|
230
|
+
? socialImageHeight
|
|
231
|
+
: undefined;
|
|
232
|
+
// `summary_large_image` only looks good for genuine landscape content; a
|
|
233
|
+
// portrait or square image gets center-cropped into a thin banner.
|
|
234
|
+
const useLargeTwitterCard =
|
|
235
|
+
hasExplicitSocialImage &&
|
|
236
|
+
socialImageWidthValue !== undefined &&
|
|
237
|
+
socialImageHeightValue !== undefined &&
|
|
238
|
+
socialImageWidthValue > socialImageHeightValue &&
|
|
239
|
+
socialImageWidthValue >= 300;
|
|
192
240
|
const mainFeedHref = appConfig ? toPublicPath("/feed", sitePathPrefix) : null;
|
|
193
241
|
const latestFeedHref = appConfig
|
|
194
242
|
? toPublicPath("/feed/latest", sitePathPrefix)
|
|
@@ -261,16 +309,46 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
261
309
|
<title>{title}</title>
|
|
262
310
|
{description && <meta name="description" content={description} />}
|
|
263
311
|
<meta property="og:title" content={title} />
|
|
264
|
-
<meta property="og:type" content="website" />
|
|
312
|
+
<meta property="og:type" content={ogType ?? "website"} />
|
|
313
|
+
{ogType === "article" && articlePublishedTime && (
|
|
314
|
+
<meta
|
|
315
|
+
property="article:published_time"
|
|
316
|
+
content={articlePublishedTime}
|
|
317
|
+
/>
|
|
318
|
+
)}
|
|
319
|
+
{ogType === "article" && articleModifiedTime && (
|
|
320
|
+
<meta
|
|
321
|
+
property="article:modified_time"
|
|
322
|
+
content={articleModifiedTime}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
265
325
|
{description && (
|
|
266
326
|
<meta property="og:description" content={description} />
|
|
267
327
|
)}
|
|
268
328
|
{socialImageHref && (
|
|
269
329
|
<meta property="og:image" content={socialImageHref} />
|
|
270
330
|
)}
|
|
331
|
+
{socialImageHref && socialImageWidthValue !== undefined && (
|
|
332
|
+
<meta
|
|
333
|
+
property="og:image:width"
|
|
334
|
+
content={String(socialImageWidthValue)}
|
|
335
|
+
/>
|
|
336
|
+
)}
|
|
337
|
+
{socialImageHref && socialImageHeightValue !== undefined && (
|
|
338
|
+
<meta
|
|
339
|
+
property="og:image:height"
|
|
340
|
+
content={String(socialImageHeightValue)}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
{socialImageHref && socialImageAltText && (
|
|
344
|
+
<meta property="og:image:alt" content={socialImageAltText} />
|
|
345
|
+
)}
|
|
271
346
|
{siteName && <meta property="og:site_name" content={siteName} />}
|
|
272
347
|
{currentUrl && <meta property="og:url" content={currentUrl} />}
|
|
273
|
-
<meta
|
|
348
|
+
<meta
|
|
349
|
+
name="twitter:card"
|
|
350
|
+
content={useLargeTwitterCard ? "summary_large_image" : "summary"}
|
|
351
|
+
/>
|
|
274
352
|
<meta name="twitter:title" content={title} />
|
|
275
353
|
{description && (
|
|
276
354
|
<meta name="twitter:description" content={description} />
|
|
@@ -278,9 +356,25 @@ export const BaseLayout: FC<PropsWithChildren<BaseLayoutProps>> = ({
|
|
|
278
356
|
{socialImageHref && (
|
|
279
357
|
<meta name="twitter:image" content={socialImageHref} />
|
|
280
358
|
)}
|
|
359
|
+
{socialImageHref && socialImageAltText && (
|
|
360
|
+
<meta name="twitter:image:alt" content={socialImageAltText} />
|
|
361
|
+
)}
|
|
281
362
|
{resolvedNoindex && (
|
|
282
363
|
<meta name="robots" content="noindex, nofollow" />
|
|
283
364
|
)}
|
|
365
|
+
{!resolvedNoindex && jsonLd != null && (
|
|
366
|
+
<script
|
|
367
|
+
type="application/ld+json"
|
|
368
|
+
// JSON.stringify output with `<` / `>` escaped to \u-sequences
|
|
369
|
+
// so a value containing `</script>` cannot break out of the tag.
|
|
370
|
+
// JSON parsers decode the escapes transparently.
|
|
371
|
+
dangerouslySetInnerHTML={{
|
|
372
|
+
__html: JSON.stringify(jsonLd)
|
|
373
|
+
.replace(/</g, "\\u003c")
|
|
374
|
+
.replace(/>/g, "\\u003e"),
|
|
375
|
+
}}
|
|
376
|
+
/>
|
|
377
|
+
)}
|
|
284
378
|
{canonicalHref && <link rel="canonical" href={canonicalHref} />}
|
|
285
379
|
<link rel="icon" href={resolvedFaviconHref} sizes="16x16 32x32" />
|
|
286
380
|
<link rel="apple-touch-icon" href={resolvedAppleTouchHref} />
|