@jant/core 0.6.0 → 0.6.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.
Files changed (52) hide show
  1. package/dist/app-CUZaVgsC.js +6 -0
  2. package/dist/{app-BIkkbVQk.js → app-Ct9c4zYF.js} +298 -59
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/client-Bp2IPjDe.js +275 -0
  5. package/dist/client/_assets/client-YVrRjAid.css +2 -0
  6. package/dist/client/_assets/{client-auth-D1jDQgbH.js → client-auth-C4hQWqH1.js} +4 -4
  7. package/dist/{env-C7e2Nlnt.js → env-CoSe-1y4.js} +1 -1
  8. package/dist/{export-Bbn86HmS.js → export-O2w3AsZX.js} +4 -4
  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-BUzIYouS.js} +3 -3
  12. package/dist/{github-sync-CBQPRZ8H.js → github-sync-D49RADci.js} +3 -3
  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/client/__tests__/image-processor.test.ts +64 -0
  18. package/src/client/components/__tests__/jant-media-lightbox.test.ts +79 -8
  19. package/src/client/components/jant-compose-editor.ts +2 -2
  20. package/src/client/components/jant-media-lightbox.ts +33 -5
  21. package/src/client/image-processor.ts +89 -30
  22. package/src/client/media-scroll-hint.ts +62 -9
  23. package/src/i18n/coverage.generated.ts +2 -2
  24. package/src/i18n/locales/settings/zh-Hans.po +24 -24
  25. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  26. package/src/i18n/locales/settings/zh-Hant.po +24 -24
  27. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  28. package/src/lib/__tests__/structured-data.test.ts +87 -0
  29. package/src/lib/post-display.ts +78 -1
  30. package/src/lib/render.tsx +28 -0
  31. package/src/lib/structured-data.ts +113 -0
  32. package/src/lib/url.ts +26 -0
  33. package/src/routes/api/internal/__tests__/sites.test.ts +65 -0
  34. package/src/routes/api/internal/sites.ts +19 -0
  35. package/src/routes/pages/home.tsx +21 -1
  36. package/src/routes/pages/page.tsx +53 -2
  37. package/src/services/export-theme/assets/client-site.css +1 -1
  38. package/src/services/export-theme/assets/client-site.js +30 -29
  39. package/src/services/export-theme/layouts/partials/media-gallery.html +16 -7
  40. package/src/services/site-admin.ts +53 -1
  41. package/src/styles/site-media.css +70 -24
  42. package/src/styles/ui.css +24 -18
  43. package/src/ui/feed/ThreadPreview.tsx +0 -1
  44. package/src/ui/feed/__tests__/thread-preview.test.ts +0 -1
  45. package/src/ui/layouts/BaseLayout.tsx +110 -16
  46. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +146 -0
  47. package/src/ui/pages/PostPage.tsx +0 -1
  48. package/src/ui/shared/MediaGallery.tsx +50 -7
  49. package/src/ui/shared/__tests__/media-gallery.test.ts +31 -0
  50. package/dist/app-Bcr5_wZI.js +0 -6
  51. package/dist/client/_assets/client-Bo7sKkAQ.js +0 -274
  52. package/dist/client/_assets/client-QHRvzZwk.css +0 -2
@@ -0,0 +1,87 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildArticleJsonLd, buildWebSiteJsonLd } from "../structured-data.js";
3
+
4
+ describe("buildArticleJsonLd", () => {
5
+ it("builds a BlogPosting with required fields", () => {
6
+ const data = buildArticleJsonLd({
7
+ headline: "Hello world",
8
+ url: "https://site.com/hello",
9
+ datePublished: "2026-01-01T00:00:00.000Z",
10
+ dateModified: "2026-01-02T00:00:00.000Z",
11
+ authorName: "Jant",
12
+ });
13
+
14
+ expect(data).toMatchObject({
15
+ "@context": "https://schema.org",
16
+ "@type": "BlogPosting",
17
+ headline: "Hello world",
18
+ datePublished: "2026-01-01T00:00:00.000Z",
19
+ dateModified: "2026-01-02T00:00:00.000Z",
20
+ url: "https://site.com/hello",
21
+ mainEntityOfPage: { "@type": "WebPage", "@id": "https://site.com/hello" },
22
+ author: { "@type": "Person", name: "Jant" },
23
+ });
24
+ });
25
+
26
+ it("omits description and image when not provided", () => {
27
+ const data = buildArticleJsonLd({
28
+ headline: "Hello",
29
+ url: "https://site.com/hello",
30
+ datePublished: "2026-01-01T00:00:00.000Z",
31
+ dateModified: "2026-01-01T00:00:00.000Z",
32
+ authorName: "Jant",
33
+ });
34
+
35
+ expect(data).not.toHaveProperty("description");
36
+ expect(data).not.toHaveProperty("image");
37
+ });
38
+
39
+ it("includes description and image when provided", () => {
40
+ const data = buildArticleJsonLd({
41
+ headline: "Hello",
42
+ description: "A short note.",
43
+ url: "https://site.com/hello",
44
+ datePublished: "2026-01-01T00:00:00.000Z",
45
+ dateModified: "2026-01-01T00:00:00.000Z",
46
+ imageUrl: "https://site.com/m/cover.png",
47
+ authorName: "Jant",
48
+ });
49
+
50
+ expect(data.description).toBe("A short note.");
51
+ expect(data.image).toBe("https://site.com/m/cover.png");
52
+ });
53
+ });
54
+
55
+ describe("buildWebSiteJsonLd", () => {
56
+ it("builds a WebSite with a SearchAction when a search template is given", () => {
57
+ const data = buildWebSiteJsonLd({
58
+ name: "Jant",
59
+ url: "https://site.com/",
60
+ searchUrlTemplate: "https://site.com/search?q={search_term_string}",
61
+ });
62
+
63
+ expect(data).toMatchObject({
64
+ "@context": "https://schema.org",
65
+ "@type": "WebSite",
66
+ name: "Jant",
67
+ url: "https://site.com/",
68
+ potentialAction: {
69
+ "@type": "SearchAction",
70
+ target: {
71
+ "@type": "EntryPoint",
72
+ urlTemplate: "https://site.com/search?q={search_term_string}",
73
+ },
74
+ "query-input": "required name=search_term_string",
75
+ },
76
+ });
77
+ });
78
+
79
+ it("omits the SearchAction when no search template is given", () => {
80
+ const data = buildWebSiteJsonLd({
81
+ name: "Jant",
82
+ url: "https://site.com/",
83
+ });
84
+
85
+ expect(data).not.toHaveProperty("potentialAction");
86
+ });
87
+ });
@@ -13,9 +13,70 @@ import { createMediaContext, toPostView } from "./view.js";
13
13
 
