From df5c4bdc6b2bcb692232cf4bec50a56e69a21b74 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 15:06:53 +0000 Subject: [PATCH 1/3] feat(website): use organism image for social graph card Add og:image and twitter:image meta tags to BaseLayout, populated from the current organism's configured image when the page is within an organism context (either via the URL `:organism` param or the `implicitOrganism` prop, e.g. on sequence detail pages). The organism image path is resolved against the current URL so the emitted meta tag contains an absolute URL, as required by social platforms when generating link previews. --- website/src/layouts/BaseLayout.astro | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/website/src/layouts/BaseLayout.astro b/website/src/layouts/BaseLayout.astro index cdcaa3b9c9..3a2cb50dba 100644 --- a/website/src/layouts/BaseLayout.astro +++ b/website/src/layouts/BaseLayout.astro @@ -42,6 +42,10 @@ const implicitOrganismObject = implicitOrganism ? knownOrganisms.find((o) => o.k const currentOrganismObject = implicitOrganismObject || organism; const currentOrganism = currentOrganismObject?.key; +const socialCardImage = currentOrganismObject?.image + ? new URL(currentOrganismObject.image, Astro.url).toString() + : undefined; + const currentPath = Astro.url.pathname; const backendIsInDebugMode = await createBackendClient().isInDebugMode(); @@ -58,8 +62,10 @@ const lastTimeBannerWasClosed = Astro.cookies.get('lastTimeBannerWasClosed')?.va + {socialCardImage && } + {socialCardImage && } {title} | {websiteName} From e640daf5a11ac7ca9ed06871da5d91fb62ccac66 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 15:16:55 +0000 Subject: [PATCH 2/3] feat(website): add openGraph config and emit full social card meta Introduce a top-level `openGraph` config object (image + description) so instances can configure their site-wide social card without hand-rolling `` tags via `additionalHeadHTML`. Plumb it through the helm chart (values.schema.json + _common-metadata.tpl). BaseLayout now emits a coherent set of social tags: - og:type, og:url, og:title (already present), og:description, og:image - twitter:card (summary_large_image when an image is available, otherwise summary), twitter:url, twitter:title, twitter:description, twitter:image - for general SEO Image resolution: 1. The current organism's `schema.image` when the page is within an organism context (URL `:organism` param or `implicitOrganism` prop). 2. Otherwise the site-wide `openGraph.image`. Description resolution: 1. A per-page `description` prop on BaseLayout (new optional prop). 2. Otherwise the site-wide `openGraph.description`. Image paths are resolved against the current URL so the emitted absolute URLs satisfy what social platforms require. --- .../loculus/templates/_common-metadata.tpl | 3 +++ kubernetes/loculus/values.schema.json | 16 ++++++++++++++++ website/src/layouts/BaseLayout.astro | 15 +++++++++++---- website/src/types/config.ts | 7 +++++++ 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/kubernetes/loculus/templates/_common-metadata.tpl b/kubernetes/loculus/templates/_common-metadata.tpl index 147e463c77..adde6097a2 100644 --- a/kubernetes/loculus/templates/_common-metadata.tpl +++ b/kubernetes/loculus/templates/_common-metadata.tpl @@ -223,6 +223,9 @@ welcomeMessageHTML: {{ quote $.Values.welcomeMessageHTML }} {{ if $.Values.additionalHeadHTML }} additionalHeadHTML: {{ quote $.Values.additionalHeadHTML }} {{end}} +{{ if $.Values.openGraph }} +openGraph: {{ $.Values.openGraph | toYaml | nindent 6 }} +{{ end }} enableLoginNavigationItem: {{ $.Values.website.websiteConfig.enableLoginNavigationItem }} enableSubmissionNavigationItem: {{ $.Values.website.websiteConfig.enableSubmissionNavigationItem }} diff --git a/kubernetes/loculus/values.schema.json b/kubernetes/loculus/values.schema.json index 5e496e0114..45b6ce28f3 100644 --- a/kubernetes/loculus/values.schema.json +++ b/kubernetes/loculus/values.schema.json @@ -1168,6 +1168,22 @@ "type": "string", "description": "Additional HTML to inject into the of pages" }, + "openGraph": { + "groups": ["general"], + "type": "object", + "additionalProperties": false, + "description": "Default Open Graph / social card metadata. Used when no per-organism image is configured (organism-specific images are taken from each organism's `schema.image`).", + "properties": { + "image": { + "type": "string", + "description": "Default Open Graph image. Relative paths are resolved against the page URL." + }, + "description": { + "type": "string", + "description": "Default Open Graph / meta description for pages." + } + } + }, "createTestAccounts": { "groups": ["general"], "type": "boolean", diff --git a/website/src/layouts/BaseLayout.astro b/website/src/layouts/BaseLayout.astro index 3a2cb50dba..071ac47fa3 100644 --- a/website/src/layouts/BaseLayout.astro +++ b/website/src/layouts/BaseLayout.astro @@ -20,6 +20,7 @@ const { additionalHeadHTML, gitHubMainUrl, readOnlyMode, + openGraph, } = websiteConfig; const remoteBannerMessage = await getRemoteBannerMessage(); const readOnlyBannerMessage = readOnlyMode @@ -29,22 +30,23 @@ const bannerMessage = remoteBannerMessage ?? configBannerMessage ?? readOnlyBann interface Props { title: string; + description?: string; implicitOrganism?: string; fullWidth?: boolean; withoutMargin?: boolean; activeTopNavigationItem?: string; } -const { title, implicitOrganism, fullWidth, withoutMargin = false, activeTopNavigationItem } = Astro.props; +const { title, description, implicitOrganism, fullWidth, withoutMargin = false, activeTopNavigationItem } = Astro.props; const { organism, knownOrganisms } = cleanOrganism(Astro.params.organism); const implicitOrganismObject = implicitOrganism ? knownOrganisms.find((o) => o.key === implicitOrganism) : undefined; const currentOrganismObject = implicitOrganismObject || organism; const currentOrganism = currentOrganismObject?.key; -const socialCardImage = currentOrganismObject?.image - ? new URL(currentOrganismObject.image, Astro.url).toString() - : undefined; +const rawSocialCardImage = currentOrganismObject?.image ?? openGraph?.image; +const socialCardImage = rawSocialCardImage ? new URL(rawSocialCardImage, Astro.url).toString() : undefined; +const socialCardDescription = description ?? openGraph?.description; const currentPath = Astro.url.pathname; @@ -60,12 +62,17 @@ const lastTimeBannerWasClosed = Astro.cookies.get('lastTimeBannerWasClosed')?.va + + {socialCardDescription && } {socialCardImage && } + + {socialCardDescription && } {socialCardImage && } + {socialCardDescription && } {title} | {websiteName} diff --git a/website/src/types/config.ts b/website/src/types/config.ts index 0f9f0ad26b..d201e13308 100644 --- a/website/src/types/config.ts +++ b/website/src/types/config.ts @@ -238,6 +238,12 @@ const fieldToDisplay = z.object({ displayName: z.string(), }); +const openGraphConfig = z.object({ + image: z.string().optional(), + description: z.string().optional(), +}); +export type OpenGraphConfig = z.infer; + const seqSetGraphTypes = z.enum(['date', 'category']); export const seqSetGraph = z.object({ name: z.string(), @@ -258,6 +264,7 @@ export const websiteConfig = z.object({ submissionBannerMessageURL: z.string().optional(), welcomeMessageHTML: z.string().optional().nullable(), additionalHeadHTML: z.string().optional(), + openGraph: openGraphConfig.optional(), gitHubEditLink: z.string().optional(), gitHubMainUrl: z.string().optional(), gitHubIssuesUrl: z.string().optional(), From dc342b312b3f536659f153e9013457764b6f2323 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 17:22:55 +0000 Subject: [PATCH 3/3] feat(website): make openGraph.twitterCard configurable Add an optional `twitterCard` field to the top-level `openGraph` config, typed as an enum of the four card types Twitter/X supports (`summary`, `summary_large_image`, `player`, `app`). The schema is enforced both in the website's zod config and the helm chart's values.schema.json. When unset, the previous defaulting logic is preserved: `summary_large_image` if a card image is available (either from the current organism's `schema.image` or from `openGraph.image`), otherwise `summary`. --- kubernetes/loculus/values.schema.json | 5 +++++ website/src/layouts/BaseLayout.astro | 3 ++- website/src/types/config.ts | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/kubernetes/loculus/values.schema.json b/kubernetes/loculus/values.schema.json index 45b6ce28f3..0908671bf7 100644 --- a/kubernetes/loculus/values.schema.json +++ b/kubernetes/loculus/values.schema.json @@ -1181,6 +1181,11 @@ "description": { "type": "string", "description": "Default Open Graph / meta description for pages." + }, + "twitterCard": { + "type": "string", + "enum": ["summary", "summary_large_image", "player", "app"], + "description": "Value for ``. When unset, defaults to `summary_large_image` if a card image is configured (per-organism or site-wide), otherwise `summary`." } } }, diff --git a/website/src/layouts/BaseLayout.astro b/website/src/layouts/BaseLayout.astro index 071ac47fa3..b9c9ecc072 100644 --- a/website/src/layouts/BaseLayout.astro +++ b/website/src/layouts/BaseLayout.astro @@ -47,6 +47,7 @@ const currentOrganism = currentOrganismObject?.key; const rawSocialCardImage = currentOrganismObject?.image ?? openGraph?.image; const socialCardImage = rawSocialCardImage ? new URL(rawSocialCardImage, Astro.url).toString() : undefined; const socialCardDescription = description ?? openGraph?.description; +const twitterCard = openGraph?.twitterCard ?? (socialCardImage ? 'summary_large_image' : 'summary'); const currentPath = Astro.url.pathname; @@ -67,7 +68,7 @@ const lastTimeBannerWasClosed = Astro.cookies.get('lastTimeBannerWasClosed')?.va {socialCardDescription && } {socialCardImage && } - + {socialCardDescription && } diff --git a/website/src/types/config.ts b/website/src/types/config.ts index d201e13308..79321b1ab0 100644 --- a/website/src/types/config.ts +++ b/website/src/types/config.ts @@ -241,6 +241,7 @@ const fieldToDisplay = z.object({ const openGraphConfig = z.object({ image: z.string().optional(), description: z.string().optional(), + twitterCard: z.enum(['summary', 'summary_large_image', 'player', 'app']).optional(), }); export type OpenGraphConfig = z.infer;