@m-media/vue3-meta-tags
Version:
Add support for meta tags in your Vue3 Router
281 lines (244 loc) • 7.93 kB
text/typescript
import type { RouteLocationNormalized, Router } from "vue-router";
import type { App } from "vue";
export const RTL_LOCALES = [
"ar",
"arc",
"dv",
"fa",
"ha",
"he",
"khw",
"ks",
"ku",
"ps",
"ur",
"yi",
];
const preconnect = [] as string[];
let defaultName = "Web App";
let defaultLocale = "en_US";
let locales: string[] = [];
let textCallback = null as ((text: string) => string) | null;
// Taken from https://www.digitalocean.com/community/tutorials/vuejs-vue-router-modify-head
export const setMetaAttributes = (
to: RouteLocationNormalized,
from: RouteLocationNormalized
) => {
if (typeof to.meta.description === "string") {
setDescription(setText(to.meta.description));
}
if (typeof to.meta.image === "string") {
updateOrCreateMetaTag("og:image", to.meta.image, "meta", "property");
updateOrCreateMetaTag("twitter:image", to.meta.image);
}
updateOrCreateMetaTag("og:site_name", defaultName);
if (typeof to.meta.locale === "string") {
setLocale(to.meta.locale ?? defaultLocale);
} else {
setLocale(defaultLocale);
}
setFollow(true);
setCurrentUrl();
updateOrCreateSchema();
setPreconnect(preconnect);
setLocaleAlternate(locales);
// This goes through the matched routes from last to first, finding the closest route with a title.
// e.g., if we have `/some/deep/nested/route` and `/some`, `/deep`, and `/nested` have titles,
// `/nested`'s will be chosen.
const nearestWithTitle = to.matched
.slice()
.reverse()
.find((r) => r.meta && r.meta.title);
// Find the nearest route element with meta tags.
const nearestWithMeta = to.matched
.slice()
.reverse()
.find((r) => r.meta && r.meta.metaTags);
const previousNearestWithMeta = from.matched
.slice()
.reverse()
.find((r) => r.meta && r.meta.metaTags);
// If a route with a title was found, set the document (page) title to that value.
if (nearestWithTitle && typeof nearestWithTitle.meta.title === "string") {
setTitle(setText(nearestWithTitle.meta.title));
} else if (
previousNearestWithMeta &&
typeof previousNearestWithMeta.meta.title === "string"
) {
setTitle(setText(previousNearestWithMeta.meta.title));
} else if (typeof to.name === "string") {
setTitle(
setText(to.name) + " - " + defaultName
);
} else {
setTitle(defaultName);
}
// Remove any stale meta tags from the document using the key attribute we set below.
Array.from(document.querySelectorAll("[data-vue-router-controlled]")).map(
(el) => el.parentNode?.removeChild(el)
);
// Skip rendering meta tags if there are none.
if (!nearestWithMeta || !(nearestWithMeta.meta.metaTags instanceof Array))
return;
// Turn the meta tag definitions into actual elements in the head.
nearestWithMeta.meta.metaTags
.map((tagDef: { [x: string]: string }) => {
const tag = document.createElement("meta");
Object.keys(tagDef).forEach((key) => {
tag.setAttribute(key, tagDef[key]);
});
// We use this to track which meta tags we create so we don't interfere with other ones.
tag.setAttribute("data-vue-router-controlled", "");
return tag;
})
// Add the meta tags to the document head.
.forEach((tag: HTMLMetaElement) => document.head.appendChild(tag));
};
export const setLocaleToUse = (locale: string) => {
defaultLocale = locale;
};
export const setTitle = (title: string) => {
document.title = title;
updateOrCreateMetaTag("og:title", title, "meta", "property");
updateOrCreateMetaTag("twitter:title", title);
};
export const setFollow = (follow = true) => {
updateOrCreateMetaTag("robots", follow ? "index,follow" : "noindex,nofollow");
updateOrCreateMetaTag(
"googlebot",
follow ? "index,follow" : "noindex,nofollow"
);
};
export const setDescription = (description: string) => {
updateOrCreateMetaTag("description", description);
updateOrCreateMetaTag("og:description", description, "meta", "property");
updateOrCreateMetaTag("twitter:description", description);
};
export const setCurrentUrl = (url = null as null | string) => {
// If the URL is null, set it to the current URL.
if (url === null) {
url = window.location.href;
}
updateOrCreateMetaTag("og:url", url);
updateOrCreateMetaTag("twitter:url", url);
};
export const updateOrCreateMetaTag = (
tagName: string,
content: string,
type = "meta",
tagNameSelector = "name",
contentSelector = "content",
additionalAttributes = [] as { name: string; value: string }[]
) => {
const metaTag = document.querySelector(
`${type}[${tagNameSelector}="${tagName}"][${contentSelector}]`
);
if (metaTag) {
metaTag.setAttribute(contentSelector, content);
additionalAttributes.forEach((attribute) => {
metaTag.setAttribute(attribute.name, attribute.value);
});
} else {
const newMetaTag = document.createElement(type);
newMetaTag.setAttribute(tagNameSelector, tagName);
newMetaTag.setAttribute(contentSelector, content);
additionalAttributes.forEach((attribute) => {
newMetaTag.setAttribute(attribute.name, attribute.value);
});
document.head.appendChild(newMetaTag);
}
};
export const setDefaultSchema = () => {
updateOrCreateSchema({
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: defaultName,
applicationCategory: "BrowserApplication",
offers: {
"@type": "Offer",
price: "0",
},
});
};
export const updateOrCreateSchema = (json = null as null | Object) => {
if (!json) {
return setDefaultSchema();
}
const schema = document.querySelector("script[type='application/ld+json']");
if (schema) {
schema.innerHTML = JSON.stringify(json);
} else {
const newSchema = document.createElement("script");
newSchema.setAttribute("type", "application/ld+json");
newSchema.innerHTML = JSON.stringify(json);
document.head.appendChild(newSchema);
}
};
export const setLocale = (locale: string) => {
updateOrCreateMetaTag("og:locale", locale, "meta", "property");
document.documentElement.lang = locale;
document.documentElement.dir = RTL_LOCALES.includes(locale) ? "rtl" : "ltr";
};
export const setLocaleAlternate = (locales: string[]) => {
updateOrCreateMetaTag("x-default", "alternate", "link", "hreflang", "rel", [
{
name: "href",
value: window.location.href,
},
]);
locales.forEach((locale) => {
updateOrCreateMetaTag(locale, "alternate", "link", "hreflang", "rel", [
{
name: "href",
value: window.location.href,
},
]);
});
};
export const setPreconnect = (urls: string[]) => {
urls.forEach((url) => {
const preconnectLink = document.querySelector(
`link[rel="preconnect"][href="${url}"]`
);
if (!preconnectLink) {
const newPreconnect = document.createElement("link");
newPreconnect.setAttribute("rel", "preconnect");
newPreconnect.setAttribute("href", url);
document.head.appendChild(newPreconnect);
}
});
};
export const setText = (text: string) => {
if (!textCallback) {
return text;
} else {
return textCallback(text);
}
};
export const setupMetaTagsHandler = (router: Router) => {
router.afterEach((to, from, failure) => {
if (!failure && !to.meta.skipMetaTagsHandler) {
setMetaAttributes(to, from);
}
});
};
export const metaTagPlugin = {
install: (app: App, options: any, router: Router) => {
if (options.preconnect) {
preconnect.push(...options.preconnect);
}
if (options.defaultLocale) {
setLocaleToUse(options.defaultLocale);
}
if (options.textCallback) {
textCallback = options.textCallback;
}
if (options.locales) {
locales = options.locales;
}
if (options.defaultName) {
defaultName = options.defaultName;
}
setupMetaTagsHandler(router);
},
};