14
14
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
15
 
16
+ /** Social/preview image picked for a post page, with metadata when known. */
17
+ export interface PostSocialImage {
18
+ /** Image URL — may be app-local or an already-absolute CDN URL. */
19
+ url: string;
20
+ /** Pixel width, when known (image attachments only, not link previews). */
21
+ width?: number;
22
+ /** Pixel height, when known. */
23
+ height?: number;
24
+ /** Alt text, when the source image attachment has one. */
25
+ alt?: string;
26
+ }
27
+
16
28
  export interface PostPageDisplayData {
17
29
  postView: PostView;
18
30
  threadPostViews?: PostView[];
31
+ /**
32
+ * Image to use as og:image / twitter:image for this post page. Prefers an
33
+ * image attached to the current post, then its link-preview thumbnail, then
34
+ * any image found elsewhere in the thread. Undefined when the thread has no
35
+ * images — BaseLayout then falls back to the site avatar or the default
36
+ * Jant social image.
37
+ */
38
+ socialImage?: PostSocialImage;
39
+ /**
40
+ * ISO 8601 publish time for `article:published_time`. A post page renders
41
+ * the whole thread as one "article", so this is the thread root's publish
42
+ * time (the post itself when it is not part of a thread).
43
+ */
44
+ articlePublishedTime: string;
45
+ /**
46
+ * ISO 8601 last-modified time for `article:modified_time`: the most recent
47
+ * update across every post in the thread.
48
+ */
49
+ articleModifiedTime: string;
50
+ }
51
+
52
+ function findFirstImage(post: PostView): PostSocialImage | undefined {
53
+ const image = post.media.find((m) => m.mimeType.startsWith("image/"));
54
+ if (image) {
55
+ return {
56
+ url: image.url,
57
+ width: image.width,
58
+ height: image.height,
59
+ alt: image.altText,
60
+ };
61
+ }
62
+ // Link-preview thumbnails are transformed images with no known dimensions.
63
+ return post.previewImageUrl ? { url: post.previewImageUrl } : undefined;
64
+ }
65
+
66
+ function resolvePostSocialImage(
67
+ postView: PostView,
68
+ threadPostViews: PostView[] | undefined,
69
+ ): PostSocialImage | undefined {
70
+ const direct = findFirstImage(postView);
71
+ if (direct) return direct;
72
+
73
+ if (!threadPostViews) return undefined;
74
+ for (const p of threadPostViews) {
75
+ if (p.id === postView.id) continue;
76
+ const found = findFirstImage(p);
77
+ if (found) return found;
78
+ }
79
+ return undefined;
19
80
  }
20
81
 
21
82
  function canViewPost(post: Post, isAuthenticated: boolean): boolean {
@@ -146,5 +207,21 @@ export async function assemblePostPageDisplay(
146
207
  )
147
208
  : undefined;
148
209
 
149
- return { postView, threadPostViews };
210
+ const socialImage = resolvePostSocialImage(postView, threadPostViews);
211
+
212
+ const rootView = threadPostViews?.[0] ?? postView;
213
+ const allViews = threadPostViews ?? [postView];
214
+ // ISO 8601 strings from toISOString() are all UTC and zero-padded, so
215
+ // lexical comparison matches chronological order.
216
+ const articleModifiedTime = allViews
217
+ .map((p) => p.updatedAt)
218
+ .reduce((latest, t) => (t > latest ? t : latest));
219
+
220
+ return {
221
+ postView,
222
+ threadPostViews,
223
+ socialImage,
224
+ articlePublishedTime: rootView.publishedAt,
225
+ articleModifiedTime,
226
+ };
150
227
  }
