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.
This commit is contained in:
Jennie Robinson Faber 2026-05-21 17:50:34 +01:00
parent 877ef1a220
commit 31144617d7
36 changed files with 1182 additions and 53 deletions

View file

@ -104,7 +104,13 @@
</PageShell>
</template>
<script setup></script>
<script setup>
useSiteMeta({
title: 'About',
description:
'A membership community for game developers exploring cooperative models. Three circles, pay what you can. A program of Baby Ghosts, a Canadian non-profit advancing cooperative practice in the game industry since 2023.',
})
</script>
<style scoped>
/* ---- ABOUT HERO ---- */

View file

@ -242,6 +242,7 @@ import {
} from "~/config/contributions";
definePageMeta({ layout: false });
useSiteMeta({ title: "Accept Invitation", noindex: true });
const { checkMemberStatus } = useAuth();
const { initializeHelcimPay, verifyPayment } = useHelcimPay();

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
definePageMeta({ layout: false });
useHead({ title: "Sign Out — Ghost Guild" });
useSiteMeta({ title: "Sign Out", noindex: true });
// The xsrf token comes from a short-lived httpOnly cookie set by
// oidc-provider's logoutSource callback (see server/utils/oidc-provider.ts).

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
definePageMeta({ layout: false });
useHead({ title: "Signed Out — Ghost Guild" });
useSiteMeta({ title: "Signed Out", noindex: true });
</script>
<template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
definePageMeta({ layout: false });
useHead({ title: "Sign-In Error — Ghost Guild" });
useSiteMeta({ title: "Sign-In Error", noindex: true });
const route = useRoute();

View file

@ -2,6 +2,7 @@
definePageMeta({
layout: false,
});
useSiteMeta({ title: "Wiki Sign In", noindex: true });
const route = useRoute();
const uid = route.query.uid as string;

View file

