metafy-seo
Version:
Lightweight, dependency-free React components and utilities for managing SEO metadata (meta tags, Open Graph, Twitter Cards) with optional SSR markup generation.
407 lines (400 loc) • 14.4 kB
JavaScript
'use strict';
var react = require('react');
function upsertTag(head, added, tag, attrs, contentAttr) {
if (contentAttr === void 0) { contentAttr = 'content'; }
// build a selector like: meta[name="foo"][content="bar"]
var selector = tag +
Object.entries(attrs)
.map(function (_a) {
var key = _a[0], val = _a[1];
return "[".concat(key, "=\"").concat(val, "\"]");
})
.join('');
var el = head.querySelector(selector);
if (!el) {
el = document.createElement(tag);
// set all attributes
for (var _i = 0, _a = Object.entries(attrs); _i < _a.length; _i++) {
var _b = _a[_i], k = _b[0], v = _b[1];
el.setAttribute(k, v);
}
head.appendChild(el);
added.push(el);
}
else if (attrs[contentAttr]) {
// update content or href
el.setAttribute(contentAttr, attrs[contentAttr]);
}
return el;
}
/**
* React component that injects SEO tags into the document <head>.
*
* It handles:
* - title, meta[name*], link[rel=canonical]
* - Open Graph and Twitter Card tags
* - custom extraMeta and extraLinks entries
*
* @remarks
* Uses a cleanup effect to remove tags when this component unmounts.
*
* @example
* <SeoTags
* title="My Page"
* description="Page description"
* canonical="/my-page"
* openGraph={{ url: '/my-page', title: 'My Page' }}
* twitter={{ card: 'summary', site: '@mysite' }}
* />
*
* @param config - SEO configuration object (see SeoConfig).
* @returns `null` (manipulates <head> as side effect).
*/
var SeoTags = function (config) {
react.useEffect(function () {
var _a, _b;
var head = document.head;
// Track newly appended elements so we can clean up on unmount
var added = [];
/**
* Helper to upsert a <meta> tag.
* @param attrs - key/value map of attributes for the <meta>.
*/
var addMeta = function (attrs) {
return upsertTag(head, added, 'meta', attrs);
};
/**
* Helper to upsert a <link> tag.
* @param attrs - key/value map (must include href).
*/
var addLink = function (attrs) {
return upsertTag(head, added, 'link', attrs, 'href');
};
// 1) Title tag
// Try to find existing <head><title>; otherwise create one
var titleEl = document.querySelector('head > title');
if (!titleEl && config.title) {
titleEl = document.createElement('title');
head.appendChild(titleEl);
added.push(titleEl);
}
// Update title text if provided
if (titleEl && config.title) {
titleEl.textContent = config.title;
}
// 2) Core meta & link
// Insert standard SEO tags if those config fields exist
config.description && addMeta({ name: 'description', content: config.description });
config.keywords && addMeta({ name: 'keywords', content: config.keywords.join(',') });
config.robots && addMeta({ name: 'robots', content: config.robots });
config.viewport && addMeta({ name: 'viewport', content: config.viewport });
config.themeColor && addMeta({ name: 'theme-color', content: config.themeColor });
config.canonical && addLink({ rel: 'canonical', href: config.canonical });
config.author && addMeta({ name: 'author', content: config.author });
config.publisher && addMeta({ name: 'publisher', content: config.publisher });
config.rating && addMeta({ name: 'rating', content: config.rating });
config.revisitAfter && addMeta({ name: 'revisit-after', content: config.revisitAfter });
config.language && addMeta({ name: 'language', content: config.language });
// 3) OpenGraph (typed via keyof)
if (config.openGraph) {
// Cast to our OpenGraph interface to preserve keyof type safety
var og_1 = config.openGraph;
Object.keys(og_1).forEach(function (key) {
var val = og_1[key];
if (!val)
return;
if (key === 'images' && Array.isArray(val)) {
// Handle array of images specially
val.forEach(function (img) {
addMeta({ property: 'og:image', content: img.url });
img.alt && addMeta({ property: 'og:image:alt', content: img.alt });
});
}
else {
// Standard OG properties (og:title, og:url, etc.)
addMeta({ property: "og:".concat(key), content: String(val) });
}
});
}
// 4) Twitter (typed via keyof)
if (config.twitter) {
var tw_1 = config.twitter;
Object.keys(tw_1).forEach(function (key) {
var val = tw_1[key];
if (val)
addMeta({ name: "twitter:".concat(key), content: String(val) });
});
}
// 5) Extras
// Allow arbitrary <meta> and <link> entries from extraMeta/extraLinks
(_a = config.extraMeta) === null || _a === void 0 ? void 0 : _a.forEach(addMeta);
(_b = config.extraLinks) === null || _b === void 0 ? void 0 : _b.forEach(addLink);
// Cleanup: remove tags we added on unmount
return function () {
added.forEach(function (el) { return head.contains(el) && head.removeChild(el); });
};
}, [
// JSON.stringify to trigger effect on deep config changes,
// but note: large config objects may cause extra renders.
JSON.stringify(config)
]);
// No JSX rendered; this component exists for side-effects only
return null;
};
/**
* Generate a string of `<title>`, `<meta>`, and `<link>` tags
* based on the provided SEO configuration.
*
* This utility is framework-agnostic and ideal for server-side
* rendering or static HTML injection.
*
* @remarks
* - Core tags (title, description, canonical, etc.)
* - Open Graph tags (`og:*`)
* - Twitter Card tags (`twitter:*`)
* - Extra `<meta>` and `<link>` entries
*
* @example
* ```js
* import { generateSeoMarkup } from 'react-seo'
*
* const head = generateSeoMarkup({
* title: 'My SSR Page',
* description: 'Hello from the server!',
* openGraph: { url: '/page', title: 'OG Title' },
* twitter: { card: 'summary', title: 'Twitter Title' }
* })
*
* // head:
* // "<title>My SSR Page</title>\n<meta name="description" ...>\n..."
* ```
*
* @param config - The SEO configuration object.
* @returns A string of newline-separated head tags.
*/
function generateSeoMarkup(config) {
var _a, _b;
var tags = [];
/**
* Helper to build a `<meta>` tag string from attributes.
* @param attrs - Key/value pairs for meta attributes.
*/
var m = function (attrs) {
return "<meta ".concat(Object.entries(attrs)
.map(function (_a) {
var k = _a[0], v = _a[1];
return "".concat(k, "=\"").concat(v, "\"");
})
.join(' '), ">");
};
/**
* Helper to build a `<link>` tag string.
* @param rel - The `rel` attribute (e.g., 'canonical', 'stylesheet').
* @param href - The `href` URL or path.
*/
var l = function (rel, href) {
return "<link rel=\"".concat(rel, "\" href=\"").concat(href, "\">");
};
// 1) Core tags
if (config.title) {
// <title> tag must come first for proper SEO
tags.push("<title>".concat(config.title, "</title>"));
}
if (config.description) {
tags.push(m({ name: 'description', content: config.description }));
}
if (config.keywords) {
tags.push(m({ name: 'keywords', content: config.keywords.join(',') }));
}
if (config.robots) {
tags.push(m({ name: 'robots', content: config.robots }));
}
// ...viewport, themeColor, canonical, author, publisher, rating...
if (config.viewport) {
tags.push(m({ name: 'viewport', content: config.viewport }));
}
if (config.themeColor) {
tags.push(m({ name: 'theme-color', content: config.themeColor }));
}
if (config.canonical) {
tags.push(l('canonical', config.canonical));
}
if (config.author) {
tags.push(m({ name: 'author', content: config.author }));
}
if (config.publisher) {
tags.push(m({ name: 'publisher', content: config.publisher }));
}
if (config.rating) {
tags.push(m({ name: 'rating', content: config.rating }));
}
if (config.revisitAfter) {
tags.push(m({ name: 'revisit-after', content: config.revisitAfter }));
}
if (config.language) {
tags.push(m({ name: 'language', content: config.language }));
}
// 2) Open Graph tags
if (config.openGraph) {
var og_1 = config.openGraph;
Object.keys(og_1).forEach(function (key) {
var val = og_1[key];
if (!val)
return;
if (key === 'images' && Array.isArray(val)) {
// Multiple images support
val.forEach(function (img) {
tags.push(m({ property: 'og:image', content: img.url }));
if (img.alt) {
tags.push(m({ property: 'og:image:alt', content: img.alt }));
}
});
}
else {
// Standard OG property, e.g., og:title, og:url
tags.push(m({ property: "og:".concat(key), content: String(val) }));
}
});
}
// 3) Twitter Card tags
if (config.twitter) {
var tw_1 = config.twitter;
Object.keys(tw_1).forEach(function (key) {
var val = tw_1[key];
if (val) {
tags.push(m({ name: "twitter:".concat(key), content: String(val) }));
}
});
}
// 4) Extra meta tags
(_a = config.extraMeta) === null || _a === void 0 ? void 0 : _a.forEach(function (x) {
// Distinguish 'name' vs 'property'
if (x.name) {
tags.push(m({ name: x.name, content: x.content }));
}
else if (x.property) {
tags.push(m({ property: x.property, content: x.content }));
}
});
// 5) Extra link tags
(_b = config.extraLinks) === null || _b === void 0 ? void 0 : _b.forEach(function (x) {
tags.push(l(x.rel, x.href));
});
// Join with newlines for readability in injected HTML
return tags.join('\n');
}
/**
* Default SEO configuration applied site-wide.
*
* @remarks
* This preset covers basic title, description, and robots tags.
*/
var defaultPreset = {
title: 'My Site',
description: 'Welcome to my awesome site',
robots: 'index,follow'
};
/**
* Generate a full `SeoConfig` tailored for a blog post.
*
* @remarks
* - Sets the `<title>` and meta description.
* - Adds `canonical` link to `slug`.
* - Builds Open Graph properties including images.
* - Configures Twitter Card for large image preview.
* - Appends `article:published_time` and `article:author` meta tags.
*
* @example
* ```ts
* import { blogPostPreset } from 'react-seo'
*
* const seo = blogPostPreset({
* title: 'Deep Dive into TSDoc',
* description: 'Learn how to document TypeScript libraries effectively.',
* slug: '/posts/tsdoc-guide',
* author: 'Jane Doe',
* datePublished: '2025-07-16',
* images: ['https://cdn.example.com/cover.png'],
* siteName: 'Example Blog'
* })
* ```
*
* @param opts - Configuration values specific to the blog post.
* @returns A `SeoConfig` ready for `<SeoTags>` or `generateSeoMarkup()`.
*/
function blogPostPreset(opts) {
var _a, _b, _c;
return {
title: opts.title,
description: opts.description,
canonical: opts.slug,
openGraph: {
title: opts.title,
description: opts.description,
url: opts.slug,
siteName: opts.siteName,
images: (_a = opts.images) === null || _a === void 0 ? void 0 : _a.map(function (url) { return ({ url: url }); })
},
twitter: {
card: 'summary_large_image',
title: opts.title,
description: opts.description,
image: (_c = (_b = opts.images) === null || _b === void 0 ? void 0 : _b[0]) !== null && _c !== void 0 ? _c : ''
},
extraMeta: [
{ property: 'article:published_time', content: opts.datePublished },
{ property: 'article:author', content: opts.author }
]
};
}
/**
* Generate a full `SeoConfig` tailored for a product page.
*
* @remarks
* - Sets title, description, and canonical link.
* - Builds Open Graph metadata including images.
* - Appends product-specific meta tags for price, currency, and availability.
*
* @example
* ```ts
* import { productPreset } from 'react-seo'
*
* const seo = productPreset({
* name: 'Wireless Headphones',
* description: 'Noise-cancelling over-ear headphones.',
* url: '/products/headphones',
* images: ['https://cdn.example.com/headphones.jpg'],
* price: '99.99',
* currency: 'USD',
* availability: 'InStock'
* })
* ```
*
* @param opts - Configuration values specific to the product.
* @returns A `SeoConfig` ready for `<SeoTags>` or `generateSeoMarkup()`.
*/
function productPreset(opts) {
var _a;
return {
title: opts.name,
description: opts.description,
canonical: opts.url,
openGraph: {
title: opts.name,
description: opts.description,
url: opts.url,
images: (_a = opts.images) === null || _a === void 0 ? void 0 : _a.map(function (url) { return ({ url: url }); })
},
extraMeta: [
{ property: 'product:price:amount', content: opts.price },
{ property: 'product:price:currency', content: opts.currency },
{ property: 'product:availability', content: opts.availability }
]
};
}
exports.SeoTags = SeoTags;
exports.blogPostPreset = blogPostPreset;
exports.defaultPreset = defaultPreset;
exports.generateSeoMarkup = generateSeoMarkup;
exports.productPreset = productPreset;
//# sourceMappingURL=index.js.map