@@ -24,6 +24,20 @@ export interface RenderPublicPageOptions {
24
24
  appleTouchHref?: string;
25
25
  /** Optional explicit social image href */
26
26
  socialImageUrl?: string;
27
+ /** Alt text describing an explicit `socialImageUrl`. */
28
+ socialImageAlt?: string;
29
+ /** Pixel width of an explicit `socialImageUrl`, when known. */
30
+ socialImageWidth?: number;
31
+ /** Pixel height of an explicit `socialImageUrl`, when known. */
32
+ socialImageHeight?: number;
33
+ /** Open Graph object type. Defaults to "website" in BaseLayout. */
34
+ ogType?: "website" | "article";
35
+ /** ISO 8601 publish time, rendered as `article:published_time` for articles. */
36
+ articlePublishedTime?: string;
37
+ /** ISO 8601 modified time, rendered as `article:modified_time` for articles. */
38
+ articleModifiedTime?: string;
39
+ /** JSON-LD structured data object (or array) for this page. */
40
+ jsonLd?: unknown;
27
41
  /**
28
42
  * Absolute canonical URL for this page. Forwarded to `BaseLayout` and
29
43
  * rendered as `<link rel="canonical">`. Only set when the page has a
@@ -73,6 +87,13 @@ export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
73
87
  faviconHref,
74
88
  appleTouchHref,
75
89
  socialImageUrl,
90
+ socialImageAlt,
91
+ socialImageWidth,
92
+ socialImageHeight,
93
+ ogType,
94
+ articlePublishedTime,
95
+ articleModifiedTime,
96
+ jsonLd,
76
97
  canonicalHref,
77
98
  navData,
78
99
  content,
@@ -127,6 +148,13 @@ export function renderPublicPage(c: Context, options: RenderPublicPageOptions) {
127
148
  faviconHref={faviconHref}
128
149
  appleTouchHref={appleTouchHref}
129
150
  socialImageUrl={socialImageUrl}
151
+ socialImageAlt={socialImageAlt}
152
+ socialImageWidth={socialImageWidth}
153
+ socialImageHeight={socialImageHeight}
154
+ ogType={ogType}
155
+ articlePublishedTime={articlePublishedTime}
156
+ articleModifiedTime={articleModifiedTime}
157
+ jsonLd={jsonLd}
130
158
  canonicalHref={canonicalHref}
131
159
  faviconUrl={faviconUrl}
132
160
  faviconVersion={faviconVersion}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * JSON-LD structured data builders.
3
+ *
4
+ * Produces schema.org objects rendered as `<script type="application/ld+json">`
5
+ * in BaseLayout. Builders return plain objects; serialization and script-safe
6
+ * escaping happen at render time.
7
+ */
8
+
9
+ export interface ArticleJsonLdInput {
10
+ /** Bare post title (without the site-name suffix). */
11
+ headline: string;
12
+ /** Meta description, when available. */
13
+ description?: string;
14
+ /** Canonical absolute URL of the post page. */
15
+ url: string;
16
+ /** ISO 8601 publish time. */
17
+ datePublished: string;
18
+ /** ISO 8601 last-modified time. */
19
+ dateModified: string;
20
+ /** Absolute URL of the post's social/preview image, when available. */
21
+ imageUrl?: string;
22
+ /** Display name of the site author. */
23
+ authorName: string;
24
+ }
25
+
26
+ /**
27
+ * Build a schema.org `BlogPosting` object for a single post page.
28
+ *
29
+ * `BlogPosting` (a subtype of `Article`) fits a personal microblog better than
30
+ * the generic `Article` type.
31
+ *
32
+ * @param input - Post metadata, with absolute URLs already resolved
33
+ * @returns A JSON-LD-ready `BlogPosting` object
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * buildArticleJsonLd({
38
+ * headline: "Hello",
39
+ * url: "https://site.com/hello",
40
+ * datePublished: "2026-01-01T00:00:00.000Z",
41
+ * dateModified: "2026-01-02T00:00:00.000Z",
42
+ * authorName: "Jant",
43
+ * });
44
+ * ```
45
+ */
46
+ export function buildArticleJsonLd(
47
+ input: ArticleJsonLdInput,
48
+ ): Record<string, unknown> {
49
+ const data: Record<string, unknown> = {
50
+ "@context": "https://schema.org",
51
+ "@type": "BlogPosting",
52
+ headline: input.headline,
53
+ datePublished: input.datePublished,
54
+ dateModified: input.dateModified,
55
+ url: input.url,
56
+ mainEntityOfPage: { "@type": "WebPage", "@id": input.url },
57
+ author: { "@type": "Person", name: input.authorName },
58
+ };
59
+ if (input.description) data.description = input.description;
60
+ if (input.imageUrl) data.image = input.imageUrl;
61
+ return data;
62
+ }
63
+
64
+ export interface WebSiteJsonLdInput {
65
+ /** Site display name. */
66
+ name: string;
67
+ /** Absolute URL of the site root. */
68
+ url: string;
69
+ /**
70
+ * Absolute search URL template containing the literal placeholder
71
+ * `{search_term_string}`, e.g. `https://site.com/search?q={search_term_string}`.
72
+ * Omit to skip the sitelinks search box action.
73
+ */
74
+ searchUrlTemplate?: string;
75
+ }
76
+
77
+ /**
78
+ * Build a schema.org `WebSite` object, optionally with a `SearchAction` that
79
+ * enables Google's sitelinks search box.
80
+ *
81
+ * @param input - Site metadata, with absolute URLs already resolved
82
+ * @returns A JSON-LD-ready `WebSite` object
83
+ *
84
+ * @example
85
+ * ```ts
86
+ * buildWebSiteJsonLd({
87
+ * name: "Jant",
88
+ * url: "https://site.com/",
89
+ * searchUrlTemplate: "https://site.com/search?q={search_term_string}",
90
+ * });
91
+ * ```
92
+ */
93
+ export function buildWebSiteJsonLd(
94
+ input: WebSiteJsonLdInput,
95
+ ): Record<string, unknown> {
96
+ const data: Record<string, unknown> = {
97
+ "@context": "https://schema.org",
98
+ "@type": "WebSite",
99
+ name: input.name,
100
+ url: input.url,
101
+ };
102
+ if (input.searchUrlTemplate) {
103
+ data.potentialAction = {
104
+ "@type": "SearchAction",
105
+ target: {
106
+ "@type": "EntryPoint",
107
+ urlTemplate: input.searchUrlTemplate,
108
+ },
109
+ "query-input": "required name=search_term_string",
110
+ };
111
+ }
112
+ return data;
113
+ }
package/src/lib/url.ts CHANGED
@@ -372,3 +372,29 @@ export function toAbsoluteSiteUrl(
372
372
  if (!siteUrl) return toPublicPath(path, sitePathPrefix);
373
373
  return new URL(toPublicPath(path, sitePathPrefix), siteUrl).toString();
374
374
  }
375
+
376
+ /**
377
+ * Resolve a possibly-relative asset URL to an absolute URL, leaving
378
+ * already-absolute (`http(s):`) and protocol-relative (`//host`) URLs
379
+ * untouched. Use for assets — like media — whose stored URL may be either an
380
+ * app-local path or a full CDN URL.
381
+ *
382
+ * @param url - Asset URL: an internal path or an already-absolute URL
383
+ * @param siteUrl - Normalized site URL
384
+ * @param sitePathPrefix - Public site path prefix
385
+ * @returns Absolute URL, or the original value when it is already absolute
386
+ *
387
+ * @example
388
+ * ```ts
389
+ * toAbsoluteAssetUrl("/m/a.png", "https://site.com"); // "https://site.com/m/a.png"
390
+ * toAbsoluteAssetUrl("https://cdn.example/a.png", "x"); // "https://cdn.example/a.png"
391
+ * ```
392
+ */
393
+ export function toAbsoluteAssetUrl(
394
+ url: string,
395
+ siteUrl: string,
396
+ sitePathPrefix = "",
397
+ ): string {
398
+ if (isFullUrl(url) || url.startsWith("//")) return url;
399
+ return toAbsoluteSiteUrl(url, siteUrl, sitePathPrefix);
400
+ }
@@ -264,6 +264,71 @@ describe("Internal site admin routes", () => {
264
264
  });
265
265
  });
266
266
 
