Skip to content

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

bash
pnpm add @ampernic/vitepress-plugin-meta

sharp is optional, only needed for OG image generation:

bash
pnpm add -D sharp

The 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:

ts
// .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

OptionTypeDefaultDescription
hostnamestring--Absolute origin for canonical and og:url. Without it neither tag is written
siteNamestring'Documentation'og:site_name and a description fallback
localestring'ru_RU'og:locale
fallbackDescriptionstring${siteName} documentationFallback used when the page has neither a frontmatter description nor a first paragraph
emitEmitTogglesall trueFamily 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.defaultstring--Site-wide fallback image
ogImage.defaultWidth / defaultHeightnumber--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.outputstring'og/{slug}.png'Template of the OG image path in outDir
ogImage.generate.width / heightnumber1200 / 630Size of the generated image
ogImage.generate.filter(ctx) => booleanalways trueLets you skip pages (for example, 404)
ogImage.generate.fontDirsstring[][]Font directories for the renderer. See below
robots.defaultstring'index,follow'Default robots string
twitter.site / creatorstring--twitter:site / twitter:creator
pwaPwaOptions--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:

ts
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

  1. transformPageData reads the source page (respecting vitepress rewrites), resolves fields via resolveAll, builds head via headBuilder, appends it to the page frontmatter.
  2. Existing head entries with the same name / property / rel are preserved: side-by-side frontmatter wins over autogeneration.
  3. For every page that passes the OG generator filter, a record is accumulated; on buildEnd it becomes a PNG (with sharp) or SVG (without).
  4. When pwa.generate is set, the same buildEnd generates icons and site.webmanifest inside outDir.

Notes

  • hostname cannot be skipped when the site wants valid og:url and canonical.
  • articleTime is derived from the source file git metadata; disable it with emit.articleTime when the repository lacks commit history.
  • The OG image is not re-rendered when resolveImage returned a ready URL: that URL is used as og:image directly.
  • For canonical URLs with a locale, account for vitepress.rewrites: ru/index.md is 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:

ts
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.