UNPKG

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
'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