vitepress-plugin-meta
Sets per-page head tags for VitePress automatically: description, Open Graph, Twitter Card, JSON-LD Article, canonical, robots, keywords, author, article:published_time / article:modified_time. Optionally renders a per-page OG image from an SVG template via sharp.
The plugin covers the SEO/GEO layer that the base VitePress theme leaves to hand-written head entries on every page. Metadata is resolved from frontmatter when present, otherwise from the first paragraph of the source; pre-existing tags are not overwritten.
Installation
pnpm add @ampernic/vitepress-plugin-metasharp is optional, only needed for OG image generation:
pnpm add -D sharpThe sharp package is declared as an optional peer. When it is not installed, the plugin writes the OG image as SVG (browsers and crawlers accept it).
The @ampernic scope from Forgejo is wired via .npmrc:
@ampernic:registry=https://altlinux.space/api/packages/ampernic/npm/Usage
The plugin returns transformPageData and buildEnd hooks that go into the root of defineConfig:
// .vitepress/config.ts
import { defineConfig } from 'vitepress'
import { MetaPlugin, productTemplate } from '@ampernic/vitepress-plugin-meta'
const meta = MetaPlugin({
hostname: 'https://docs.altlinux.space',
siteName: 'ALT Workstation',
locale: 'en_US',
twitter: { site: '@altlinux' },
ogImage: {
generate: {
template: productTemplate,
output: 'og/{slug}.png',
width: 1200,
height: 630,
},
},
})
export default defineConfig({
transformPageData: meta.transformPageData,
buildEnd: meta.buildEnd,
})When using @ampernic/vitepress-theme-alt-docs, the same options are wired through the meta field of createSharedConfig; the plugin is already mounted in the theme.
Head tags already declared in frontmatter are not overwritten: a page that sets its own og:image keeps it.
Options
| Option | Type | Default | Description |
|---|---|---|---|
hostname | string | -- | Absolute origin for canonical and og:url. Without it neither tag is written |
siteName | string | 'Documentation' | og:site_name and a description fallback |
locale | string | 'ru_RU' | og:locale |
fallbackDescription | string | ${siteName} documentation | Fallback used when the page has neither a frontmatter description nor a first paragraph |
emit | EmitToggles | all true | Family toggles: description, keywords, author, canonical, openGraph, twitter, jsonLd, robots, articleTime |
resolveTitle | (ctx) => string | -- | Hook over title resolution |
resolveDescription | (ctx) => string | -- | Hook over description resolution |
resolveKeywords | (ctx) => string[] | undefined | -- | Keywords |
resolveAuthor | (ctx) => string | undefined | -- | Author |
resolveImage | (ctx) => string | undefined | -- | Explicit og:image path |
resolveRobots | (ctx) => string | undefined | -- | robots value, overrides robots.default |
resolveOgImageData | (ctx) => Partial<...> | -- | Data for the image generator: tagline, theme, logo, logoSecondary |
extraHead | (ctx) => HeadConfig[] | -- | Custom tags appended at the end |
ogImage.default | string | -- | Site-wide fallback image |
ogImage.defaultWidth / defaultHeight | number | -- | Fallback image size |
ogImage.generate.template(data) | (data: OgImageData) => string | -- | Returns an SVG string rendered to PNG by sharp. Without sharp the same SVG is written |
ogImage.generate.output | string | 'og/{slug}.png' | Template of the OG image path in outDir |
ogImage.generate.width / height | number | 1200 / 630 | Size of the generated image |
ogImage.generate.filter | (ctx) => boolean | always true | Lets you skip pages (for example, 404) |
ogImage.generate.fontDirs | string[] | [] | Font directories for the renderer. See below |
robots.default | string | 'index,follow' | Default robots string |
twitter.site / creator | string | -- | twitter:site / twitter:creator |
pwa | PwaOptions | -- | Icons/manifest for PWA, auto-generated from one source SVG |
Built-in templates
productTemplate and minimalTemplate are imported from the package root. Both read data.theme (bg, bgAccent, fg, muted, accent, fontFamily) so a consuming theme can rebrand without forking the template:
import { productTemplate } from '@ampernic/vitepress-plugin-meta'
MetaPlugin({
ogImage: {
generate: {
template: productTemplate,
output: 'og/{slug}.png',
filter: (ctx) => !ctx.pageData.relativePath.startsWith('404'),
},
},
resolveOgImageData: () => ({
theme: {
bg: '#0d1117',
accent: '#fa814c',
fontFamily: 'Inter, sans-serif',
},
}),
})Order of work
transformPageDatareads the source page (respecting vitepressrewrites), resolves fields viaresolveAll, buildsheadviaheadBuilder, appends it to the page frontmatter.- Existing head entries with the same
name/property/relare preserved: side-by-side frontmatter wins over autogeneration. - For every page that passes the OG generator
filter, a record is accumulated; onbuildEndit becomes a PNG (withsharp) or SVG (without). - When
pwa.generateis set, the samebuildEndgenerates icons andsite.webmanifestinsideoutDir.
Notes
hostnamecannot be skipped when the site wants validog:urlandcanonical.articleTimeis derived from the source file git metadata; disable it withemit.articleTimewhen the repository lacks commit history.- The OG image is not re-rendered when
resolveImagereturned a ready URL: that URL is used asog:imagedirectly. - For canonical URLs with a locale, account for
vitepress.rewrites:ru/index.mdis rendered at/, and the plugin already applies the direct rewrite.
Fonts in the image
sharp resolves families through the system fontconfig and ignores@font-face inside the SVG. A face that is not installed on the build machine is therefore replaced by the first match available, and the image drifts away from the brand.
fontDirs adds directories to that lookup:
ogImage: {
generate: {
template: myTemplate,
fontDirs: ['/path/to/fonts'],
},
}The plugin writes a temporary config that includes the system one, so ordinary fallback keeps working. Directories are registered once per process, before the first text render.
Supply static TrueType faces. A variable font is indexed under its default instance name (Montserrat Thin), so a request for the base family misses it.