diff --git a/components/OgImage/NuxtSeo.satori.vue b/components/OgImage/NuxtSeo.satori.vue index 59549590..85c8af08 100644 --- a/components/OgImage/NuxtSeo.satori.vue +++ b/components/OgImage/NuxtSeo.satori.vue @@ -1,65 +1,64 @@ - 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') + }) +})