diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index cfb87e63e036..c72628446b84 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,11 +1,17 @@ -import { defineConfig } from '../../src/node' +import { defineConfig, HeadConfig, PageData } from '../../src/node' + +const lang = 'en-US'; +const title = 'VitePress'; +const description = 'Vite & Vue powered static site generator.'; export default defineConfig({ - lang: 'en-US', - title: 'VitePress', - description: 'Vite & Vue powered static site generator.', + lang, + title, + description, lastUpdated: true, + head: getHead, + themeConfig: { repo: 'vuejs/vitepress', docsDir: 'docs', @@ -92,3 +98,34 @@ function getConfigSidebar() { } ] } + +function getHead(pageData: PageData): HeadConfig[] { + + const site = 'https://vitepress.vuejs.org/'; + const canonicalURL = `${site}${pageData.relativePath}`.replace(/.md$/, '.html'); + + return [ + // Twitter + ['meta', { name: 'twitter:card', content: 'summary_large_image' }], + ['meta', { name: 'twitter:site', content: '@vuejs' }], + ['meta', { name: 'twitter:title', content: pageData.title }], + ['meta', { name: 'twitter:description', content: pageData.description || description }], + + // Open Graph + ['meta', { property: 'og:type', content: 'website' }], + ['meta', { property: 'og:locale', content: lang }], + ['meta', { property: 'og:site', content: site }], + ['meta', { property: 'og:site_name', content: 'VitePress' }], + ['meta', { property: 'og:title', content: pageData.title }], + ['meta', { property: 'og:description', content: pageData.description || description }], + + // Canonical + [ + 'link', + { + rel: 'canonical', + href: canonicalURL, + }, + ] + ] +} \ No newline at end of file diff --git a/src/client/app/composables/head.ts b/src/client/app/composables/head.ts index b1f92529ae97..878d9da7d5d0 100644 --- a/src/client/app/composables/head.ts +++ b/src/client/app/composables/head.ts @@ -1,5 +1,5 @@ import { watchEffect, Ref } from 'vue' -import { HeadConfig, SiteData } from '../../shared' +import { HeadConfig, processHead, SiteData } from '../../shared' import { Route } from '../router' export function useUpdateHead(route: Route, siteDataByRouteRef: Ref) { @@ -58,7 +58,6 @@ export function useUpdateHead(route: Route, siteDataByRouteRef: Ref) { const siteData = siteDataByRouteRef.value const pageTitle = pageData && pageData.title const pageDescription = pageData && pageData.description - const frontmatterHead = pageData && pageData.frontmatter.head // update title and description document.title = (pageTitle ? pageTitle + ` | ` : ``) + siteData.title @@ -66,11 +65,7 @@ export function useUpdateHead(route: Route, siteDataByRouteRef: Ref) { .querySelector(`meta[name=description]`)! .setAttribute('content', pageDescription || siteData.description) - updateHeadTags([ - // site head can only change during dev - ...(import.meta.env.DEV ? siteData.head : []), - ...(frontmatterHead ? filterOutHeadDescription(frontmatterHead) : []) - ]) + updateHeadTags(processHead(siteData.head, pageData)); }) } @@ -84,15 +79,3 @@ function createHeadElement([tag, attrs, innerHTML]: HeadConfig) { } return el } - -function isMetaDescription(headConfig: HeadConfig) { - return ( - headConfig[0] === 'meta' && - headConfig[1] && - headConfig[1].name === 'description' - ) -} - -function filterOutHeadDescription(head: HeadConfig[]) { - return head.filter((h) => !isMetaDescription(h)) -} diff --git a/src/node/build/render.ts b/src/node/build/render.ts index 88fef6433670..7f16238c409c 100644 --- a/src/node/build/render.ts +++ b/src/node/build/render.ts @@ -1,7 +1,7 @@ import path from 'path' import fs from 'fs-extra' import { SiteConfig, resolveSiteDataByRoute } from '../config' -import { HeadConfig } from '../shared' +import { HeadConfig, processHead } from '../shared' import { normalizePath, transformWithEsbuild } from 'vite' import { RollupOutput, OutputChunk, OutputAsset } from 'rollup' import { slash } from '../utils/slash' @@ -97,11 +97,7 @@ export async function renderPage( ? `${pageData.title} | ${siteData.title}` : siteData.title - const head = addSocialTags( - title, - ...siteData.head, - ...filterOutHeadDescription(pageData.frontmatter.head) - ) + const head = processHead(siteData.head, pageData) let inlinedScript = '' if (config.mpa && result) { @@ -207,29 +203,3 @@ function renderAttrs(attrs: Record): string { }) .join('') } - -function isMetaDescription(headConfig: HeadConfig) { - const [type, attrs] = headConfig - return type === 'meta' && attrs?.name === 'description' -} - -function filterOutHeadDescription(head: HeadConfig[] | undefined) { - return head ? head.filter((h) => !isMetaDescription(h)) : [] -} - -function hasTag(head: HeadConfig[], tag: HeadConfig) { - const [tagType, tagAttrs] = tag - const [attr, value] = Object.entries(tagAttrs)[0] // First key - return head.some(([type, attrs]) => type === tagType && attrs[attr] === value) -} - -function addSocialTags(title: string, ...head: HeadConfig[]) { - const tags: HeadConfig[] = [ - ['meta', { name: 'twitter:title', content: title }], - ['meta', { property: 'og:title', content: title }] - ] - tags.filter((tagAttrs) => { - if (!hasTag(head, tagAttrs)) head.push(tagAttrs) - }) - return head -} diff --git a/src/node/config.ts b/src/node/config.ts index a9277b85dc3d..d9edededfdaa 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -12,6 +12,7 @@ import { import { Options as VuePluginOptions } from '@vitejs/plugin-vue' import { SiteData, + PageData, HeadConfig, LocaleConfig, createLangDictionary, @@ -33,7 +34,7 @@ export interface UserConfig { base?: string title?: string description?: string - head?: HeadConfig[] + head?: HeadConfig[] | ((pageData: PageData) => HeadConfig[]) themeConfig?: ThemeConfig locales?: Record markdown?: MarkdownOptions diff --git a/src/node/index.ts b/src/node/index.ts index bc08b91b257f..458e26a466e5 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -7,6 +7,7 @@ export * from './markdown/markdown' // shared types export type { SiteData, + PageData, HeadConfig, Header, LocaleConfig, diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 64d78615fb2a..8999bab0df82 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -1,4 +1,9 @@ -import { LocaleConfig, SiteData } from '../../types/shared' +import { + HeadConfig, + LocaleConfig, + SiteData, + PageData +} from '../../types/shared' export type { SiteData, @@ -95,3 +100,15 @@ function cleanRoute(siteData: SiteData, route: string): string { return route.slice(baseWithoutSuffix.length) } + +/** + * Process `head` configuration. + */ +export function processHead( + head: HeadConfig[] | ((pageData: PageData) => HeadConfig[]), + pageData: PageData +): HeadConfig[] { + const combineHead = !head ? [] : typeof head === 'function' ? head(pageData) : head + const frontmatterHead = pageData && pageData.frontmatter.head + return [...combineHead, ...(frontmatterHead || [])] +} diff --git a/types/shared.d.ts b/types/shared.d.ts index 645c0114e9b3..05cede052b4d 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -20,7 +20,7 @@ export interface SiteData { lang: string title: string description: string - head: HeadConfig[] + head: HeadConfig[] | ((pageData: PageData) => HeadConfig[]) themeConfig: ThemeConfig scrollOffset: number | string locales: Record