@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.
Files changed (62) hide show
  1. package/dist/app-BX2XKxq0.js +6 -0
  2. package/dist/{app-DYQdDMs8.js → app-CyysIxj_.js} +464 -240
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/client-BMPMuwvV.css +2 -0
  5. package/dist/client/_assets/client-CTrEFM5W.js +275 -0
  6. package/dist/client/_assets/{client-auth-CSNcTJwP.js → client-auth-LBSZxqNC.js} +24 -24
  7. package/dist/{env-C7e2Nlnt.js → env-CoSe-1y4.js} +1 -1
  8. package/dist/{export-Bbn86HmS.js → export-CzuQyg5h.js} +32 -19
  9. package/dist/{github-api-Bh0PH3zr.js → github-api-UD4u_7fa.js} +1 -1
  10. package/dist/{github-app-D0GvNnqp.js → github-app-DeX6Td1O.js} +1 -1
  11. package/dist/{github-sync-dXsiZa_e.js → github-sync-CerNYCAn.js} +3 -3
  12. package/dist/{github-sync-CBQPRZ8H.js → github-sync-Dbrb1DS5.js} +7 -4
  13. package/dist/index.js +5 -5
  14. package/dist/node.js +6 -6
  15. package/dist/{url-umUptr5z.js → url-XF0GbKGO.js} +22 -1
  16. package/package.json +1 -1
  17. package/src/__tests__/export-service.test.ts +127 -0
  18. package/src/client/__tests__/image-processor.test.ts +64 -0
  19. package/src/client/components/__tests__/jant-collection-directory.test.ts +0 -42
  20. package/src/client/components/__tests__/jant-media-lightbox.test.ts +79 -8
  21. package/src/client/components/collection-manager-types.ts +0 -2
  22. package/src/client/components/jant-collection-directory.ts +0 -23
  23. package/src/client/components/jant-compose-editor.ts +2 -2
  24. package/src/client/components/jant-media-lightbox.ts +33 -5
  25. package/src/client/image-processor.ts +89 -30
  26. package/src/client/media-scroll-hint.ts +62 -9
  27. package/src/i18n/coverage.generated.ts +2 -2
  28. package/src/i18n/locales/public/en.po +0 -12
  29. package/src/i18n/locales/public/zh-Hans.po +0 -12
  30. package/src/i18n/locales/public/zh-Hant.po +0 -12
  31. package/src/i18n/locales/settings/zh-Hans.po +24 -24
  32. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  33. package/src/i18n/locales/settings/zh-Hant.po +24 -24
  34. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  35. package/src/lib/__tests__/structured-data.test.ts +87 -0
  36. package/src/lib/github-sync-site-config.ts +4 -2
  37. package/src/lib/post-display.ts +78 -1
  38. package/src/lib/render.tsx +28 -0
  39. package/src/lib/structured-data.ts +113 -0
  40. package/src/lib/url.ts +26 -0
  41. package/src/routes/api/internal/__tests__/sites.test.ts +65 -0
  42. package/src/routes/api/internal/sites.ts +19 -0
  43. package/src/routes/pages/home.tsx +21 -1
  44. package/src/routes/pages/page.tsx +53 -2
  45. package/src/services/export-theme/assets/client-site.css +1 -1
  46. package/src/services/export-theme/assets/client-site.js +30 -29
  47. package/src/services/export-theme/layouts/partials/media-gallery.html +16 -7
  48. package/src/services/export-theme/styles/main.css +4 -3
  49. package/src/services/export.ts +47 -17
  50. package/src/services/github-sync.ts +8 -2
  51. package/src/services/site-admin.ts +53 -1
  52. package/src/styles/site-media.css +70 -24
  53. package/src/styles/ui.css +30 -48
  54. package/src/ui/layouts/BaseLayout.tsx +110 -16
  55. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +146 -0
  56. package/src/ui/pages/CollectionsPage.tsx +0 -22
  57. package/src/ui/shared/CollectionsManager.tsx +6 -41
  58. package/src/ui/shared/MediaGallery.tsx +50 -7
  59. package/src/ui/shared/__tests__/media-gallery.test.ts +31 -0
  60. package/dist/app-CMSW_AYG.js +0 -6
  61. package/dist/client/_assets/client-BRTh1ii1.js +0 -274
  62. 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 horizontal scroll fade hints ------------------------------- */
805
+ /* --- Gallery scroll arrows --------------------------------------------- */
798
806
 
799
807
  .media-gallery-scroll-wrap {
800
- /* Default: no mask full visibility */
801
- --_fade: 1.5rem;
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-scroll-wrap > [data-post-media] {
807
- mask-image: linear-gradient(
808
- to right,
809
- var(--_mask-left) 0%,
810
- black var(--_fade),
811
- black calc(100% - var(--_fade)),
812
- var(--_mask-right) 100%
813
- );
814
- -webkit-mask-image: linear-gradient(
815
- to right,
816
- var(--_mask-left) 0%,
817
- black var(--_fade),
818
- black calc(100% - var(--_fade)),
819
- var(--_mask-right) 100%
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
- .media-gallery-scroll-wrap.can-scroll-start {
824
- --_mask-left: transparent;
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
- .media-gallery-scroll-wrap.can-scroll-end {
828
- --_mask-right: transparent;
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
- width: calc(var(--avatar-size) + 2px);
615
- height: calc(var(--avatar-size) + 2px);
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
- box-shadow: 0 0 0 1px
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.2rem;
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: 2rem;
1141
- height: 2rem;
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(--foreground);
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
- background: color-mix(in srgb, var(--accent) 42%, transparent);
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-more-btn:focus-visible {
1165
+ .collections-page-toolbar-button:focus-visible {
1188
1166
  outline: none;
1189
- border-color: color-mix(in srgb, var(--site-accent) 26%, var(--border));
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
- margin-top: 12px;
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 { isFullUrl, toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
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
- resolvedSocialImagePath &&
184
- (isFullUrl(resolvedSocialImagePath) ||
185
- resolvedSocialImagePath.startsWith("//")
186
- ? resolvedSocialImagePath
187
- : toAbsoluteSiteUrl(
188
- resolvedSocialImagePath,
189
- appConfig?.siteUrl || "",
190
- sitePathPrefix,
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 name="twitter:card" content="summary" />
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} />