From 31144617d71cbec2ce46b2d926a7b926af484ec7 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 21 May 2026 17:50:34 +0100 Subject: [PATCH] feat(seo): site meta composable + Open Graph image generation Adds `useSiteMeta()` composable that wraps useSeoMeta with site defaults (title template, canonical URL, og/twitter image, og:site_name) and absolute-URL handling via runtimeConfig.public.appUrl. Adds /og/events/[slug].png route that renders per-event OG images via satori + @resvg/resvg-js, cached on disk by slug + updatedAt. Bundles Brygada 1918 + Commit Mono fonts as server assets, ships a fallback default.png, and patches @shuding/opentype.js via patch-package. Converts ~25 pages from useHead to useSiteMeta and adds noindex on private/auth/admin pages. --- app/composables/useSiteMeta.js | 58 ++ app/layouts/admin.vue | 2 + app/pages/about.vue | 8 +- app/pages/accept-invite.vue | 1 + app/pages/auth/logout-confirm.vue | 2 +- app/pages/auth/logout-success.vue | 2 +- app/pages/auth/oidc-error.vue | 2 +- app/pages/auth/wiki-login.vue | 1 + app/pages/board.vue | 12 +- app/pages/community-guidelines.vue | 6 +- app/pages/index.vue | 27 + app/pages/join.vue | 6 + app/pages/member/account.vue | 2 + app/pages/member/dashboard.vue | 2 + app/pages/member/payment-setup.vue | 2 + app/pages/member/profile.vue | 2 + app/pages/members/[id].vue | 12 +- app/pages/members/index.vue | 11 +- app/pages/policies/[slug].vue | 5 +- app/pages/policies/privacy.vue | 6 +- app/pages/policies/refunds.vue | 6 +- app/pages/policies/terms.vue | 6 +- app/pages/series/[id].vue | 8 +- app/pages/series/index.vue | 13 +- app/pages/verify.vue | 1 + package-lock.json | 709 ++++++++++++++++++ package.json | 5 +- .../@shuding+opentype.js+1.4.0-beta.0.patch | 13 + public/og/default.png | Bin 0 -> 18739 bytes server/assets/fonts/Brygada1918-Regular.ttf | Bin 0 -> 32460 bytes server/assets/fonts/Brygada1918-SemiBold.ttf | Bin 0 -> 32664 bytes server/assets/fonts/commit-mono-400.woff | Bin 0 -> 57580 bytes server/assets/fonts/commit-mono-600.woff | Bin 0 -> 57664 bytes server/routes/og/events/[slug].js | 29 + server/utils/og-cache.js | 37 + server/utils/og-render.js | 239 ++++++ 36 files changed, 1182 insertions(+), 53 deletions(-) create mode 100644 app/composables/useSiteMeta.js create mode 100644 patches/@shuding+opentype.js+1.4.0-beta.0.patch create mode 100644 public/og/default.png create mode 100644 server/assets/fonts/Brygada1918-Regular.ttf create mode 100644 server/assets/fonts/Brygada1918-SemiBold.ttf create mode 100644 server/assets/fonts/commit-mono-400.woff create mode 100644 server/assets/fonts/commit-mono-600.woff create mode 100644 server/routes/og/events/[slug].js create mode 100644 server/utils/og-cache.js create mode 100644 server/utils/og-render.js diff --git a/app/composables/useSiteMeta.js b/app/composables/useSiteMeta.js new file mode 100644 index 0000000..007a644 --- /dev/null +++ b/app/composables/useSiteMeta.js @@ -0,0 +1,58 @@ +/** + * useSiteMeta — set page-level SEO + social meta with site defaults baked in. + * + * Builds absolute URLs from runtimeConfig.public.appUrl so og:image and og:url + * resolve for crawlers. Defaults og:type=website, twitter:card=summary_large_image, + * og:site_name=Ghost Guild. Set noindex:true to emit robots="noindex, nofollow". + * + * Pass a function (or refs in fields) to keep tags reactive when content loads + * asynchronously via useFetch. + */ +export function useSiteMeta(input) { + const runtimeConfig = useRuntimeConfig() + const route = useRoute() + const appUrl = (runtimeConfig.public.appUrl || '').replace(/\/$/, '') + + const resolve = () => (typeof input === 'function' ? input() : input) || {} + + const buildAbsolute = (path) => { + if (!path) return undefined + if (/^https?:\/\//i.test(path)) return path + return `${appUrl}${path.startsWith('/') ? '' : '/'}${path}` + } + + const titleGetter = () => resolve().title || 'Ghost Guild' + const descGetter = () => resolve().description || undefined + const isBareTitle = () => Boolean(resolve().bareTitle) + const imageGetter = () => buildAbsolute(resolve().image || '/og/default.png') + const typeGetter = () => resolve().type || 'website' + const robotsGetter = () => + resolve().noindex ? 'noindex, nofollow' : undefined + const canonicalGetter = () => buildAbsolute(route.path) + + useSeoMeta({ + title: titleGetter, + description: descGetter, + ogSiteName: 'Ghost Guild', + ogTitle: titleGetter, + ogDescription: descGetter, + ogType: typeGetter, + ogUrl: canonicalGetter, + ogImage: imageGetter, + ogImageWidth: 1200, + ogImageHeight: 630, + twitterCard: 'summary_large_image', + twitterTitle: titleGetter, + twitterDescription: descGetter, + twitterImage: imageGetter, + robots: robotsGetter, + }) + + useHead({ + link: [{ rel: 'canonical', href: canonicalGetter }], + }) + + if (isBareTitle()) { + useHead({ titleTemplate: null }) + } +} diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index 5e0baad..0c0d201 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -217,6 +217,8 @@ +