-
-
-
-
-
-
-
-
-
- NuxtSEO{{ isPro ? " Pro" : "" }}
-
+
+
+
![]()
+
+
+ OneLiteFeather.net
+
+
+ {{ title }}
+
+
-
-
- {{ title }}
-
-
-
-
{{ description }}
-
+
+
+
diff --git a/layers/content-core/utils/sectionOgImage.ts b/layers/content-core/utils/sectionOgImage.ts
new file mode 100644
index 00000000..83a169c9
--- /dev/null
+++ b/layers/content-core/utils/sectionOgImage.ts
@@ -0,0 +1,23 @@
+/**
+ * The fixed social card of each section page, one per locale. The PNGs are
+ * rendered by scripts/render-og-images.mjs and uploaded to the image bucket
+ * under images/og/, so the path resolves through the image provider like any
+ * other page image. Keep the list in step with the script's SECTIONS.
+ */
+export const OG_SECTIONS = [
+ 'home',
+ 'blog',
+ 'projects',
+ 'community',
+ 'community-poi',
+ 'events',
+ 'team',
+ 'about',
+ 'bluemap'
+] as const
+
+export type OgSection = typeof OG_SECTIONS[number]
+
+export function sectionOgImage(section: OgSection, locale: string): string {
+ return `/images/og/${section}-${locale}.png`
+}
diff --git a/pages/about.vue b/pages/about.vue
index 6e8a1a3b..56d0a5b8 100644
--- a/pages/about.vue
+++ b/pages/about.vue
@@ -15,6 +15,7 @@ const { numbers } = useCommunityOverview()
usePageSeo({
title: t('about.title'),
description: t('about.description'),
+ image: sectionOgImage('about', locale.value),
schemaType: 'AboutPage',
keywords: [
'OneLiteFeather',
diff --git a/pages/blog/index.vue b/pages/blog/index.vue
index 8602975e..09e937a8 100644
--- a/pages/blog/index.vue
+++ b/pages/blog/index.vue
@@ -14,6 +14,7 @@ const { top1Article, allPosts, authorsOf } = useBlogOverview()
usePageSeo({
title: t('blog.overview.title'),
description: t('blog.overview.description'),
+ image: sectionOgImage('blog', locale.value),
schemaType: 'CollectionPage',
keywords: [
'OneLiteFeather blog',
diff --git a/pages/bluemap.vue b/pages/bluemap.vue
index 86a09323..f1a92806 100644
--- a/pages/bluemap.vue
+++ b/pages/bluemap.vue
@@ -10,6 +10,7 @@ definePageMeta({
usePageSeo({
title: t('bluemap.title'),
description: t('bluemap.description'),
+ image: sectionOgImage('bluemap', locale.value),
schemaType: 'WebPage',
keywords: [
'OneLiteFeather BlueMap',
diff --git a/pages/community-poi/index.vue b/pages/community-poi/index.vue
index bc21e817..0696161a 100644
--- a/pages/community-poi/index.vue
+++ b/pages/community-poi/index.vue
@@ -13,6 +13,7 @@ const { pois, total } = useCommunityPoiOverview()
usePageSeo({
title: t('community_poi.overview.title'),
description: t('community_poi.overview.description'),
+ image: sectionOgImage('community-poi', locale.value),
schemaType: 'CollectionPage',
keywords: [
'OneLiteFeather community',
diff --git a/pages/community.vue b/pages/community.vue
index e254c8e3..5cb6feca 100644
--- a/pages/community.vue
+++ b/pages/community.vue
@@ -12,6 +12,7 @@ const { overview, numbers } = useCommunityOverview()
usePageSeo({
title: t('community.title'),
description: t('community.description'),
+ image: sectionOgImage('community', locale.value),
schemaType: 'CollectionPage',
keywords: [
'OneLiteFeather community',
diff --git a/pages/events/index.vue b/pages/events/index.vue
index dd3a408c..ddc360d0 100644
--- a/pages/events/index.vue
+++ b/pages/events/index.vue
@@ -17,6 +17,7 @@ const discordUrl = (runtimeConfig.public?.discordUrl as string | undefined)
usePageSeo({
title: t('events.title'),
description: t('events.description'),
+ image: sectionOgImage('events', locale.value),
schemaType: 'CollectionPage'
})
diff --git a/pages/index.vue b/pages/index.vue
index 0ce082f7..9a220ff7 100644
--- a/pages/index.vue
+++ b/pages/index.vue
@@ -30,7 +30,7 @@ const carouselSlides = computed(() => {
const { sponsors } = useSponsoring()
const { data: collective } = useOpenCollective()
const { items: faqItems } = useFaqContent()
-useHomeSeo({ title: t('index.title') })
+useHomeSeo({ title: t('index.title'), image: sectionOgImage('home', locale.value) })
// The h1 in the template is sr-only: the carousel is the visual opening and leaves no room for a heading.
diff --git a/pages/projects/[...slug].vue b/pages/projects/[...slug].vue
index 97893245..048d19f1 100644
--- a/pages/projects/[...slug].vue
+++ b/pages/projects/[...slug].vue
@@ -21,7 +21,7 @@ const { pois } = await useCommunityPoisByProject(computed(() => project.value?.s
usePageSeo({
title: title.value,
description: description.value,
- image: project.value?.logo,
+ image: project.value?.logo || sectionOgImage('projects', locale.value),
imageAlt: project.value?.logoAlt,
ogType: 'article',
schemaType: 'ItemPage'
diff --git a/pages/projects/index.vue b/pages/projects/index.vue
index f1130e89..04244eba 100644
--- a/pages/projects/index.vue
+++ b/pages/projects/index.vue
@@ -13,6 +13,7 @@ const { projects } = await useProjectsOverview()
usePageSeo({
title: t('projects.overview.title'),
description: t('projects.overview.description'),
+ image: sectionOgImage('projects', locale.value),
schemaType: 'CollectionPage',
keywords: [
'OneLiteFeather Projekte',
diff --git a/pages/team/index.vue b/pages/team/index.vue
index 0423f4b5..a1cc1745 100644
--- a/pages/team/index.vue
+++ b/pages/team/index.vue
@@ -14,6 +14,7 @@ const supporterLinks = useLiteSupporterLinks()
usePageSeo({
title: t('team.index.title'),
description: t('team.index.description'),
+ image: sectionOgImage('team', locale.value),
schemaType: 'CollectionPage',
keywords: [
'OneLiteFeather team',
diff --git a/scripts/render-og-images.mjs b/scripts/render-og-images.mjs
new file mode 100644
index 00000000..b3cde858
--- /dev/null
+++ b/scripts/render-og-images.mjs
@@ -0,0 +1,146 @@
+// Renders the 18 section cards (9 sections x de/en) from the real NuxtSeo.satori.vue
+// template, without prerendering, and prints the rclone commands that upload them.
+// pnpm build && node scripts/render-og-images.mjs [--out
] [--fonts ]
+
+import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath, pathToFileURL } from 'node:url'
+import { parseArgs } from 'node:util'
+import * as cheerio from 'cheerio'
+import satori from 'satori'
+import { Resvg, initWasm } from '@resvg/resvg-wasm'
+import { compileScript, parse } from 'vue/compiler-sfc'
+import { createSSRApp } from 'vue'
+import { renderToString } from 'vue/server-renderer'
+import ts from 'typescript'
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
+const TEMPLATE = join(ROOT, 'components/OgImage/NuxtSeo.satori.vue')
+const WIDTH = 1200
+const HEIGHT = 630
+const LOCALES = ['de', 'en']
+
+/** Section -> the i18n keys its page uses for the title and description today. */
+export const SECTIONS = [
+ { section: 'home', title: 'index.title', description: 'seo.default_description' },
+ { section: 'blog', title: 'blog.overview.title', description: 'blog.overview.description' },
+ { section: 'projects', title: 'projects.overview.title', description: 'projects.overview.description' },
+ { section: 'community', title: 'community.title', description: 'community.description' },
+ { section: 'community-poi', title: 'community_poi.overview.title', description: 'community_poi.overview.description' },
+ { section: 'events', title: 'events.title', description: 'events.description' },
+ { section: 'team', title: 'team.index.title', description: 'team.index.description' },
+ { section: 'about', title: 'about.title', description: 'about.description' },
+ { section: 'bluemap', title: 'bluemap.title', description: 'bluemap.description' }
+]
+
+/** Bucket key (and public path suffix) of one card. */
+export const uploadKey = (section, locale) => `images/og/${section}-${locale}.png`
+
+const lookup = (json, key) => key.split('.').reduce((node, part) => node?.[part], json)
+
+/** Compiles the real SFC with Vue's compiler; `?raw` imports are inlined as strings. */
+async function loadTemplate() {
+ const { descriptor, errors } = parse(readFileSync(TEMPLATE, 'utf8'), { filename: TEMPLATE })
+ if (errors.length) throw new Error(`template does not parse: ${errors[0].message}`)
+ const script = compileScript(descriptor, { id: 'og-section-card', inlineTemplate: true, isProd: true })
+ let code = ts.transpileModule(script.content, {
+ fileName: 'NuxtSeo.satori.ts',
+ compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }
+ }).outputText
+ code = code.replace(/import (\w+) from ['"]([^'"]+)\?raw['"];?/g, (_, name, file) => `const ${name} = ${JSON.stringify(readFileSync(resolve(dirname(TEMPLATE), file), 'utf8'))};`)
+ // Next to the repo, so the bare `vue` import resolves to the repo's copy.
+ const dir = join(ROOT, 'node_modules/.cache/og-section-cards')
+ mkdirSync(dir, { recursive: true })
+ const file = join(dir, 'NuxtSeo.satori.mjs')
+ writeFileSync(file, code)
+ return (await import(pathToFileURL(file).href)).default
+}
+
+/** Inline style string from Vue's SSR output -> Satori style object. */
+function parseStyle(text) {
+ const style = {}
+ for (const declaration of text.split(';')) {
+ const at = declaration.indexOf(':')
+ if (at < 0) continue
+ const key = declaration.slice(0, at).trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase())
+ const value = declaration.slice(at + 1).trim()
+ style[key] = /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value
+ }
+ return style
+}
+
+/** Rendered HTML -> the element tree Satori takes. Comments and blank text are dropped. */
+function toSatoriTree(html) {
+ const $ = cheerio.load(html, null, false)
+ const convert = (node) => {
+ if (node.type === 'text') {
+ const text = node.data.replace(/\s+/g, ' ').trim()
+ return text ? text : null
+ }
+ if (node.type !== 'tag') return null
+ const props = {}
+ for (const [name, value] of Object.entries(node.attribs)) {
+ if (name === 'style') props.style = parseStyle(value)
+ else props[name] = /^\d+$/.test(value) ? Number(value) : value
+ }
+ const children = node.children.map(convert).filter(child => child !== null)
+ if (children.length) props.children = children.length === 1 ? children[0] : children
+ return { type: node.name, props }
+ }
+ return convert($.root().contents().toArray().find(node => node.type === 'tag'))
+}
+
+async function renderCard(component, props, fonts) {
+ const html = await renderToString(createSSRApp(component, props))
+ const svg = await satori(toSatoriTree(html), { width: WIDTH, height: HEIGHT, fonts })
+ return new Resvg(svg, { fitTo: { mode: 'width', value: WIDTH } }).render().asPng()
+}
+
+function loadFonts(fontDir) {
+ const files = [[400, 'inter-400-latin.ttf'], [700, 'inter-700-latin.ttf']]
+ return files.map(([weight, file]) => {
+ const path = join(fontDir, file)
+ if (!existsSync(path)) throw new Error(`missing ${path}: run pnpm build once, or pass --fonts`)
+ return { name: 'Inter', data: readFileSync(path), weight, style: 'normal' }
+ })
+}
+
+export async function renderAll({ outDir, fontDir }) {
+ await initWasm(WebAssembly.compile(readFileSync(join(ROOT, 'node_modules/@resvg/resvg-wasm/index_bg.wasm'))))
+ const component = await loadTemplate()
+ const fonts = loadFonts(fontDir)
+ mkdirSync(outDir, { recursive: true })
+ const written = []
+ for (const locale of LOCALES) {
+ const json = JSON.parse(readFileSync(join(ROOT, `i18n/locales/${locale}.json`), 'utf8'))
+ for (const entry of SECTIONS) {
+ const title = lookup(json, entry.title)
+ const description = lookup(json, entry.description)
+ if (typeof title !== 'string' || typeof description !== 'string') {
+ throw new Error(`${locale}: ${entry.title} or ${entry.description} is missing`)
+ }
+ const file = join(outDir, `${entry.section}-${locale}.png`)
+ writeFileSync(file, await renderCard(component, { title, description }, fonts))
+ written.push({ file, key: uploadKey(entry.section, locale) })
+ }
+ }
+ return written
+}
+
+async function main() {
+ const { values } = parseArgs({
+ options: {
+ out: { type: 'string', default: join(ROOT, 'node_modules/.cache/og-section-cards/png') },
+ fonts: { type: 'string', default: join(ROOT, '.output/public/_og-static-fonts') }
+ }
+ })
+ const cards = await renderAll({ outDir: resolve(values.out), fontDir: resolve(values.fonts) })
+ console.log('Upload once every card exists, before anything requests the URLs:')
+ for (const { file, key } of cards) {
+ console.log(`rclone copyto --s3-no-check-bucket --immutable ${file} img-onelitefeather-net:img-onelitefeather-net/${key}`)
+ }
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ await main()
+}
diff --git a/scripts/seo-check.mjs b/scripts/seo-check.mjs
index e2c2a30a..08c75c3e 100644
--- a/scripts/seo-check.mjs
+++ b/scripts/seo-check.mjs
@@ -21,6 +21,7 @@
*/
import { load } from 'cheerio'
+import { existsSync } from 'node:fs'
import process from 'node:process'
const args = process.argv.slice(2)
@@ -53,17 +54,6 @@ const NOINDEX_ROUTES = [
/** The canonical origin every absolute SEO URL is written against. */
const SITE_ORIGIN = 'https://onelitefeather.net'
-/**
- * Routes whose og:image is the site default served from public/, so the image
- * itself can be fetched from any build (see checkSocialImage).
- */
-const FETCH_IMAGE_ROUTES = new Set([
- '/en',
- '/de',
- '/en/blog',
- '/de/blog'
-])
-
const TITLE_MIN = 10
const TITLE_MAX = 70
const DESCRIPTION_MIN = 50
@@ -264,7 +254,7 @@ const checkPage = async ({ route, mustIndex }) => {
const ids = nodes.map((n) => n['@id']).filter(Boolean)
for (const id of new Set(ids.filter((id, i) => ids.indexOf(id) !== i))) err(route, `JSON-LD @id appears twice: ${id}`)
- await checkSocialImage(route, $, { fetchImage: FETCH_IMAGE_ROUTES.has(route) })
+ await checkSocialImage(route, $)
}
/** Width and height of a PNG, read from its IHDR chunk. */
@@ -280,12 +270,11 @@ const pngSize = (bytes) => {
/**
* og:image must be absolute, and a large card needs a large image. The image
- * itself is fetched only for routes that use the site default from public/:
- * article and build images live behind the production image proxy and are
- * absent from a local build, so fetching them here would test the proxy, not
- * this repository.
+ * is fetched only when it is served from public/: proxied images are absent
+ * from a local build, so fetching them here would test the proxy, not this
+ * repository.
*/
-const checkSocialImage = async (route, $, { fetchImage }) => {
+const checkSocialImage = async (route, $) => {
const image = headContent($, 'meta[property="og:image"]')
if (!image) return // missing tag is already reported above
if (!/^https?:\/\//.test(image)) {
@@ -297,12 +286,13 @@ const checkSocialImage = async (route, $, { fetchImage }) => {
if (card === 'summary_large_image' && declaredWidth && declaredWidth < 1200) {
err(route, `twitter:card summary_large_image with a ${declaredWidth}px wide og:image`)
}
- if (!fetchImage) return
-
const imageUrl = new URL(image)
- const servedLocally = IS_LOCAL && imageUrl.origin === SITE_ORIGIN
- const target = servedLocally ? new URL(imageUrl.pathname, BASE) : imageUrl
- const res = await fetch(target)
+ const servedLocally = IS_LOCAL
+ && imageUrl.origin === SITE_ORIGIN
+ && existsSync(new URL(`../public${imageUrl.pathname}`, import.meta.url))
+ if (!servedLocally) return
+
+ const res = await fetch(new URL(imageUrl.pathname, BASE))
if (res.status !== 200) {
err(route, `og:image ${image} answered ${res.status}`)
return
diff --git a/tests/seo/og-image-template.spec.ts b/tests/seo/og-image-template.spec.ts
new file mode 100644
index 00000000..7e9bf544
--- /dev/null
+++ b/tests/seo/og-image-template.spec.ts
@@ -0,0 +1,56 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { repoRoot } from '../helpers/sources'
+import { schemeColors } from '../helpers/theme'
+
+/**
+ * The ejected `NuxtSeo.satori` template is the card every page without its
+ * own image is shared with. It must carry OneLiteFeather branding, not the
+ * nuxt-og-image demo, and it renders inside an isolated Satori island, so it
+ * may not reach for i18n, routing or cookies (see the nuxt-seo skill,
+ * references/og-images.md).
+ */
+
+const TEMPLATE = 'components/OgImage/NuxtSeo.satori.vue'
+const source = readFileSync(join(repoRoot, TEMPLATE), 'utf8')
+
+/** Every `#rrggbb` literal in the template's inline styles, lowercase. */
+const hexLiterals = (text: string): string[] => [
+ ...new Set([...text.matchAll(/#[0-9a-fA-F]{6}\b/g)].map(([hex]) => hex.toLowerCase()))
+]
+
+describe('NuxtSeo.satori template branding', () => {
+ it('carries no Nuxt SEO wording', () => {
+ expect(source, 'the module demo name must be gone from the markup').not.toMatch(/nuxt\s*seo/i)
+ expect(source, 'the module demo name must be gone from the markup').not.toMatch(/nuxt-seo/i)
+ })
+
+ it('names the site as OneLiteFeather.net', () => {
+ expect(source).toContain('OneLiteFeather.net')
+ })
+
+ it('draws the repository logo rather than a placeholder mark', () => {
+ expect(source, 'the logo is imported from public/images/logo.svg as raw text').toMatch(/public\/images\/logo\.svg\?raw/)
+ })
+
+ it('takes its title and description from props only', () => {
+ expect(source).toMatch(/defineProps<\{\s*title: string;\s*description\?: string/)
+ expect(source, 'no i18n, route, cookie or composable access inside the island')
+ .not.toMatch(/useI18n|\$t\(|useRoute|useCookie|useRuntimeConfig|useSiteConfig/)
+ })
+
+ it('uses only literal colours that exist in the MD3 scheme', () => {
+ // Satori cannot resolve CSS variables, so the palette is copied as
+ // literals. Each literal must be a dark-scheme role from tailwind.css,
+ // so the card cannot drift from the site without a failing test.
+ const darkRoles = new Set([...schemeColors().values()].map(role => role.dark))
+ const used = hexLiterals(source)
+ expect(used.length).toBeGreaterThan(0)
+ expect(used.filter(hex => !darkRoles.has(hex))).toEqual([])
+ })
+
+ it('does not use the module demo colour switch for pro cards', () => {
+ expect(source).not.toMatch(/isPro|colorMode/)
+ })
+})
diff --git a/tests/seo/section-og-images.spec.ts b/tests/seo/section-og-images.spec.ts
new file mode 100644
index 00000000..88d1e08c
--- /dev/null
+++ b/tests/seo/section-og-images.spec.ts
@@ -0,0 +1,89 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { OG_SECTIONS, sectionOgImage } from '../../layers/content-core/utils/sectionOgImage'
+import { SECTIONS, uploadKey } from '../../scripts/render-og-images.mjs'
+import { repoRoot } from '../helpers/sources'
+
+/**
+ * Section pages share one fixed card per section and locale, rendered by
+ * scripts/render-og-images.mjs and uploaded to images/og/-.png.
+ * A page that loses its `image:` argument silently falls back to
+ * og-default.png, which is the regression these tests guard against.
+ */
+
+const LOCALES = ['de', 'en'] as const
+
+/** Section page -> the usePageSeo/useHomeSeo call that must pass its card. */
+const SECTION_PAGES: Array<[section: string, file: string]> = [
+ ['home', 'pages/index.vue'],
+ ['blog', 'pages/blog/index.vue'],
+ ['projects', 'pages/projects/index.vue'],
+ ['community', 'pages/community.vue'],
+ ['community-poi', 'pages/community-poi/index.vue'],
+ ['events', 'pages/events/index.vue'],
+ ['team', 'pages/team/index.vue'],
+ ['about', 'pages/about.vue'],
+ ['bluemap', 'pages/bluemap.vue']
+]
+
+const read = (file: string) => readFileSync(join(repoRoot, file), 'utf8')
+const i18n = (locale: string) => JSON.parse(read(`i18n/locales/${locale}.json`)) as Record
+const lookup = (json: Record, key: string): unknown => key.split('.').reduce((node, part) => (node as Record | undefined)?.[part], json)
+
+describe('section og image paths', () => {
+ it('gives every section a card for each locale at the bucket path', () => {
+ for (const section of OG_SECTIONS) {
+ for (const locale of LOCALES) {
+ expect(sectionOgImage(section, locale)).toBe(`/images/og/${section}-${locale}.png`)
+ }
+ }
+ })
+
+ it('renders exactly the sections the composable can reference', () => {
+ expect(SECTIONS.map(entry => entry.section)).toEqual([...OG_SECTIONS])
+ })
+
+ it('uploads one card per section and locale under the same key', () => {
+ const keys = SECTIONS.flatMap(entry => LOCALES.map(locale => uploadKey(entry.section, locale)))
+ expect(keys).toHaveLength(OG_SECTIONS.length * LOCALES.length)
+ expect(new Set(keys).size).toBe(keys.length)
+ expect(keys).toContain('images/og/community-poi-de.png')
+ })
+})
+
+describe('section card text', () => {
+ it('reads every title and description from both locale files', () => {
+ for (const locale of LOCALES) {
+ const json = i18n(locale)
+ for (const entry of SECTIONS) {
+ for (const key of [entry.title, entry.description]) {
+ expect(typeof lookup(json, key), `${locale}: ${key}`).toBe('string')
+ expect(String(lookup(json, key)).length, `${locale}: ${key}`).toBeGreaterThan(0)
+ }
+ }
+ }
+ })
+})
+
+describe('section pages pass their card', () => {
+ it.each(SECTION_PAGES)('%s (%s) sets its section card for the current locale', (section, file) => {
+ expect(read(file)).toContain(`image: sectionOgImage('${section}', locale.value)`)
+ })
+
+ it('finds every section page it checks', () => {
+ // Guards against a renamed file making the loop above check nothing.
+ expect(SECTION_PAGES).toHaveLength(OG_SECTIONS.length)
+ })
+
+ it('falls project detail pages without a logo back to the projects card', () => {
+ expect(read('pages/projects/[...slug].vue')).toContain('image: project.value?.logo || sectionOgImage(\'projects\', locale.value)')
+ })
+
+ it('keeps the own images of content pages', () => {
+ expect(read('pages/events/[...slug].vue')).toContain('image: event.value?.thumbnail')
+ expect(read('pages/community-poi/[...slug].vue')).toContain('image: poi.value?.thumbnail')
+ expect(read('pages/team/[slug].vue')).toContain('image: avatarSrc.value')
+ expect(read('layers/blog/composables/useArticleSeo.ts')).toContain('blog.value?.headerImage')
+ })
+})