267
+ it("returns published post counts for hosted sites", async () => {
268
+ const { app, sqlite } = createTestApp({
269
+ authenticated: false,
270
+ internalAdminToken: "internal-secret",
271
+ siteResolutionMode: "host-based",
272
+ });
273
+ app.route("/api/internal/sites", internalSitesRoutes);
274
+
275
+ const insertPost = sqlite.prepare(
276
+ `INSERT INTO "post" ("id", "site_id", "format", "status", "thread_id", "created_at", "updated_at")
277
+ VALUES (?, ?, 'note', ?, ?, 1774200002, 1774200002)`,
278
+ );
279
+ insertPost.run(
280
+ "pst_count_1",
281
+ DEFAULT_TEST_SITE_ID,
282
+ "published",
283
+ "pst_count_1",
284
+ );
285
+ insertPost.run(
286
+ "pst_count_2",
287
+ DEFAULT_TEST_SITE_ID,
288
+ "published",
289
+ "pst_count_2",
290
+ );
291
+ insertPost.run("pst_count_3", DEFAULT_TEST_SITE_ID, "draft", "pst_count_3");
292
+
293
+ const res = await app.request("/api/internal/sites/post-counts", {
294
+ method: "POST",
295
+ headers: {
296
+ Authorization: "Bearer internal-secret",
297
+ "Content-Type": "application/json",
298
+ },
299
+ body: JSON.stringify({
300
+ siteIds: [DEFAULT_TEST_SITE_ID, "sit_does_not_exist"],
301
+ }),
302
+ });
303
+
304
+ expect(res.status).toBe(200);
305
+ // Drafts are excluded, and an unknown site id resolves to 0 rather than
306
+ // failing the whole batch.
307
+ await expect(res.json()).resolves.toEqual({
308
+ counts: [
309
+ { publishedPostCount: 2, siteId: DEFAULT_TEST_SITE_ID },
310
+ { publishedPostCount: 0, siteId: "sit_does_not_exist" },
311
+ ],
312
+ });
313
+ });
314
+
315
+ it("rejects post-count lookups without an admin token", async () => {
316
+ const { app } = createTestApp({
317
+ authenticated: false,
318
+ internalAdminToken: "internal-secret",
319
+ siteResolutionMode: "host-based",
320
+ });
321
+ app.route("/api/internal/sites", internalSitesRoutes);
322
+
323
+ const res = await app.request("/api/internal/sites/post-counts", {
324
+ method: "POST",
325
+ headers: { "Content-Type": "application/json" },
326
+ body: JSON.stringify({ siteIds: [DEFAULT_TEST_SITE_ID] }),
327
+ });
328
+
329
+ expect(res.status).toBe(401);
330
+ });
331
+
267
332
  it("deletes a managed site without clearing other sites", async () => {
268
333
  const { app, sqlite } = createTestApp({
269
334
  authenticated: false,
@@ -42,6 +42,10 @@ const CreateManagedSiteSchema = z.object({
42
42
  idempotencyKey: z.string().trim().min(1).max(128).optional(),
43
43
  });
44
44
 
45
+ const SitePostCountsSchema = z.object({
46
+ siteIds: z.array(z.string().trim().min(1)).max(200),
47
+ });
48
+
45
49
  const ManagedSiteDomainSchema = z.object({
46
50
  host: z
47
51
  .string()
@@ -99,6 +103,21 @@ internalSitesRoutes.get(
99
103
  },
100
104
  );
101
105
 
106
+ internalSitesRoutes.post(
107
+ "/post-counts",
108
+ requireInternalAdminApi(),
109
+ async (c) => {
110
+ assertHostBasedMode(c.env);
111
+
112
+ const body = parseValidated(SitePostCountsSchema, await c.req.json());
113
+ const counts = await c.var.services.siteAdmin.getManagedSitePostCounts(
114
+ body.siteIds,
115
+ );
116
+
117
+ return c.json({ counts });
118
+ },
119
+ );
120
+
102
121
  internalSitesRoutes.delete("/:siteId", requireInternalAdminApi(), async (c) => {
103
122
  assertHostBasedMode(c.env);
104
123
 
@@ -24,7 +24,8 @@ import {
24
24
  assembleFeaturedTimeline,
25
25
  assembleTimeline,
26
26
  } from "../../lib/timeline.js";
27
- import { toPublicPath } from "../../lib/url.js";
27
+ import { toAbsoluteSiteUrl, toPublicPath } from "../../lib/url.js";
28
+ import { buildWebSiteJsonLd } from "../../lib/structured-data.js";
28
29
  import { HomePage } from "../../ui/pages/HomePage.js";
29
30
  import { FeaturedPage } from "../../ui/pages/FeaturedPage.js";
30
31
 
@@ -56,6 +57,23 @@ homeRoutes.get("/", async (c) => {
56
57
 
57
58
  const { items, currentPage, totalPages } = timeline;
58
59
 
60
+ // WebSite + SearchAction structured data, emitted only on the first page
61
+ // (the canonical site entry point) and only when a site URL is configured
62
+ // so the search-box action resolves to an absolute URL.
63
+ const { siteUrl } = c.var.appConfig;
64
+ const websiteJsonLd =
65
+ page === 1 && siteUrl
66
+ ? buildWebSiteJsonLd({
67
+ name: navData.siteName,
68
+ url: toAbsoluteSiteUrl("/", siteUrl, navData.sitePathPrefix),
69
+ searchUrlTemplate: `${toAbsoluteSiteUrl(
70
+ "/search",
71
+ siteUrl,
72
+ navData.sitePathPrefix,
73
+ )}?q={search_term_string}`,
74
+ })
75
+ : undefined;
76
+
59
77
  if (homeDefaultView === "featured") {
60
78
  const featuredTitle = i18n._(
61
79
  msg({
@@ -69,6 +87,7 @@ homeRoutes.get("/", async (c) => {
69
87
  page > 1
70
88
  ? buildPageTitle(featuredTitle, paginatedPageTitle, navData.siteName)
71
89
  : navData.siteName,
90
+ jsonLd: websiteJsonLd,
72
91
  navData,
73
92
  showHomeBranding:
74
93
  c.var.appConfig.showJantBrandingOnHome && currentPage === 1,
@@ -95,6 +114,7 @@ homeRoutes.get("/", async (c) => {
95
114
  page > 1
96
115
  ? buildPageTitle(latestTitle, paginatedPageTitle, navData.siteName)
97
116
  : navData.siteName,
117
+ jsonLd: websiteJsonLd,
98
118
  navData,
99
119
  showHomeBranding:
100
120
  c.var.appConfig.showJantBrandingOnHome && currentPage === 1,
@@ -13,8 +13,16 @@ import { getNavigationData } from "../../lib/navigation.js";
13
13
  import { renderPublicPage } from "../../lib/render.js";
14
14
  import { buildPageTitle } from "../../lib/page-title.js";
15
15
  import { buildPostMeta } from "../../lib/post-meta.js";
16
- import { assemblePostPageDisplay } from "../../lib/post-display.js";
17
- import { toPublicHref, toPublicPath } from "../../lib/url.js";
16
+ import {
17
+ assemblePostPageDisplay,
18
+ type PostPageDisplayData,
19
+ } from "../../lib/post-display.js";
20
+ import { buildArticleJsonLd } from "../../lib/structured-data.js";
21
+ import {
22
+ toAbsoluteAssetUrl,
23
+ toPublicHref,
24
+ toPublicPath,
25
+ } from "../../lib/url.js";
18
26
  import { isTextAttachment } from "../../services/media.js";
19
27
  import type { Post } from "../../types.js";
20
28
  import { renderArchivePage } from "./archive.js";
@@ -56,6 +64,33 @@ function buildPostCanonicalHref(
56
64
  return new URL(rootPermalink, siteUrl).toString();
57
65
  }
58
66
 
67
+ /**
68
+ * Build the `BlogPosting` JSON-LD for a post page. The post page renders the
69
+ * whole thread, so the structured data describes the thread as one article:
70
+ * canonical URL, root publish time, latest thread modification time.
71
+ */
72
+ function buildPostJsonLd(
73
+ c: Context<Env>,
74
+ display: PostPageDisplayData,
75
+ meta: { title: string; description?: string },
76
+ canonicalHref: string,
77
+ siteName: string,
78
+ ): Record<string, unknown> {
79
+ const { siteUrl, sitePathPrefix } = c.var.appConfig;
80
+ const imageUrl = display.socialImage
81
+ ? toAbsoluteAssetUrl(display.socialImage.url, siteUrl, sitePathPrefix)
82
+ : undefined;
83
+ return buildArticleJsonLd({
84
+ headline: meta.title,
85
+ description: meta.description,
86
+ url: canonicalHref,
87
+ datePublished: display.articlePublishedTime,
88
+ dateModified: display.articleModifiedTime,
89
+ imageUrl,
90
+ authorName: siteName,
91
+ });
92
+ }
93
+
59
94
  async function renderPostWithTextPreview(
60
95
  c: Context<Env>,
61
96
  post: Post,
@@ -97,6 +132,14 @@ async function renderPostWithTextPreview(
97
132
  title: buildPageTitle(pageTitle, navData.siteName),
98
133
  description: meta.description,
99
134
  canonicalHref,
135
+ socialImageUrl: display.socialImage?.url,
136
+ socialImageAlt: display.socialImage?.alt,
137
+ socialImageWidth: display.socialImage?.width,
138
+ socialImageHeight: display.socialImage?.height,
139
+ ogType: "article",
140
+ articlePublishedTime: display.articlePublishedTime,
141
+ articleModifiedTime: display.articleModifiedTime,
142
+ jsonLd: buildPostJsonLd(c, display, meta, canonicalHref, navData.siteName),
100
143
  navData,
101
144
  content: (
102
145
  <>
@@ -166,6 +209,14 @@ async function renderPost(c: Context<Env>, post: Post) {
166
209
  title: buildPageTitle(meta.title, navData.siteName),
167
210
  description: meta.description,
168
211
  canonicalHref,
212
+ socialImageUrl: display.socialImage?.url,
213
+ socialImageAlt: display.socialImage?.alt,
214
+ socialImageWidth: display.socialImage?.width,
215
+ socialImageHeight: display.socialImage?.height,
216
+ ogType: "article",
217
+ articlePublishedTime: display.articlePublishedTime,
218
+ articleModifiedTime: display.articleModifiedTime,
219
+ jsonLd: buildPostJsonLd(c, display, meta, canonicalHref, navData.siteName),
169
220
  navData,
170
221
  content: (
171
222
  <PostPage post={display.postView} threadPosts={display.threadPostViews} />
@@ -1,2 +1,2 @@
1
- @keyframes lightbox-fade-in{0%{opacity:0}to{opacity:1}}@keyframes lightbox-scale-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.media-lightbox{background:0 0;border:none;outline:none;width:100%;max-width:100%;height:100%;max-height:100%;padding:0}.media-lightbox[open]{animation:.2s both lightbox-fade-in}.media-lightbox::backdrop{background-color:#000}.media-lightbox-content{outline:none;width:100%;height:100dvh;position:relative}.media-lightbox-stage{box-sizing:border-box;justify-content:center;align-items:center;width:100%;height:100%;padding:64px 96px;display:flex;overflow:hidden}.media-lightbox-stage-scroll{overscroll-behavior:contain;scrollbar-gutter:stable both-edges;-webkit-overflow-scrolling:touch;align-items:flex-start;overflow:hidden auto}.media-lightbox-img{object-fit:contain;border-radius:4px;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in;display:block}.media-lightbox-img-scroll{width:min(100%,44rem);max-width:none;height:auto;max-height:none;margin:0 auto}.media-lightbox-close{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:40px;height:40px;transition:background-color .15s;display:flex;position:fixed;top:16px;left:16px}.media-lightbox-close:hover{background-color:#000000b3}.media-lightbox-nav{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:44px;height:44px;transition:background-color .15s;display:flex;position:fixed;top:50%;transform:translateY(-50%)}.media-lightbox-nav:hover{background-color:#000000b3}.media-lightbox-nav-prev{left:16px}.media-lightbox-nav-next{right:16px}.media-lightbox-counter{z-index:10;font-size:var(--type-xs);color:#ffffffb3;font-variant-numeric:tabular-nums;-webkit-user-select:none;user-select:none;position:fixed;top:20px;left:50%;transform:translate(-50%)}.media-lightbox-short-frame{max-width:100%;max-height:100%;display:block;position:relative}.media-lightbox-short-controls{z-index:2;pointer-events:none;height:86px;position:absolute;bottom:0;left:0;right:0}.media-lightbox-short-progress{--media-progress:0%;z-index:1;appearance:none;pointer-events:auto;cursor:pointer;background:0 0;width:auto;height:34px;margin:0;padding:0;display:block;position:absolute;bottom:0;left:16px;right:16px}.media-lightbox-short-progress::-webkit-slider-runnable-track{background:linear-gradient(to right, #ffffffeb 0, #ffffffeb var(--media-progress), #ffffff2e var(--media-progress), #ffffff2e 100%);border-radius:999px;height:2px;transition:height .15s,background .15s}.media-lightbox-short-progress::-webkit-slider-thumb{appearance:none;opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;margin-top:-4px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-progress::-moz-range-track{background:#ffffff2e;border:none;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-progress{background:#ffffffeb;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-thumb{opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-controls-portrait .media-lightbox-short-progress{width:calc(100% - 48px);right:auto}.media-lightbox-short-progress:hover::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:focus-visible::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-track{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-progress{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-progress{height:4px}.media-lightbox-short-progress:hover::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:hover::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-mute{z-index:2;color:#fff;pointer-events:auto;cursor:pointer;background-color:#777;border:none;border-radius:999px;justify-content:center;align-items:center;width:44px;height:44px;transition:background-color .15s,transform .15s;display:inline-flex;position:absolute;bottom:30px;right:24px}.media-lightbox-short-mute svg{flex-shrink:0;width:16px;height:16px;display:block}.media-lightbox-short-mute:hover{background-color:#686868;transform:scale(1.03)}.media-lightbox-short-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}@media (max-width:640px){.media-lightbox-stage{padding:48px 16px}.media-lightbox-img{border-radius:0}.media-lightbox-img-scroll{width:100%}.media-lightbox-close{width:36px;height:36px;top:12px;left:12px}.media-lightbox-nav{width:36px;height:36px}.media-lightbox-nav-prev{left:8px}.media-lightbox-nav-next{right:8px}.media-lightbox-short-mute{bottom:26px;right:24px}}.media-visual-frame{border-radius:var(--media-radius,.5rem);background-color:var(--color-muted);display:block;overflow:hidden}.media-visual{background-position:50%;background-repeat:no-repeat;display:block}.media-video-wrap{position:relative}.media-video-link{cursor:pointer;display:block}.media-video-wrap video{object-fit:contain;background-color:var(--color-muted);width:100%;max-height:24rem}.media-video-wrap-short video{background-color:#000}.media-feed-video-mute{z-index:1;color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:999px;justify-content:center;align-items:center;width:28px;height:28px;transition:background-color .15s,transform .15s;display:inline-flex;position:absolute;bottom:16px;right:16px}.media-feed-video-mute svg{width:12px;height:12px}.media-feed-video-mute:hover{background-color:#0000009e;transform:scale(1.03)}.media-feed-video-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}.media-feed-video-icon{transition:opacity .15s,transform .15s;position:absolute}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-muted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-unmuted{opacity:1;transform:scale(1)}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-unmuted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-muted{opacity:0;transform:scale(.92)}.media-video-play-overlay{pointer-events:none;justify-content:center;align-items:center;transition:opacity .15s;display:flex;position:absolute;inset:0}.media-video-play-overlay svg{filter:drop-shadow(0 2px 6px #0006);opacity:.85;width:48px;height:48px}.media-gallery-card.media-audio-card{flex-direction:column;display:flex}.media-audio-card .media-audio-el{opacity:0;pointer-events:none;width:0;height:0;position:absolute}.media-audio-card .media-audio-artwork{background:linear-gradient(160deg,#8080801f 0%,#80808008 100%);flex:1;justify-content:center;align-items:center;width:100%;min-height:0;display:flex}.media-audio-card .media-audio-artwork svg{width:32px;height:32px;color:var(--site-text-secondary);opacity:.3}.media-audio-card .media-audio-waveform{cursor:pointer;touch-action:none;width:100%;height:24px;display:none}.media-audio-card.has-waveform .media-audio-waveform{display:block}.media-audio-card.has-waveform .media-audio-range{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.media-audio-card .media-audio-controls{flex-direction:column;flex-shrink:0;padding:0 0 6px;display:flex}.media-audio-card .media-audio-range{appearance:none;cursor:pointer;touch-action:none;background:0 0;width:100%;height:20px;margin:0;padding:0}.media-audio-card .media-audio-range::-webkit-slider-runnable-track{background:#80808026;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-track{background:#80808026;border:none;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-progress{background:var(--site-text-primary);border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-webkit-slider-thumb{-webkit-appearance:none;background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;margin-top:-3.5px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-webkit-slider-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-webkit-slider-thumb{opacity:1}.media-audio-card .media-audio-range::-moz-range-thumb{background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-moz-range-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-moz-range-thumb{opacity:1}.media-audio-card .media-audio-range:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:2px}.media-audio-card .media-audio-row{align-items:center;gap:6px;min-width:0;padding:6px 8px 0;display:flex}.media-audio-card .media-audio-info{flex-direction:column;flex:1;gap:1px;min-width:0;display:flex}.media-audio-card .media-audio-title{font-size:var(--type-2xs);font-weight:var(--fw-medium,500);color:var(--site-text-primary);text-overflow:ellipsis;white-space:nowrap;line-height:1.3;overflow:hidden}.media-audio-card .media-audio-time{font-size:var(--type-2xs);color:var(--site-text-secondary);font-variant-numeric:tabular-nums;line-height:1}.media-audio-card .media-audio-play-btn{cursor:pointer;background:var(--site-text-primary);width:28px;height:28px;color:var(--background,#fff);border:none;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;transition:transform .12s;display:flex}.media-audio-card .media-audio-play-btn:hover{transform:scale(1.1)}.media-audio-card .media-audio-play-btn:active{transform:scale(.92)}.media-audio-card .media-audio-play-btn svg{width:14px;height:14px}.media-audio-card .media-audio-icon-play{margin-left:2px}.media-audio-card .media-audio-icon-pause,.media-audio-card.is-playing .media-audio-icon-play{display:none}.media-audio-card.is-playing .media-audio-icon-pause{display:block}.media-gallery-card{color:var(--site-text-primary);border-radius:var(--media-radius,.5rem);background-color:var(--site-nav-hover-bg);border:1px solid var(--site-divider);text-decoration:none;transition:background-color .15s;display:block;overflow:hidden}a.media-gallery-card:hover,button.media-gallery-card:hover{background-color:var(--site-divider)}button.media-gallery-card{cursor:pointer;font:inherit;text-align:inherit}.media-gallery-card-inner{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:8px;width:100%;height:100%;padding:16px 12px;display:flex}.media-gallery-card-icon{color:var(--site-text-secondary);opacity:.6}.media-gallery-card-summary{font-size:var(--type-xs);color:var(--site-text-secondary);-webkit-line-clamp:2;word-break:break-word;-webkit-box-orient:vertical;line-height:1.4;display:-webkit-box;overflow:hidden}.media-gallery-card-meta{font-size:var(--type-xs);color:var(--site-text-secondary)}.media-lightbox-video{background-color:#000;border-radius:4px;outline:none;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in}.media-lightbox-video:focus,.media-lightbox-video:focus-visible{outline:none}.media-lightbox-video-short{object-fit:contain;width:100%;max-width:none;height:100%;max-height:none;display:block}@media (max-width:640px){.media-lightbox-video{border-radius:0}}[data-post-media] img{background:0 0}.media-gallery-scroll-wrap{--_fade:1.5rem;--_mask-left:black;--_mask-right:black}.media-gallery-scroll-wrap>[data-post-media]{-webkit-mask-image:linear-gradient(to right, var(--_mask-left) 0%, black var(--_fade), black calc(100% - var(--_fade)), var(--_mask-right) 100%);mask-image:linear-gradient(to right, var(--_mask-left) 0%, black var(--_fade), black calc(100% - var(--_fade)), var(--_mask-right) 100%);-webkit-mask-image:linear-gradient(to right, var(--_mask-left) 0%, black var(--_fade), black calc(100% - var(--_fade)), var(--_mask-right) 100%)}.media-gallery-scroll-wrap.can-scroll-start{--_mask-left:transparent}.media-gallery-scroll-wrap.can-scroll-end{--_mask-right:transparent}
1
+ @keyframes lightbox-fade-in{0%{opacity:0}to{opacity:1}}@keyframes lightbox-scale-in{0%{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}.media-lightbox{background:0 0;border:none;outline:none;width:100%;max-width:100%;height:100%;max-height:100%;padding:0}.media-lightbox[open]{animation:.2s both lightbox-fade-in}.media-lightbox::backdrop{background-color:#000}.media-lightbox-content{outline:none;width:100%;height:100dvh;position:relative}.media-lightbox-stage{box-sizing:border-box;justify-content:center;align-items:center;width:100%;height:100%;padding:64px 96px;display:flex;overflow:hidden}.media-lightbox-stage-scroll{overscroll-behavior:contain;scrollbar-gutter:stable both-edges;-webkit-overflow-scrolling:touch;align-items:flex-start;overflow:hidden auto}.media-lightbox-img{object-fit:contain;border-radius:4px;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in;display:block}.media-lightbox-img-zoomable{cursor:zoom-in}.media-lightbox-img-scroll{width:min(100%,44rem);max-width:none;height:auto;max-height:none;margin:0 auto}.media-lightbox-img-zoomable.media-lightbox-img-scroll{cursor:zoom-out}.media-lightbox-close{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:40px;height:40px;transition:background-color .15s;display:flex;position:fixed;top:16px;left:16px}.media-lightbox-close:hover{background-color:#000000b3}.media-lightbox-nav{z-index:10;-webkit-backdrop-filter:blur(8px);color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:44px;height:44px;transition:background-color .15s;display:flex;position:fixed;top:50%;transform:translateY(-50%)}.media-lightbox-nav:hover{background-color:#000000b3}.media-lightbox-nav-prev{left:16px}.media-lightbox-nav-next{right:16px}.media-lightbox-counter{z-index:10;font-size:var(--type-xs);color:#ffffffb3;font-variant-numeric:tabular-nums;-webkit-user-select:none;user-select:none;position:fixed;top:20px;left:50%;transform:translate(-50%)}.media-lightbox-short-frame{max-width:100%;max-height:100%;display:block;position:relative}.media-lightbox-short-controls{z-index:2;pointer-events:none;height:86px;position:absolute;bottom:0;left:0;right:0}.media-lightbox-short-progress{--media-progress:0%;z-index:1;appearance:none;pointer-events:auto;cursor:pointer;background:0 0;width:auto;height:34px;margin:0;padding:0;display:block;position:absolute;bottom:0;left:16px;right:16px}.media-lightbox-short-progress::-webkit-slider-runnable-track{background:linear-gradient(to right, #ffffffeb 0, #ffffffeb var(--media-progress), #ffffff2e var(--media-progress), #ffffff2e 100%);border-radius:999px;height:2px;transition:height .15s,background .15s}.media-lightbox-short-progress::-webkit-slider-thumb{appearance:none;opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;margin-top:-4px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-progress::-moz-range-track{background:#ffffff2e;border:none;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-progress{background:#ffffffeb;border-radius:999px;height:2px}.media-lightbox-short-progress::-moz-range-thumb{opacity:.82;background-color:#fff;border:none;border-radius:999px;width:10px;height:10px;transition:opacity .15s,transform .15s;box-shadow:0 1px 4px #00000052}.media-lightbox-short-controls-portrait .media-lightbox-short-progress{width:calc(100% - 48px);right:auto}.media-lightbox-short-progress:hover::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:focus-visible::-webkit-slider-runnable-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-track{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-track{height:4px}.media-lightbox-short-progress:hover::-moz-range-progress{height:4px}.media-lightbox-short-progress:focus-visible::-moz-range-progress{height:4px}.media-lightbox-short-progress:hover::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-webkit-slider-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:hover::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-progress:focus-visible::-moz-range-thumb{opacity:1;transform:scale(1.05)}.media-lightbox-short-mute{z-index:2;color:#fff;pointer-events:auto;cursor:pointer;background-color:#777;border:none;border-radius:999px;justify-content:center;align-items:center;width:44px;height:44px;transition:background-color .15s,transform .15s;display:inline-flex;position:absolute;bottom:30px;right:24px}.media-lightbox-short-mute svg{flex-shrink:0;width:16px;height:16px;display:block}.media-lightbox-short-mute:hover{background-color:#686868;transform:scale(1.03)}.media-lightbox-short-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}@media (max-width:640px){.media-lightbox-stage{padding:48px 16px}.media-lightbox-img{border-radius:0}.media-lightbox-img-scroll{width:100%}.media-lightbox-close{width:36px;height:36px;top:12px;left:12px}.media-lightbox-nav{width:36px;height:36px}.media-lightbox-nav-prev{left:8px}.media-lightbox-nav-next{right:8px}.media-lightbox-short-mute{bottom:26px;right:24px}}.media-visual-frame{border-radius:var(--media-radius,.5rem);background-color:var(--color-muted);display:block;overflow:hidden}.media-visual{background-position:50%;background-repeat:no-repeat;display:block}.media-video-wrap{position:relative}.media-video-link{cursor:pointer;display:block}.media-video-wrap video{object-fit:contain;background-color:var(--color-muted);width:100%;max-height:24rem}.media-video-wrap-short video{background-color:#000}.media-feed-video-mute{z-index:1;color:#fff;cursor:pointer;background-color:#00000080;border:none;border-radius:999px;justify-content:center;align-items:center;width:28px;height:28px;transition:background-color .15s,transform .15s;display:inline-flex;position:absolute;bottom:16px;right:16px}.media-feed-video-mute svg{width:12px;height:12px}.media-feed-video-mute:hover{background-color:#0000009e;transform:scale(1.03)}.media-feed-video-mute:focus-visible{outline:none;box-shadow:0 0 0 3px #fff3}.media-feed-video-icon{transition:opacity .15s,transform .15s;position:absolute}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-muted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-unmuted{opacity:1;transform:scale(1)}.media-feed-video-mute[data-muted=true] .media-feed-video-icon-unmuted,.media-feed-video-mute[data-muted=false] .media-feed-video-icon-muted{opacity:0;transform:scale(.92)}.media-video-play-overlay{pointer-events:none;justify-content:center;align-items:center;transition:opacity .15s;display:flex;position:absolute;inset:0}.media-video-play-overlay svg{filter:drop-shadow(0 2px 6px #0006);opacity:.85;width:48px;height:48px}.media-gallery-card.media-audio-card{flex-direction:column;display:flex}.media-audio-card .media-audio-el{opacity:0;pointer-events:none;width:0;height:0;position:absolute}.media-audio-card .media-audio-artwork{background:linear-gradient(160deg,#8080801f 0%,#80808008 100%);flex:1;justify-content:center;align-items:center;width:100%;min-height:0;display:flex}.media-audio-card .media-audio-artwork svg{width:32px;height:32px;color:var(--site-text-secondary);opacity:.3}.media-audio-card .media-audio-waveform{cursor:pointer;touch-action:none;width:100%;height:24px;display:none}.media-audio-card.has-waveform .media-audio-waveform{display:block}.media-audio-card.has-waveform .media-audio-range{clip:rect(0, 0, 0, 0);white-space:nowrap;border:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.media-audio-card .media-audio-controls{flex-direction:column;flex-shrink:0;padding:0 0 6px;display:flex}.media-audio-card .media-audio-range{appearance:none;cursor:pointer;touch-action:none;background:0 0;width:100%;height:20px;margin:0;padding:0}.media-audio-card .media-audio-range::-webkit-slider-runnable-track{background:#80808026;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-track{background:#80808026;border:none;border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-moz-range-progress{background:var(--site-text-primary);border-radius:1.5px;height:3px}.media-audio-card .media-audio-range::-webkit-slider-thumb{-webkit-appearance:none;background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;margin-top:-3.5px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-webkit-slider-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-webkit-slider-thumb{opacity:1}.media-audio-card .media-audio-range::-moz-range-thumb{background:var(--site-text-primary);opacity:0;border:none;border-radius:50%;width:10px;height:10px;transition:opacity .15s}.media-audio-card .media-audio-range:hover::-moz-range-thumb{opacity:1}.media-audio-card.is-playing .media-audio-range::-moz-range-thumb{opacity:1}.media-audio-card .media-audio-range:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:2px}.media-audio-card .media-audio-row{align-items:center;gap:6px;min-width:0;padding:6px 8px 0;display:flex}.media-audio-card .media-audio-info{flex-direction:column;flex:1;gap:1px;min-width:0;display:flex}.media-audio-card .media-audio-title{font-size:var(--type-2xs);font-weight:var(--fw-medium,500);color:var(--site-text-primary);text-overflow:ellipsis;white-space:nowrap;line-height:1.3;overflow:hidden}.media-audio-card .media-audio-time{font-size:var(--type-2xs);color:var(--site-text-secondary);font-variant-numeric:tabular-nums;line-height:1}.media-audio-card .media-audio-play-btn{cursor:pointer;background:var(--site-text-primary);width:28px;height:28px;color:var(--background,#fff);border:none;border-radius:50%;flex-shrink:0;justify-content:center;align-items:center;transition:transform .12s;display:flex}.media-audio-card .media-audio-play-btn:hover{transform:scale(1.1)}.media-audio-card .media-audio-play-btn:active{transform:scale(.92)}.media-audio-card .media-audio-play-btn svg{width:14px;height:14px}.media-audio-card .media-audio-icon-play{margin-left:2px}.media-audio-card .media-audio-icon-pause,.media-audio-card.is-playing .media-audio-icon-play{display:none}.media-audio-card.is-playing .media-audio-icon-pause{display:block}.media-gallery-card{color:var(--site-text-primary);border-radius:var(--media-radius,.5rem);background-color:var(--site-nav-hover-bg);border:1px solid var(--site-divider);text-decoration:none;transition:background-color .15s;display:block;overflow:hidden}a.media-gallery-card:hover,button.media-gallery-card:hover{background-color:var(--site-divider)}button.media-gallery-card{cursor:pointer;font:inherit;text-align:inherit}.media-gallery-card-inner{text-align:center;flex-direction:column;justify-content:center;align-items:center;gap:8px;width:100%;height:100%;padding:16px 12px;display:flex}.media-gallery-card-icon{color:var(--site-text-secondary);opacity:.6}.media-gallery-card-summary{font-size:var(--type-xs);color:var(--site-text-secondary);-webkit-line-clamp:2;word-break:break-word;-webkit-box-orient:vertical;line-height:1.4;display:-webkit-box;overflow:hidden}.media-gallery-card-meta{font-size:var(--type-xs);color:var(--site-text-secondary)}.media-lightbox-video{background-color:#000;border-radius:4px;outline:none;max-width:100%;max-height:100%;animation:.28s cubic-bezier(.22,1,.36,1) both lightbox-scale-in}.media-lightbox-video:focus,.media-lightbox-video:focus-visible{outline:none}.media-lightbox-video-short{object-fit:contain;width:100%;max-width:none;height:100%;max-height:none;display:block}@media (max-width:640px){.media-lightbox-video{border-radius:0}}[data-post-media] img{background:0 0}.media-gallery-scroll-wrap{position:relative}.media-gallery-nav{z-index:2;-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);color:#fff;cursor:pointer;opacity:0;pointer-events:none;background-color:#00000080;border:none;border-radius:50%;justify-content:center;align-items:center;width:34px;height:34px;padding:0;transition:opacity .15s,background-color .15s;display:flex;position:absolute;top:50%;transform:translateY(-50%)}.media-gallery-nav:hover{background-color:#000000b3}.media-gallery-nav svg{width:18px;height:18px;display:block}.media-gallery-nav-prev{left:8px}.media-gallery-nav-next{right:8px}@media (hover:hover) and (pointer:fine){.media-gallery-scroll-wrap.can-scroll-start:hover .media-gallery-nav-prev,.media-gallery-scroll-wrap.can-scroll-end:hover .media-gallery-nav-next{opacity:1;pointer-events:auto}}.media-gallery-scroll-wrap>[data-post-media]:focus-visible{outline:2px solid var(--site-text-primary);outline-offset:2px;border-radius:4px}
2
2
  /*$vite$:1*/