@ -192,14 +192,10 @@ const loadTags = async () => {
cooperativeTags.value = (data.tags || []).filter((t) => t.pool === 'cooperative')
}
useHead({
title: 'Board - Ghost Guild',
meta: [
{
name: 'description',
content: 'Share what you are seeking and offering with the Ghost Guild community.',
},
],
useSiteMeta({
title: 'Bulletin Board',
description:
'The Ghost Guild bulletin board. Members post offers and requests around shared interests and cooperative topics.',
})
onMounted(async () => {

View file

@ -273,8 +273,10 @@
</template>
<script setup>
useHead({
title: "Community Guidelines · Ghost Guild",
useSiteMeta({
title: "Community Guidelines",
description:
"What you're agreeing to when you join Ghost Guild — community values, member commitments, and the policies that govern participation.",
});
</script>

View file

@ -117,6 +117,33 @@ definePageMeta({
layout: "default",
});
const runtimeConfig = useRuntimeConfig();
const siteUrl = (runtimeConfig.public.appUrl || "").replace(/\/$/, "");
useSiteMeta({
title: "Ghost Guild",
bareTitle: true,
description:
"Ghost Guild is where game developers explore cooperative models. Membership, events, and resources for people figuring it out together. Pay what you can.",
});
useHead({
script: [
{
type: "application/ld+json",
innerHTML: JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
name: "Ghost Guild",
url: siteUrl || "https://ghostguild.org",
logo: `${siteUrl || "https://ghostguild.org"}/og/default.png`,
description:
"A membership community for game developers exploring cooperative models. A program of Baby Ghosts, a Canadian non-profit.",
}),
},
],
});
const { data: events } = await useFetch("/api/events", {
query: { limit: 4, upcoming: true },
default: () => [],

View file

@ -391,6 +391,12 @@ import {
getGuidanceLabel,
} from "~/config/contributions";
useSiteMeta({
title: "Join",
description:
"Join Ghost Guild — a membership community for game developers exploring cooperative models. Everyone gets everything. Pay what you can, $0 to $50 per month.",
});
// Auth state
const { isAuthenticated, memberData, checkMemberStatus } = useAuth();

View file

@ -317,6 +317,8 @@
import { CONTRIBUTION_PRESETS, getGuidanceLabel, requiresPayment } from '~/config/contributions';
import { STATUS_LABELS } from '~/config/memberStatus';
useSiteMeta({ title: 'Account', noindex: true });
definePageMeta({
middleware: "auth",
});

View file

@ -220,6 +220,8 @@
</template>
<script setup>
useSiteMeta({ title: 'Dashboard', noindex: true });
const { memberData, checkMemberStatus } = useAuth();
const { isActive, statusConfig, isPendingPayment, canPeerSupport } =
useMemberStatus();

View file

@ -50,6 +50,8 @@
<script setup>
definePageMeta({ middleware: 'auth' });
useSiteMeta({ title: 'Payment Setup', noindex: true });
const route = useRoute();
const router = useRouter();
const toast = useToast();

View file

@ -306,6 +306,8 @@ import { MEMBER_STATUSES } from "~/composables/useMemberStatus";
import { TIMEZONE_OPTIONS } from "~/config/timezones";
import { formatActivity } from "~/utils/activityText";
useSiteMeta({ title: "Profile", noindex: true });
definePageMeta({
middleware: "auth",
});

View file

@ -276,14 +276,10 @@ onUnmounted(() => {
pageBreadcrumbTitle.value = "";
});
// Page head
useHead({
title: computed(() =>
member.value
? `${member.value.name} — Ghost Guild`
: "Member Profile — Ghost Guild",
),
});
useSiteMeta(() => ({
title: member.value ? member.value.name : "Member Profile",
noindex: true,
}));
</script>
<style scoped>

View file

@ -277,16 +277,7 @@ onBeforeUnmount(() => {
clearTimeout(searchTimeout)
})
// ---- useHead ----
useHead({
title: 'Member Directory - Ghost Guild',
meta: [
{
name: 'description',
content: 'Connect with members of the Ghost Guild community - game developers, founders, and practitioners building solidarity economy studios.',
},
],
})
useSiteMeta({ title: 'Member Directory', noindex: true })
// ---- Init ----
onMounted(async () => {

View file

@ -39,8 +39,9 @@ if (!policy) {
throw createError({ statusCode: 404, statusMessage: 'Policy not found', fatal: true })
}
useHead({
title: `${policy.title} · Ghost Guild`,
useSiteMeta({
title: policy.title,
description: policy.description,
})
</script>

View file

@ -231,8 +231,10 @@
</template>
<script setup>
useHead({
title: 'Privacy Policy · Ghost Guild',
useSiteMeta({
title: 'Privacy Policy',
description:
'How Ghost Guild handles your data: what we collect, why we collect it, and who has access. No Google Analytics, no advertising pixels, no third-party tracking.',
})
</script>

View file

@ -50,8 +50,10 @@
</template>
<script setup>
useHead({
title: 'Refund Policy · Ghost Guild',
useSiteMeta({
title: 'Refund Policy',
description:
'How Ghost Guild handles refund requests for membership dues and event tickets. Pay-what-you-can, case-by-case, run as a non-profit program of Baby Ghosts.',
})
</script>

View file

@ -250,8 +250,10 @@
</template>
<script setup>
useHead({
title: 'Terms of Service · Ghost Guild',
useSiteMeta({
title: 'Terms of Service',
description:
'Terms of service for ghostguild.org and wiki.ghostguild.org, operated by Baby Ghosts. Covers accounts, membership, acceptable use, and what we expect from each other.',
})
</script>

View file

@ -117,9 +117,11 @@ const handlePurchaseSuccess = () => {
refreshNuxtData()
}
useHead(() => ({
title: series.value ? `${series.value.title} - Event Series - Ghost Guild` : 'Event Series - Ghost Guild',
meta: [{ name: 'description', content: series.value?.description || 'Multi-event series' }],
useSiteMeta(() => ({
title: series.value ? `${series.value.title} · Event Series` : 'Event Series',
description:
series.value?.description ||
(series.value?.title ? `${series.value.title} — a Ghost Guild event series.` : undefined),
}))
</script>

View file

@ -72,15 +72,10 @@
</template>
<script setup>
useHead({
title: "Event Series - Ghost Guild",
meta: [
{
name: "description",
content:
"Multi-session events on cooperative topics for game developers.",
},
],
useSiteMeta({
title: "Event Series",
description:
"Multi-session event series on cooperative topics — from foundations courses to practitioner cohorts.",
});
const { data: seriesData, pending } = await useFetch("/api/series", {

View file

@ -17,6 +17,7 @@
<script setup>
definePageMeta({ layout: false })
useSiteMeta({ title: 'Verifying', noindex: true })
const state = ref('verifying')
const errorMessage = ref('')