Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 52 additions & 53 deletions components/OgImage/NuxtSeo.satori.vue
Original file line number Diff line number Diff line change
@@ -1,65 +1,64 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
colorMode: { type: String, required: false, default: "light" },
title: { type: String, required: false, default: "title" },
description: { type: String, required: false },
isPro: { type: Boolean, required: false }
});
const themeColor = computed(() => props.isPro ? "124, 58, 237" : "34, 197, 94");
<script setup lang="ts">
import logoSvg from '../../public/images/logo.svg?raw'

// Receives only what the calling composable resolved. Satori cannot read CSS
// variables, so the colours are the dark-scheme MD3 roles from
// assets/css/tailwind.css, copied as literals.
defineProps<{ title: string; description?: string }>()

const logoSrc = `data:image/svg+xml;base64,${btoa(logoSvg)}`
const colors = {
surface: '#121319',
onSurface: '#e4e1e9',
onSurfaceVariant: '#c6c5d3',
primary: '#bbc3ff',
secondary: '#7dd0ff',
tertiary: '#ffb0cc'
}
</script>

<template>
<div
class="w-full h-full justify-center items-center relative p-10 lg:p-[60px] bg-white text-neutral-900 dark:bg-neutral-900 dark:text-neutral-50"
:style="{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
width: '100%',
height: '100%',
padding: '64px 72px',
background: colors.surface,
color: colors.onSurface,
fontFamily: 'Inter'
}"
>
<!-- Gradient background -->
<div
class="absolute top-0 left-0 right-0 bottom-0" :style="{
backgroundImage: `radial-gradient(ellipse 100% 100% at 100% 100%, rgba(${themeColor}, 0.15) 0%, transparent 60%)`
}"
/>
<div
class="absolute top-0 left-0 right-0 bottom-0" :style="{
backgroundImage: `radial-gradient(ellipse 100% 100% at 0.1% 0.1%, rgba(${themeColor}, 0.1) 0%, transparent 50%)`
}"
/>

<div class="w-full flex-col justify-center relative items-center text-center gap-5 lg:gap-8">
<!-- Logo -->
<div class="flex items-center gap-1">
<svg viewBox="0 0 64 64" class="w-10 h-10 lg:w-16 lg:h-16">
<defs>
<linearGradient :id="isPro ? 'nsLine2' : 'nsLine1'" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" :stop-color="isPro ? '#7c3aed' : '#22c55e'" />
<stop offset="100%" :stop-color="isPro ? '#c4b5fd' : '#86efac'" />
</linearGradient>
<linearGradient :id="isPro ? 'nsFill2' : 'nsFill1'" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" :stop-color="isPro ? '#7c3aed' : '#22c55e'" stop-opacity="0.6" />
<stop offset="100%" :stop-color="isPro ? '#7c3aed' : '#22c55e'" stop-opacity="0" />
</linearGradient>
</defs>
<path d="M8 52 Q20 48 24 36 T40 20 T56 12 L56 56 L8 56 Z" :fill="`url(#${isPro ? 'nsFill2' : 'nsFill1'})`" />
<path d="M8 52 Q20 48 24 36 T40 20 T56 12" fill="none" :stroke="`url(#${isPro ? 'nsLine2' : 'nsLine1'})`" stroke-width="4" stroke-linecap="round" />
<circle cx="56" cy="12" r="6" :fill="`url(#${isPro ? 'nsLine2' : 'nsLine1'})`" />
</svg>
<span class="text-[32px] lg:text-[42px] font-bold tracking-tight">
Nuxt<span :class="isPro ? 'text-violet-500' : 'text-green-500'" class="ml-1">SEO{{ isPro ? " Pro" : "" }}</span>
</span>
<div :style="{ display: 'flex', flexDirection: 'column', gap: '40px' }">
<div :style="{ display: 'flex', alignItems: 'center', gap: '48px' }">
<img :src="logoSrc" width="240" height="240" :style="{ width: '240px', height: '240px' }" />
<div :style="{ display: 'flex', flexDirection: 'column', width: '768px', overflow: 'hidden' }">
<div :style="{ display: 'flex', fontSize: '40px', fontWeight: 700, color: colors.primary }">
OneLiteFeather.net
</div>
<div :style="{ display: 'flex', width: '768px', marginTop: '20px', fontSize: '68px', fontWeight: 700, lineHeight: 1.1 }">
{{ title }}
</div>
</div>
</div>

<!-- Title -->
<h1
class="w-full justify-center text-center text-[48px] lg:text-[80px] font-bold m-0 leading-tight max-w-[700px] lg:max-w-[1000px]"
style="display: block; line-clamp: 3; text-overflow: ellipsis; text-wrap: balance;"
<div
v-if="description"
:style="{ display: 'flex', fontSize: '30px', lineHeight: 1.4, color: colors.onSurfaceVariant, maxHeight: '176px', overflow: 'hidden' }"
>
{{ title }}
</h1>

<!-- Description -->
<p v-if="description" class="text-[24px] lg:text-[32px] opacity-70 max-w-[650px] lg:max-w-[900px] leading-relaxed" style="display: block; line-clamp: 2; text-overflow: ellipsis;">
{{ description }}
</p>
</div>
</div>

<div
:style="{
display: 'flex',
height: '10px',
borderRadius: '5px',
backgroundImage: `linear-gradient(90deg, ${colors.primary}, ${colors.secondary}, ${colors.tertiary})`
}"
/>
</div>
</template>
23 changes: 23 additions & 0 deletions layers/content-core/utils/sectionOgImage.ts
Original file line number Diff line number Diff line change
@@ -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`
}
1 change: 1 addition & 0 deletions pages/about.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions pages/blog/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions pages/bluemap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ definePageMeta({
usePageSeo({
title: t('bluemap.title'),
description: t('bluemap.description'),
image: sectionOgImage('bluemap', locale.value),
schemaType: 'WebPage',
keywords: [
'OneLiteFeather BlueMap',
Expand Down
1 change: 1 addition & 0 deletions pages/community-poi/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions pages/community.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions pages/events/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
})

Expand Down
2 changes: 1 addition & 1 deletion pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pages/projects/[...slug].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions pages/projects/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions pages/team/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
146 changes: 146 additions & 0 deletions scripts/render-og-images.mjs
Original file line number Diff line number Diff line change
@@ -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 <dir>] [--fonts <dir>]

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()
}
Loading
Loading