How to add metadata, canonical URLs, and structured data to your VuePress site
Oftentimes when utilizing a framework or other packaged codebase (ahem, such as VuePress), you have to get creative in order to get the exact functionality you're looking for. At the time of writing, VuePress is a bit limited as to what types of metadata and other structured data can be added out of the box to your pages for
In this post, I'll outline the solutions I came up with to add metadata, canonical URLs, and structured data to my site.
Why add additional metadata, structured data, and canonical URLs?
All websites should take care to incorporate metadata in order to make finding the page via search engines and other sites as easy as possible. This includes page meta tags, Schema.org structured data, Open Graph tags, and Twitter Card tags. For sites that are not pre-rendered and run as an SPA, this content is even more important since the page is initially loaded as an empty container (meaning search indexing bots don't have much to look at). This metadata helps to determine whether your page is relevant enough to display in search results, and can be used to give users a preview of the content they will find on your website.
All sites should also include a canonical URL link tag in the <head>
of the page. Canonical URLs are a technical solution that essentially tells search engines which URL to send traffic to for content that it deems worthy as a search result. Another way to think of it is the canonical URL is the preferred URL for the content on the page.
How does VuePress handle metadata?
VuePress serves pre-rendered static HTML pages (which is way better); however, generating all of the desired tags and metadata is still, well, mostly a manual process.
Adding page metadata is supported; however, without using a plugin, the information must be defined in the .vuepress/config.js
file, or has to be hard-coded into the YAML frontmatter block at the top of each individual page (the data cannot be dynamically generated in the YAML block). This creates a problem if, like me, you're kind of lazy and don't like doing the same task over and over. 🙄
At the time of writing, VuePress does not have a default way to add canonical URL tags onto a page. VuePress 1.7.1
supports adding the canonical URL to each page via frontmatter.canonicalUrl
! 🎉
In order to add the desired metadata and other tags to my site, I needed to hook into the VuePress Options API and compile process so that I could access all of the data available in my posts and pages. Some of the data is also sourced from custom themeConfig
properties outlined below. Since I couldn't find much information available on the web as to how to solve the issue, I figured I'd write it all up in a post in case anyone is looking to build a similar solution.
Now that you understand why this extra data should be included, let's walk through how you can replicate the functionality in your own project!
Add metadata to the VuePress page object with a plugin
To dynamically add our tags into the site during the build, we will take advantage of the extendPageData
property of the VuePress Option API. This option allows you to extend or edit the $page
object and is invoked once for each page at compile time, meaning we can directly add corresponding data to each unique page.
I have thought about releasing this plugin as an open-source package; however, at the time of writing, it's likely more helpful to manually install into your project on your own since customizing the desired tags and data really varies depending on the content and structure of your site.
The VuePress plugin included below is geared towards a site similar to this one, meaning most of the properties relate to a single author, and a site about a single person. It should be fairly easy to customize the tags you would like to use as well as their values.
Install dependencies
The plugin utilizes dayjs to parse, validate, manipulate, and displays dates. You'll need to install dayjs
in your project in order to utilize the code that follows (or modify to substitute another date library).
Update the VuePress themeConfig
In order to feed all the valid data into the plugin, you will need to add some additional properties to the themeConfig
object in your .vuepress/config.js
file that will help extend the data for each page. If there are properties listed below that are unneeded for your particular project, you should be able to simply leave them out (or alternatively just pass a null
value).
// .vuepress/config.js
module.exports = {
// I'm only showing the themeConfig properties needed for the plugin
// your site likely has many more
title: 'Back to the Future', // The title of your site
themeConfig: {
domain: 'https://www.example.com', // Base URL of the VuePress site
// Absolute path to the main image preview for the site
// (Example location: ./vuepress/public/img/default-image.jpg)
defaultImage: '/img/default-image.jpg',
personalInfo: {
name: 'Marty McFly', // Your full name
email: 'marty@thepinheads.com', // Your email address
website: 'https://www.example.com/', // Your website
avatar: '/img/avatar.jpg', // Path to avatar/image
company: 'Twin Pines Mall', // Employer
title: 'Lead Guitarist', // Job title
about: 'https://www.example.com/about/', // Link to page about the author
gender: 'male', // Gender of author (or exclude if unwanted)
social: [
// Add an object for each of your social media sites
// You may include others (pinterest, linkedin, etc.) just
// add the objects to the array, following the same format
{
title: 'GitHub', // Social Site title
// I use https://github.com/Justineo/vue-awesome for FontAwesome icons
// you can omit this property if not needed
icon: 'brands/github',
account: 'username', // Your username at the site
url: 'https://github.com/username', // Your profile/page URL on the site
},
{
title: 'Twitter',
icon: 'brands/twitter',
// Your username at the site; do not include the @ sign for Twitter
account: 'username',
url: 'https://twitter.com/username',
},
{
title: 'Instagram',
icon: 'brands/instagram',
account: 'username',
url: 'https://instagram.com/username',
},
],
},
// .. More themeConfig properties...
},
}
Add page frontmatter properties
The plugin will utilize several required frontmatter properties, including title, description, image, etc. as shown in the block below to help extend the data of each page.
To allow the canonical URL to be utilized by both our plugin and our structured data component, you'll also need to manually add the canonicalUrl
property here to the frontmatter of each page. Unfortunately, the $page.path
is computed after plugins are initialized, so at the time of writing, manually adding the canonical URL to each page is the best solution.
To fully utilize the capabilities of the plugin, make sure you include all of the frontmatter properties shown below on each page of your site:
title: This is the page title
description: This is the page description that will be used
# Publish date of this page/post
date: 2020-08-22
# If using vuepress-plugin-blog, the category for the post
category: tutorials
# The list of tags for the post
tags:
- VuePress
- JavaScript
# Absolute path to the main image preview for this page
# Example location: ./vuepress/public/img/posts/page-image.jpg
image: /img/path/page-image.jpg
# Add canonical URL to the frontmatter of each page
# Make sure this is the final, permanent URL of the page
canonicalUrl: https://example.com/blog/path-to-this-page/
Pages on the site can also have additional tags and data added depending on your needs. If you have an About Me page, Contact page, or a Homepage (all included by default), or another "special" type of page you'd like to customize the tags for, simply add the corresponding entry shown below in only that page's frontmatter. Then, you can customize the plugin and structured data component by checking for the existence of the frontmatter page{NAME}
property.
# Homepage
pageHome: true
# About page
pageAbout: true
# Contact page
pageContact: true
# Other custom special page
pageCustomName: true
Add plugin file structure
Next, you will need to add the plugin directory (suggested) and source file. Modify your site's .vuepress
directory so that it includes the following:
.
|── config.js
└── .vuepress/
└── theme/
└── plugins/
└── dynamic-metadata.js
Add plugin code
Now let's add the dynamic metadata code to the new plugin file we created. The plugin extends the $page
object via the extendPageData
method of the VuePress Options API.
You need to copy and insert all of the code included below (click below to view the file content) into the dynamic-metadata.js
file we just created.
Click to view the contents of dynamic-metadata.js
const dayjs = require('dayjs')
const utc = require('dayjs/plugin/utc')
const timezone = require('dayjs/plugin/timezone')
dayjs.extend(utc)
dayjs.extend(timezone)
// Customize the value to your timezone
// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
dayjs.tz.setDefault('America/Kentucky/Louisville')
module.exports = (options = {}, ctx) => ({
extendPageData($page) {
const { frontmatter, path } = $page
const metadata = {
title: frontmatter.title
? frontmatter.title.toString().replace(/["|'|\\]/g, '')
: $page.title
? $page.title.toString().replace(/["|'|\\]/g, '')
: null,
description: frontmatter.description
? frontmatter.description
.toString()
.replace(/'/g, "'")
.replace(/["|\\]/g, '')
: null,
url:
frontmatter.canonicalUrl && typeof frontmatter.canonicalUrl === 'string'
? frontmatter.canonicalUrl.startsWith('http')
? frontmatter.canonicalUrl
: ctx.siteConfig.themeConfig.domain + frontmatter.canonicalUrl
: null,
image:
frontmatter.image && typeof frontmatter.image === 'string'
? frontmatter.image.startsWith('http')
? frontmatter.image
: ctx.siteConfig.themeConfig.domain + frontmatter.image
: null,
type: meta_isArticle(path) ? 'article' : 'website',
siteName: ctx.siteConfig.title || null,
siteLogo: ctx.siteConfig.themeConfig.domain + ctx.siteConfig.themeConfig.defaultImage,
published: frontmatter.date
? dayjs(frontmatter.date).toISOString()
: $page.lastUpdated
? dayjs($page.lastUpdated).toISOString()
: null,
modified: $page.lastUpdated ? dayjs($page.lastUpdated).toISOString() : null,
author: ctx.siteConfig.themeConfig.personalInfo ? ctx.siteConfig.themeConfig.personalInfo : null,
}
let meta_articleTags = []
if (meta_isArticle(path)) {
// Article info
meta_articleTags.push(
{
property: 'article:published_time',
content: metadata.published,
},
{
property: 'article:modified_time',
content: metadata.modified,
},
{
property: 'article:section',
content: frontmatter.category ? frontmatter.category.replace(/(?:^|\s)\S/g, (a) => a.toUpperCase()) : null,
},
{
property: 'article:author',
content: meta_isArticle(path) && metadata.author.name ? metadata.author.name : null,
},
)
// Article tags
// Todo: Currently, VuePress only injects the first tag
if (frontmatter.tags && frontmatter.tags.length) {
frontmatter.tags.forEach((tag, i) =>
meta_articleTags.push({
property: 'article:tag',
content: tag,
}),
)
}
}
let meta_profileTags = []
if (frontmatter.pageAbout && metadata.author.name) {
meta_profileTags.push(
{
property: 'profile:first_name',
content: metadata.author.name.split(' ')[0],
},
{
property: 'profile:last_name',
content: metadata.author.name.split(' ')[1],
},
{
property: 'profile:username',
content: metadata.author.social.find((s) => s.title.toLowerCase() === 'twitter').account
? '@' + metadata.author.social.find((s) => s.title.toLowerCase() === 'twitter').account
: null,
},
{
property: 'profile:gender',
content: metadata.author.gender ? metadata.author.gender : null,
},
)
}
let meta_dynamicMeta = [
// General meta tags
{ name: 'description', content: metadata.description },
{
name: 'keywords',
content: frontmatter.tags && frontmatter.tags.length ? frontmatter.tags.join(', ') : null,
},
{ itemprop: 'name', content: metadata.title },
{ itemprop: 'description', content: metadata.description },
{
itemprop: 'image',
content: metadata.image ? metadata.image : null,
},
// Open Graph
{ property: 'og:url', content: metadata.url },
{ property: 'og:type', content: metadata.type },
{ property: 'og:title', content: metadata.title },
{
property: 'og:image',
content: metadata.image ? metadata.image : null,
},
{
property: 'og:image:type',
content: metadata.image && meta_getImageMimeType(metadata.image) ? meta_getImageMimeType(metadata.image) : null,
},
{
property: 'og:image:alt',
content: metadata.image ? metadata.title : null,
},
{ property: 'og:description', content: metadata.description },
{ property: 'og:updated_time', content: metadata.modified },
// Article info (if meta_isArticle)
...meta_articleTags,
// Profile (if /about/ page)
...meta_profileTags,
// Twitter Cards
{ property: 'twitter:url', content: metadata.url },
{ property: 'twitter:title', content: metadata.title },
{ property: 'twitter:description', content: metadata.description },
{
property: 'twitter:image',
content: metadata.image ? metadata.image : null,
},
{ property: 'twitter:image:alt', content: metadata.title },
]
// Remove tags with empty content values
meta_dynamicMeta = meta_dynamicMeta.filter((meta) => meta.content && meta.content !== '')
// Combine frontmatter
meta_dynamicMeta = [...(frontmatter.meta || []), ...meta_dynamicMeta]
// Set frontmatter after removing duplicate entries
meta_dynamicMeta = getUniqueArray(meta_dynamicMeta, ['name', 'content', 'itemprop', 'property'])
frontmatter.meta = meta_dynamicMeta
},
})
/**
* Removes duplicate objects from an Array of JavaScript objects
* @param {Array} arr Array of Objects
* @param {Array} keyProps Array of keys to determine uniqueness
*/
function getUniqueArray(arr, keyProps) {
return Object.values(
arr.reduce((uniqueMap, entry) => {
const key = keyProps.map((k) => entry[k]).join('|')
if (!(key in uniqueMap)) uniqueMap[key] = entry
return uniqueMap
}, {}),
)
}
/**
* Returns boolean indicating if page is a blog post
* @param {String} path Page path
*/
function meta_isArticle(path) {
// Include path(s) where blog posts/articles are contained
return ['articles', 'posts', '_posts', 'blog'].some((folder) => {
let regex = new RegExp('^\\/' + folder + '\\/([\\w|-])+', 'gi')
// Customize /category/ and /tag/ (or other sub-paths) below to exclude, if needed
return regex.test(path) && path.indexOf(folder + '/category/') === -1 && path.indexOf(folder + '/tag/') === -1
})
? true
: false
}
/**
* Returns the meme type of an image, based on the extension
* @param {String} img Image path
*/
function meta_getImageMimeType(img) {
if (!img) {
return null
}
const regex = /\.([0-9a-z]+)(?:[\?#]|$)/i
if (Array.isArray(img.match(regex)) && ['png', 'jpg', 'jpeg', 'gif'].some((ext) => img.match(regex)[1] === ext)) {
return 'image/' + img.match(regex)[1]
} else {
return null
}
}
See the highlighted lines in the dynamic-metadata.js
file indicating where changes are likely necessary.
Initialize the plugin
Finally, in your .vuepress/theme/index.js
file, add the plugin reference as shown below (only relevant lines shown):
// .vuepress/theme/index.js
const path = require('path')
module.exports = (options, ctx) => {
const { themeConfig, siteConfig } = ctx
return {
plugins: [
// Ensure the path below matches where you saved the dynamic-metadata.js file
require(path.resolve(__dirname, './plugins/dynamic-metadata.js')),
],
}
}
Now that the plugin is initialized, the metadata portion of our solution is complete! 🎉
Preview your project by running the local VuePress server and you will now see the metadata update after each page change with all of the corresponding tags in the head
of the rendered HTML page.
Now we're ready to add the canonical URL to all pages.
Add the canonical URL to every page
Update As of VuePress version
1.7.1
(and thanks to help from me 🎉) the canonical URL can now be set in thefrontmatter
of your VuePress pages by providing acanonicalUrl
entry. Refer to the VuePress canonical URL documentation for more details.
Since we already added the canonical_url
property in the frontmatter of all of the pages on our VuePress site, we're ready to add the corresponding <link>
tag to the <head>
of each page.
To add the canonical URL, you will need to add some methods to the GlobalLayout.vue
file in your theme. If you do not utilize globalLayout
in your theme (see here for more details) you will need to add the following code to another file that is used on every page of your site (e.g. in a layout component).
Inside your layout component, we'll add a new method that will manipulate the DOM to add/update the canonical URL tag in the <head>
of the page. We will invoke this method both in the beforeMount
and mounted
hooks:
Click to view code for custom implementation
// Inside your layout component (preferrably GlobalLayout.vue)
beforeMount() {
// Add/update the canonical URL on initial load
this.updateCanonicalUrl()
},
mounted() {
// Update the canonical URL after navigation
this.$router.afterEach((to, from) => {
this.updateCanonicalUrl()
})
},
methods: {
updateCanonicalUrl() {
let canonicalUrl = document.getElementById('canonicalUrlLink')
// If the element already exists, update the value
if (canonicalUrl) {
canonicalUrl.href = this.$site.themeConfig.domain + this.$page.path
}
// Otherwise, create the element and set the value
else {
canonicalUrl = document.createElement('link')
canonicalUrl.id = 'canonicalUrlLink' // Ensure no other elements on your site use this ID. Customize as needed.
canonicalUrl.rel = 'canonical'
canonicalUrl.href = this.$site.themeConfig.domain + this.$page.path
document.head.appendChild(canonicalUrl)
}
},
},
With these methods now in place, the canonical URL will be added to the initial page before mount, and then subsequently updated on every following page when $router.afterEach
is called by Vue Router.
Next up, we will create a new component to utilize within our GlobalLayout.vue
file that will inject Schema.org structured data into all the pages on our VuePress site.
Add structured data to VuePress pages
To add structured data to our pages, we will create a new Vue component that will utilize both the same $page
data and frontmatter properties we previously added.
Create structured data component
Create a new SchemaStructuredData.vue
file in your project wherever you store components (likely in .vuepress/theme/components
). It should look something like this:
.
└── .vuepress/
└── theme/
└── components/
└── SchemaStructuredData.vue
Now let's add the code to the component file. You need to copy and insert all of the code included below (hidden for size) into the SchemaStructuredData.vue
file we just created.
Click to view the contents of SchemaStructuredData.vue
<template>
<script v-if="meta_structuredData" type="application/ld+json" v-html="meta_structuredData"></script>
</template>
<script>
import * as dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
dayjs.extend(utc)
dayjs.extend(timezone)
// Customize the value to your timezone (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List)
dayjs.tz.setDefault('America/Kentucky/Louisville')
export default {
name: 'SchemaStructuredData',
computed: {
meta_data() {
if (!this.$page || !this.$site) {
return
}
return {
title: this.$page.title ? this.$page.title.toString().replace(/["|'|\\]/g, '') : null,
description: this.$page.frontmatter.description
? this.$page.frontmatter.description.toString().replace(/["|'|\\]/g, '')
: null,
image: this.$page.frontmatter.image ? this.$site.themeConfig.domain + this.$page.frontmatter.image : null,
type: this.meta_isArticle ? 'article' : 'website',
siteName: this.$site.title || null,
siteLogo: this.$site.themeConfig.domain + this.$site.themeConfig.defaultImage,
published: dayjs(this.$page.frontmatter.date).toISOString() || dayjs(this.$page.lastUpdated).toISOString(),
modified: dayjs(this.$page.lastUpdated).toISOString(),
author: this.$site.themeConfig.personalInfo ? this.$site.themeConfig.personalInfo : null,
}
},
// If page is a blog post
meta_isArticle() {
// Include path(s) where blog posts/articles are contained
return ['articles', 'posts', '_posts', 'blog'].some((folder) => {
let regex = new RegExp('^\\/' + folder + '\\/([\\w|-])+', 'gi')
// Customize /category/ and /tag/ (or other sub-paths) below to exclude, if needed
return (
regex.test(this.$page.path) &&
this.$page.path.indexOf(folder + '/category/') === -1 &&
this.$page.path.indexOf(folder + '/tag/') === -1
)
})
? true
: false
},
// Generate canonical URL (requires additional themeConfig data)
meta_canonicalUrl() {
if (!this.$page.frontmatter.canonicalUrl || !this.$page.path || !this.$site.themeConfig.domain) {
return null
}
return this.$page.frontmatter.canonicalUrl
? this.$page.frontmatter.canonicalUrl
: this.$site.themeConfig.domain + this.$page.path
},
meta_sameAs() {
if (!this.meta_data.author.social || !this.meta_data.author.social.length) {
return []
}
let socialLinks = []
this.meta_data.author.social.forEach((s) => {
if (s.url) {
socialLinks.push(s.url)
}
})
return socialLinks
},
// Generate Schema.org data for 'Person' (requires additional themeConfig data)
schema_person() {
if (!this.meta_data.author || !this.meta_data.author.name) {
return null
}
return {
'@context': 'https://schema.org/',
'@type': 'Person',
'name': this.meta_data.author.name,
'url': this.$site.themeConfig.domain,
'image': this.meta_data.author.avatar ? this.$site.themeConfig.domain + this.meta_data.author.avatar : null,
'sameAs': this.meta_sameAs,
'jobTitle': this.meta_data.author.title || null,
'worksFor': {
'@type': 'Organization',
'name': this.meta_data.author.company || null,
},
}
},
// Inject Schema.org structured data
meta_structuredData() {
let structuredData = []
// Home Page
if (this.$page.frontmatter.pageHome) {
structuredData.push({
'@context': 'https://schema.org/',
'@type': 'WebSite',
'name':
this.meta_data.title + (this.$page.frontmatter.subtitle ? ' | ' + this.$page.frontmatter.subtitle : '') ||
null,
'description': this.meta_data.description || null,
'url': this.meta_canonicalUrl,
'image': {
'@type': 'ImageObject',
'url': this.$site.themeConfig.domain + this.$site.themeConfig.defaultImage,
},
'@id': this.meta_canonicalUrl,
})
}
// About Page
else if (this.$page.frontmatter.pageAbout) {
// Person
structuredData.push(this.schema_person)
// About Page
structuredData.push({
'@context': 'https://schema.org/',
'@type': 'AboutPage',
'name': this.meta_data.title || null,
'description': this.meta_data.description || null,
'url': this.meta_canonicalUrl,
'primaryImageOfPage': {
'@type': 'ImageObject',
'url': this.meta_data.image || null,
},
'image': {
'@type': 'ImageObject',
'url': this.meta_data.image || null,
},
'mainEntityOfPage': {
'@type': 'WebPage',
'@id': this.meta_canonicalUrl,
},
'author': this.schema_person || null,
})
// Breadcrumbs
structuredData.push({
'@context': 'https://schema.org/',
'@type': 'BreadcrumbList',
'itemListElement': [
{
'@type': 'ListItem',
'position': 1,
'name': 'Home',
'item': this.$site.themeConfig.domain || null,
},
{
'@type': 'ListItem',
'position': 2,
'name': this.meta_data.title || null,
'item': this.meta_canonicalUrl,
},
],
})
}
// Contact Page
else if (this.$page.frontmatter.pageContact) {
// Contact Page
structuredData.push({
'@context': 'https://schema.org/',
'@type': 'ContactPage',
'name': this.meta_data.title || null,
'description': this.meta_data.description || null,
'url': this.meta_canonicalUrl,
'primaryImageOfPage': {
'@type': 'ImageObject',
'url': this.meta_data.image || null,
},
'image': {
'@type': 'ImageObject',
'url': this.meta_data.image || null,
},
'mainEntityOfPage': {
'@type': 'WebPage',
'@id': this.meta_canonicalUrl,
},
'author': this.schema_person || null,
})
// Breadcrumbs
structuredData.push({
'@context': 'https://schema.org/',
'@type': 'BreadcrumbList',
'itemListElement': [
{
'@type': 'ListItem',
'position': 1,
'name': 'Home',
'item': this.$site.themeConfig.domain || null,
},
{
'@type': 'ListItem',
'position': 2,
'name': this.meta_data.title || null,
'item': this.meta_canonicalUrl,
},
],
})
}
// Article
else if (this.meta_isArticle) {
structuredData.push({
'@context': 'https://schema.org/',
'@type': 'Article',
'name': this.meta_data.title || null,
'description': this.meta_data.description || null,
'url': this.meta_canonicalUrl,
'discussionUrl': this.meta_canonicalUrl + '#comments',
'mainEntityOfPage': {
'@type': 'WebPage',
'@id': this.meta_canonicalUrl,
},
'headline': this.meta_data.title || null,
'articleSection': this.$page.frontmatter.category
? this.$page.frontmatter.category.replace(/(?:^|\s)\S/g, (a) => a.toUpperCase())
: null,
'keywords': this.$page.frontmatter.tags || [],
'image': {
'@type': 'ImageObject',
'url': this.meta_data.image || null,
},
'author': this.schema_person || null,
'publisher': {
'@type': 'Organization',
'name': this.meta_data.author.name || '',
'url': this.$site.themeConfig.domain || null,
'logo': {
'@type': 'ImageObject',
'url': this.meta_data.siteLogo || null,
},
},
'datePublished': dayjs(this.meta_data.published).toISOString() || null,
'dateModified': dayjs(this.meta_data.modified).toISOString() || null,
'copyrightHolder': this.schema_person || null,
'copyrightYear':
dayjs(this.meta_data.published).format('YYYY') || dayjs(this.meta_data.modified).format('YYYY'),
})
// Breadcrumbs
structuredData.push({
'@context': 'https://schema.org/',
'@type': 'BreadcrumbList',
'itemListElement': [
{
'@type': 'ListItem',
'position': 1,
'name': 'Home',
'item': this.$site.themeConfig.domain || null,
},
{
'@type': 'ListItem',
'position': 2,
'name': 'Blog',
'item': this.$site.themeConfig.domain + '/blog/',
},
{
'@type': 'ListItem',
'position': 3,
'name': this.meta_data.title || null,
'item': this.meta_canonicalUrl,
},
],
})
}
// Blog Index
else if (this.$page.path === '/blog/') {
// Breadcrumbs
structuredData.push({
'@context': 'https://schema.org/',
'@type': 'BreadcrumbList',
'itemListElement': [
{
'@type': 'ListItem',
'position': 1,
'name': 'Home',
'item': this.$site.themeConfig.domain || null,
},
{
'@type': 'ListItem',
'position': 2,
'name': this.meta_data.title || null,
'item': this.meta_canonicalUrl,
},
],
})
}
// Blog Category or Tag Page
else if (this.$page.path === '/blog/category/' || this.$page.path === '/blog/tag/') {
// Breadcrumbs
structuredData.push({
'@context': 'https://schema.org/',
'@type': 'BreadcrumbList',
'itemListElement': [
{
'@type': 'ListItem',
'position': 1,
'name': 'Home',
'item': this.$site.themeConfig.domain || null,
},
{
'@type': 'ListItem',
'position': 2,
'name': 'Blog',
'item': this.$site.themeConfig.domain + '/blog/',
},
{
'@type': 'ListItem',
'position': 3,
'name': this.meta_data.title || null,
'item': this.meta_canonicalUrl,
},
],
})
}
// Inject webpage for all pages
structuredData.push({
'@context': 'https://schema.org/',
'@type': 'WebPage',
'name': this.meta_data.title || null,
'headline': this.meta_data.title || null,
'description': this.meta_data.description || null,
'url': this.meta_canonicalUrl,
'mainEntityOfPage': {
'@type': 'WebPage',
'@id': this.meta_canonicalUrl,
},
'keywords': this.$page.frontmatter.tags || [],
'primaryImageOfPage': {
'@type': 'ImageObject',
'url': this.meta_data.image || null,
},
'image': {
'@type': 'ImageObject',
'url': this.meta_data.image || null,
},
'author': this.schema_person || null,
'publisher': {
'@type': 'Organization',
'name': this.meta_data.author.name || '',
'url': this.$site.themeConfig.domain || null,
'logo': {
'@type': 'ImageObject',
'url': this.meta_data.siteLogo || null,
},
},
'datePublished': dayjs(this.meta_data.published).toISOString() || null,
'dateModified': dayjs(this.meta_data.modified).toISOString() || null,
'lastReviewed': dayjs(this.meta_data.modified).toISOString() || null,
'copyrightHolder': this.schema_person || null,
'copyrightYear':
dayjs(this.meta_data.published).format('YYYY') || dayjs(this.meta_data.modified).format('YYYY'),
})
return JSON.stringify(structuredData, null, 4)
},
},
}
</script>
See the highlighted lines in the SchemaStructuredData.vue
file above indicating where customizations are likely necessary.
Utilize the component on each page of your site
To ensure our SchemaStructuredData.vue
component adds data to every page, we will import it into our theme's GlobalLayout.vue
file. If you do not utilize globalLayout
in your theme (see here for more details) you should import into another layout file that is used on every page of your site (e.g. in a footer component).
Below, I'll show you how to import the component. Notice the :key
property on the component, which assists Vue in knowing when the content in the component is stale. Only the relevant code is included:
<template>
<div class="your-layout-component">
<!-- Other layout code... -->
<SchemaStructuredData :key="$page.path"></SchemaStructuredData>
</div>
</template>
<script>
// Ensure the path matches the location of the component in your project
import SchemaStructuredData from '@theme/components/SchemaStructuredData.vue'
export default {
name: 'YourLayoutComponent',
components: {
SchemaStructuredData,
},
}
</script>
Once the component is in place, every page on our site will be dynamically updated with the corresponding structured data for search engines and other site scrapers to parse!
That's a wrap!
After implementing the solutions above, your site should now be prepped and ready for search engines and social media sites alike.
I really can't guess how every site would utilize the different aspects of this solution -- if you've made it this far, you likely already know what you're doing -- but if you need help with implementation, customizing the code to meet your needs, or have suggestions, reach out on twitter @adamdehaven.
Maybe in a future version of VuePress, some of this functionality will be baked right in. In the meantime, stay safe, stay healthy, and keep learning!