UNPKG

metafy-seo

Version:

Lightweight, SSR-safe React components and utilities for managing SEO metadata. Supports Next.js App Router, Vite, and any React environment.

1,082 lines (1,066 loc) 40.2 kB
'use strict'; var React = require('react'); /****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */ var __assign = function() { __assign = Object.assign || function __assign(t) { for (var s, i = 1, n = arguments.length; i < n; i++) { s = arguments[i]; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p]; } return t; }; return __assign.apply(this, arguments); }; function __rest(s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; /** * SSR and utility helpers for metafy-seo */ /** Check if code is running on server (no window/document) */ var isServer = typeof window === 'undefined'; /** Check if code is running on client (has window/document) */ var isClient = !isServer; /** * Escape HTML entities to prevent XSS attacks in meta tag content. * * @param str - The string to escape * @returns Escaped string safe for HTML attributes */ function escapeHtml(str) { if (!str) return ''; return str .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#x27;'); } /** * Helper to upsert a DOM element in <head>. * * @param head - The document.head element. * @param added - Array to track elements created by this component (for cleanup). * @param tag - Tag name ('meta' or 'link'). * @param uniqueKey - The attribute name used to identify the tag (e.g. 'name', 'property', 'rel'). * @param uniqueValue - The value for that attribute (e.g. 'description', 'og:title'). * @param attrs - Full map of attributes to set on the element. * @returns The created/updated element, or null if running on server */ function upsertTag(head, added, tag, uniqueKey, uniqueValue, attrs) { // SSR safety: return early if no document if (isServer) return null; // Try to find an existing tag that matches the unique key/value // e.g. meta[name="description"] var selector = "".concat(tag, "[").concat(uniqueKey, "=\"").concat(uniqueValue, "\"]"); var el = head.querySelector(selector); if (!el) { // Create new el = document.createElement(tag); // Set identifying attribute first el.setAttribute(uniqueKey, uniqueValue); head.appendChild(el); added.push(el); } // Update/Set all other attributes // We do this even if it existed, to ensure it matches current config 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); } return el; } /** * Deep merge two objects, with source values taking precedence. * Useful for merging SEO configs. */ function deepMerge(target, source) { var result = __assign({}, target); for (var key in source) { var sourceValue = source[key]; var targetValue = target[key]; if (sourceValue !== undefined && typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue) && typeof targetValue === 'object' && targetValue !== null && !Array.isArray(targetValue)) { // Recursively merge objects result[key] = deepMerge(targetValue, sourceValue); } else if (sourceValue !== undefined) { result[key] = sourceValue; } } return result; } var defaultMergeConfig = function (c) { return c; }; var SeoContext = React.createContext({ defaults: {}, mergeConfig: defaultMergeConfig }); /** * Provider component for global SEO defaults. * * Wrap your app with SeoProvider to set site-wide defaults * that will be merged with individual SeoTags props. * * @example * ```tsx * <SeoProvider defaults={{ * titleTemplate: '%s | My Site', * twitter: { site: '@myhandle' } * }}> * <App /> * </SeoProvider> * ``` */ var SeoProvider = function (_a) { var _b = _a.defaults, defaults = _b === void 0 ? {} : _b, children = _a.children; var value = React.useMemo(function () { return ({ defaults: defaults, mergeConfig: function (config) { // Deep merge defaults with config var merged = deepMerge(defaults, config); // Special handling for titleTemplate: only use if not explicitly set in config if (defaults.titleTemplate && !config.titleTemplate) { merged.titleTemplate = defaults.titleTemplate; } return merged; } }); }, [defaults]); return (React.createElement(SeoContext.Provider, { value: value }, children)); }; SeoProvider.displayName = 'SeoProvider'; /** * Hook to access SEO context. * * Can be used without a provider - will return a no-op mergeConfig. */ var useSeo = function () { var context = React.useContext(SeoContext); return context; }; /** * 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. * SSR-safe: does nothing on server, only runs on client. * * @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 (props) { var mergeConfig = useSeo().mergeConfig; var config = mergeConfig(props); var prevConfigRef = React.useRef(''); React.useEffect(function () { var _a, _b, _c, _d; // SSR safety: skip on server if (isServer) return; var configString = JSON.stringify(config); // Skip if config hasn't changed if (configString === prevConfigRef.current) return; prevConfigRef.current = configString; var head = document.head; // Track newly appended elements so we can clean up on unmount var added = []; /** * Helper to upsert a <meta> tag. * Identifies tag by `name` or `property`. */ var addMeta = function (uniqueKey, uniqueValue, content) { var _a; if (!content) return; upsertTag(head, added, 'meta', uniqueKey, uniqueValue, (_a = {}, _a[uniqueKey] = uniqueValue, _a.content = content, _a)); }; /** * Helper to upsert a <link> tag. * Identifies tag by `rel`. */ var addLink = function (rel, href, extraAttrs) { if (extraAttrs === void 0) { extraAttrs = {}; } if (!href) return; upsertTag(head, added, 'link', 'rel', rel, __assign({ rel: rel, href: href }, extraAttrs)); }; // 1) Title tag var titleEl = document.querySelector('head > title'); if (config.title) { var titleText = config.titleTemplate ? config.titleTemplate.replace('%s', config.title) : config.title; if (!titleEl) { titleEl = document.createElement('title'); head.appendChild(titleEl); added.push(titleEl); } titleEl.textContent = titleText; } // 2) Core meta & link if (config.description) addMeta('name', 'description', config.description); // Robots meta tag handling: noindex/nofollow take precedence var robotsContent = config.robots; var noindex = config.noindex === true; var nofollow = config.nofollow === true; if (noindex && nofollow) { robotsContent = 'noindex,nofollow'; } else if (noindex) { robotsContent = 'noindex'; } else if (nofollow) { robotsContent = 'nofollow'; } if (robotsContent) { addMeta('name', 'robots', robotsContent); } if (config.viewport) addMeta('name', 'viewport', config.viewport); if (config.themeColor) addMeta('name', 'theme-color', config.themeColor); if (config.author) addMeta('name', 'author', config.author); if (config.publisher) addMeta('name', 'publisher', config.publisher); if (config.language) addMeta('name', 'language', config.language); if (config.canonical) addLink('canonical', config.canonical); // Site Verification if (config.siteVerification) { if (config.siteVerification.google) addMeta('name', 'google-site-verification', config.siteVerification.google); if (config.siteVerification.bing) addMeta('name', 'msvalidate.01', config.siteVerification.bing); if (config.siteVerification.yandex) addMeta('name', 'yandex-verification', config.siteVerification.yandex); if (config.siteVerification.pinterest) addMeta('name', 'p:domain_verify', config.siteVerification.pinterest); } // Facebook App ID if ((_a = config.facebook) === null || _a === void 0 ? void 0 : _a.appId) { addMeta('property', 'fb:app_id', config.facebook.appId); } // 3) Language Alternates (hreflang) if (config.languageAlternates) { Object.entries(config.languageAlternates).forEach(function (_a) { var lang = _a[0], href = _a[1]; upsertTag(head, added, 'link', 'hreflang', lang, { rel: 'alternate', hreflang: lang, href: href }); }); } // 4) Icons if (config.icons) { if (config.icons.icon) addLink('icon', config.icons.icon); if (config.icons.apple) addLink('apple-touch-icon', config.icons.apple); if (config.icons.manifest) addLink('manifest', config.icons.manifest); if (config.icons.mask) { addLink('mask-icon', config.icons.mask.url, { color: config.icons.mask.color || '#000000' }); } } // 5) OpenGraph 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)) { // Clear previous OG images before adding new ones head.querySelectorAll('meta[property^="og:image"]').forEach(function (el) { if (added.includes(el)) return; head.removeChild(el); }); // Now add new ones val.forEach(function (img) { addMeta('property', 'og:image', img.url); if (img.alt) addMeta('property', 'og:image:alt', img.alt); if (img.width) addMeta('property', 'og:image:width', String(img.width)); if (img.height) addMeta('property', 'og:image:height', String(img.height)); if (img.type) addMeta('property', 'og:image:type', img.type); }); } else if (typeof val === 'object') { // Handle nested OG objects (article, book, profile, video) var prefix_1 = "og:".concat(key); var valObj_1 = val; (Object.keys(valObj_1)).forEach(function (nestedKey) { var nestedVal = valObj_1[nestedKey]; if (!nestedVal) return; if (Array.isArray(nestedVal)) { // Clear previous nested array OG tags head.querySelectorAll("meta[property=\"".concat(prefix_1, ":").concat(nestedKey, "\"]")).forEach(function (el) { if (added.includes(el)) return; head.removeChild(el); }); nestedVal.forEach(function (item) { if (typeof item === 'object' && item !== null) { var itemObj = item; if (key === 'video' && nestedKey === 'actors') { addMeta('property', "".concat(prefix_1, ":actor"), itemObj.actor); if (itemObj.role) addMeta('property', "".concat(prefix_1, ":actor:role"), itemObj.role); } } else { addMeta('property', "".concat(prefix_1, ":").concat(nestedKey), String(item)); } }); } else { addMeta('property', "".concat(prefix_1, ":").concat(nestedKey), String(nestedVal)); } }); } else { addMeta('property', "og:".concat(key), String(val)); } }); } // 6) Twitter if (config.twitter) { var tw_1 = config.twitter; Object.keys(tw_1).forEach(function (key) { var val = tw_1[key]; if (val) { if (key === 'imageAlt') { addMeta('name', 'twitter:image:alt', String(val)); } else { addMeta('name', "twitter:".concat(key), String(val)); } } }); } // 7) Structured Data if ((_b = config.structuredData) === null || _b === void 0 ? void 0 : _b.length) { // Clear existing structured data scripts head.querySelectorAll('script[type="application/ld+json"][data-metafy^="structured-"]').forEach(function (el) { if (added.includes(el)) return; head.removeChild(el); }); config.structuredData.forEach(function (obj, i) { var el = document.createElement('script'); el.type = 'application/ld+json'; el.setAttribute('data-metafy', "structured-".concat(i)); el.textContent = JSON.stringify(obj); head.appendChild(el); added.push(el); }); } // 8) Extras head.querySelectorAll('meta[data-metafy^="extra-meta"]').forEach(function (el) { if (added.includes(el)) return; head.removeChild(el); }); head.querySelectorAll('link[data-metafy^="extra-link"]').forEach(function (el) { if (added.includes(el)) return; head.removeChild(el); }); (_c = config.extraMeta) === null || _c === void 0 ? void 0 : _c.forEach(function (x, i) { var el = document.createElement('meta'); if (x.name) el.setAttribute('name', x.name); else if (x.property) el.setAttribute('property', x.property); el.setAttribute('content', x.content); el.setAttribute('data-metafy', "extra-meta-".concat(i)); head.appendChild(el); added.push(el); }); (_d = config.extraLinks) === null || _d === void 0 ? void 0 : _d.forEach(function (x, i) { var el = document.createElement('link'); Object.entries(x).forEach(function (_a) { var k = _a[0], v = _a[1]; return el.setAttribute(k, v); }); el.setAttribute('data-metafy', "extra-link-".concat(i)); head.appendChild(el); added.push(el); }); // Cleanup return function () { added.forEach(function (el) { return head.contains(el) && head.removeChild(el); }); }; }, [JSON.stringify(config)]); return null; }; SeoTags.displayName = 'SeoTags'; /** * 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 * - All content is HTML escaped for security * * @example * ```js * import { generateSeoMarkup } from 'metafy-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' } * }) * ``` * * @param config - The SEO configuration object. * @returns A string of newline-separated head tags. */ function generateSeoMarkup(config) { var _a, _b, _c, _d; var tags = []; /** * Helper to build a `<meta>` tag string from attributes. * All content values are HTML escaped for security. */ var m = function (attrs) { var escaped = Object.fromEntries(Object.entries(attrs).map(function (_a) { var k = _a[0], v = _a[1]; return [k, escapeHtml(v)]; })); return "<meta ".concat(Object.entries(escaped) .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. * @param extraAttrs - Optional additional attributes */ var l = function (rel, href, extraAttrs) { if (extraAttrs === void 0) { extraAttrs = {}; } var escapedHref = escapeHtml(href); var extras = Object.entries(extraAttrs) .map(function (_a) { var k = _a[0], v = _a[1]; return "".concat(k, "=\"").concat(escapeHtml(v), "\""); }) .join(' '); return "<link rel=\"".concat(escapeHtml(rel), "\" href=\"").concat(escapedHref, "\"").concat(extras ? ' ' + extras : '', ">"); }; // 1) Core tags // Title with template support if (config.title) { var titleText = config.titleTemplate ? config.titleTemplate.replace('%s', config.title) : config.title; tags.push("<title>".concat(escapeHtml(titleText), "</title>")); } if (config.description) { tags.push(m({ name: 'description', content: config.description })); } // Robots meta tag handling: noindex/nofollow take precedence var robotsContent = config.robots; var noindex = config.noindex === true; var nofollow = config.nofollow === true; if (noindex && nofollow) { robotsContent = 'noindex,nofollow'; } else if (noindex) { robotsContent = 'noindex'; } else if (nofollow) { robotsContent = 'nofollow'; } if (robotsContent) { tags.push(m({ name: 'robots', content: robotsContent })); } 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.language) { tags.push(m({ name: 'language', content: config.language })); } // Site Verification if (config.siteVerification) { if (config.siteVerification.google) { tags.push(m({ name: 'google-site-verification', content: config.siteVerification.google })); } if (config.siteVerification.bing) { tags.push(m({ name: 'msvalidate.01', content: config.siteVerification.bing })); } if (config.siteVerification.yandex) { tags.push(m({ name: 'yandex-verification', content: config.siteVerification.yandex })); } if (config.siteVerification.pinterest) { tags.push(m({ name: 'p:domain_verify', content: config.siteVerification.pinterest })); } } // Facebook App ID if ((_a = config.facebook) === null || _a === void 0 ? void 0 : _a.appId) { tags.push(m({ property: 'fb:app_id', content: config.facebook.appId })); } // Language Alternates (hreflang) if (config.languageAlternates) { Object.entries(config.languageAlternates).forEach(function (_a) { var lang = _a[0], href = _a[1]; tags.push("<link rel=\"alternate\" hreflang=\"".concat(escapeHtml(lang), "\" href=\"").concat(escapeHtml(href), "\">")); }); } // Icons if (config.icons) { if (config.icons.icon) { tags.push(l('icon', config.icons.icon)); } if (config.icons.apple) { tags.push(l('apple-touch-icon', config.icons.apple)); } if (config.icons.mask) { var _e = config.icons.mask, url = _e.url, color = _e.color; tags.push(l('mask-icon', url, { color: color || '#000000' })); } if (config.icons.manifest) { tags.push(l('manifest', config.icons.manifest)); } } // 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)) { 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 })); if (img.width) tags.push(m({ property: 'og:image:width', content: String(img.width) })); if (img.height) tags.push(m({ property: 'og:image:height', content: String(img.height) })); if (img.type) tags.push(m({ property: 'og:image:type', content: img.type })); }); } else if (typeof val === 'object') { // Handle nested OG objects (article, book, profile, video) var prefix_1 = "og:".concat(key); var valObj_1 = val; (Object.keys(valObj_1)).forEach(function (nestedKey) { var nestedVal = valObj_1[nestedKey]; if (!nestedVal) return; if (Array.isArray(nestedVal)) { nestedVal.forEach(function (item) { if (typeof item === 'object' && item !== null) { var itemObj = item; // Special handling for actors (video) if (key === 'video' && nestedKey === 'actors') { tags.push(m({ property: "".concat(prefix_1, ":actor"), content: itemObj.actor })); if (itemObj.role) tags.push(m({ property: "".concat(prefix_1, ":actor:role"), content: itemObj.role })); } } else { tags.push(m({ property: "".concat(prefix_1, ":").concat(nestedKey), content: String(item) })); } }); } else { tags.push(m({ property: "".concat(prefix_1, ":").concat(nestedKey), content: String(nestedVal) })); } }); } else { // Standard OG property 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) { if (key === 'imageAlt') { tags.push(m({ name: 'twitter:image:alt', content: String(val) })); } else { tags.push(m({ name: "twitter:".concat(key), content: String(val) })); } } }); } // 4) Extra meta tags (_b = config.extraMeta) === null || _b === void 0 ? void 0 : _b.forEach(function (x) { 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 (_c = config.extraLinks) === null || _c === void 0 ? void 0 : _c.forEach(function (x) { var rel = x.rel, href = x.href, rest = __rest(x, ["rel", "href"]); tags.push(l(rel, href, rest)); }); // 6) Structured Data (JSON-LD) if ((_d = config.structuredData) === null || _d === void 0 ? void 0 : _d.length) { config.structuredData.forEach(function (data) { // JSON.stringify handles escaping for JSON context tags.push("<script type=\"application/ld+json\">".concat(JSON.stringify(data), "</script>")); }); } // 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. * * @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, author: opts.author, openGraph: { type: 'article', 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 }); }), article: { publishedTime: opts.datePublished, modifiedTime: opts.dateModified, authors: opts.author ? [opts.author] : [], section: opts.section, tags: opts.tags } }, 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 : '' } }; } /** * 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. * - Includes JSON-LD structured data for products. * * @param opts - Configuration values specific to the product. * @returns A `SeoConfig` ready for `<SeoTags>` or `generateSeoMarkup()`. */ function productPreset(opts) { var _a, _b, _c; return { title: opts.name, description: opts.description, canonical: opts.url, openGraph: { type: 'product', 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 }); }) }, twitter: { card: 'summary_large_image', title: opts.name, description: opts.description, image: (_c = (_b = opts.images) === null || _b === void 0 ? void 0 : _b[0]) !== null && _c !== void 0 ? _c : '' }, extraMeta: [ { property: 'product:price:amount', content: opts.price }, { property: 'product:price:currency', content: opts.currency }, { property: 'product:availability', content: opts.availability } ], structuredData: [{ '@context': 'https://schema.org', '@type': 'Product', name: opts.name, description: opts.description, image: opts.images, brand: opts.brand ? { '@type': 'Brand', name: opts.brand } : undefined, category: opts.category, offers: { '@type': 'Offer', price: opts.price, priceCurrency: opts.currency, availability: "https://schema.org/".concat(opts.availability) } }] }; } /** * Generate SEO config for a generic page. * * @param opts - Page options * @returns A `SeoConfig` for the page */ function pagePreset(opts) { return { title: opts.title, description: opts.description, canonical: opts.url, openGraph: { type: 'website', title: opts.title, description: opts.description, url: opts.url, siteName: opts.siteName, images: opts.image ? [{ url: opts.image }] : undefined }, twitter: { card: opts.image ? 'summary_large_image' : 'summary', title: opts.title, description: opts.description, image: opts.image } }; } /** * Generate SEO config optimized for social media sharing. * * @param opts - Social media options * @returns A `SeoConfig` optimized for social shares */ function socialPreset(opts) { return { title: opts.title, description: opts.description, canonical: opts.url, openGraph: { type: 'website', title: opts.title, description: opts.description, url: opts.url, siteName: opts.siteName, images: [{ url: opts.image, alt: opts.imageAlt, width: 1200, height: 630 }] }, twitter: { card: 'summary_large_image', site: opts.twitterSite, creator: opts.twitterCreator, title: opts.title, description: opts.description, image: opts.image, imageAlt: opts.imageAlt } }; } /** * Next.js specific utilities for metafy-seo * * These helpers generate metadata objects compatible with: * - Next.js 14+ App Router `export const metadata` * - Next.js `generateMetadata()` function * * @example * ```tsx * // app/page.tsx * import { generateNextMetadata, blogPostPreset } from 'metafy-seo/next' * * export const metadata = generateNextMetadata(blogPostPreset({ * title: 'My Blog Post', * description: 'A great article', * slug: '/blog/my-post', * author: 'Nigel', * datePublished: '2025-01-01' * })) * ``` */ /** * Convert a metafy-seo SeoConfig to Next.js Metadata format. * * @param config - The SEO configuration object * @returns A Next.js compatible Metadata object * * @example * ```tsx * // app/page.tsx * import { generateNextMetadata } from 'metafy-seo' * * export const metadata = generateNextMetadata({ * title: 'My Page', * description: 'Page description', * canonical: '/my-page' * }) * ``` */ function generateNextMetadata(config) { var _a, _b; var metadata = {}; // Title with template support if (config.title) { if (config.titleTemplate) { metadata.title = { default: config.title, template: config.titleTemplate }; } else { metadata.title = config.title; } } if (config.description) { metadata.description = config.description; } // Robots if (config.noindex || config.nofollow) { metadata.robots = { index: !config.noindex, follow: !config.nofollow }; } else if (config.robots) { metadata.robots = config.robots; } if (config.viewport) { metadata.viewport = config.viewport; } if (config.themeColor) { metadata.themeColor = config.themeColor; } if (config.author) { metadata.authors = [{ name: config.author }]; } if (config.publisher) { metadata.publisher = config.publisher; } // Alternates if (config.canonical || config.languageAlternates) { metadata.alternates = {}; if (config.canonical) { metadata.alternates.canonical = config.canonical; } if (config.languageAlternates) { metadata.alternates.languages = config.languageAlternates; } } // Open Graph if (config.openGraph) { var og = config.openGraph; metadata.openGraph = {}; if (og.type) metadata.openGraph.type = og.type; if (og.siteName) metadata.openGraph.siteName = og.siteName; if (og.title) metadata.openGraph.title = og.title; if (og.description) metadata.openGraph.description = og.description; if (og.url) metadata.openGraph.url = og.url; if (og.locale) metadata.openGraph.locale = og.locale; if ((_a = og.images) === null || _a === void 0 ? void 0 : _a.length) { metadata.openGraph.images = og.images.map(function (img) { return ({ url: img.url, alt: img.alt, width: img.width, height: img.height }); }); } if (og.article) { metadata.openGraph.article = { publishedTime: og.article.publishedTime, modifiedTime: og.article.modifiedTime, authors: og.article.authors, section: og.article.section, tags: og.article.tags }; } } // Twitter if (config.twitter) { metadata.twitter = {}; if (config.twitter.card) metadata.twitter.card = config.twitter.card; if (config.twitter.site) metadata.twitter.site = config.twitter.site; if (config.twitter.creator) metadata.twitter.creator = config.twitter.creator; if (config.twitter.title) metadata.twitter.title = config.twitter.title; if (config.twitter.description) metadata.twitter.description = config.twitter.description; if (config.twitter.image) metadata.twitter.images = [config.twitter.image]; } // Icons if (config.icons) { metadata.icons = {}; if (config.icons.icon) metadata.icons.icon = config.icons.icon; if (config.icons.apple) metadata.icons.apple = config.icons.apple; } // Site verification if (config.siteVerification) { metadata.verification = {}; if (config.siteVerification.google) { metadata.verification.google = config.siteVerification.google; } if (config.siteVerification.yandex) { metadata.verification.yandex = config.siteVerification.yandex; } // Bing and Pinterest go in 'other' if (config.siteVerification.bing || config.siteVerification.pinterest) { metadata.verification.other = {}; if (config.siteVerification.bing) { metadata.verification.other['msvalidate.01'] = config.siteVerification.bing; } if (config.siteVerification.pinterest) { metadata.verification.other['p:domain_verify'] = config.siteVerification.pinterest; } } } // Facebook App ID goes in 'other' if ((_b = config.facebook) === null || _b === void 0 ? void 0 : _b.appId) { metadata.other = metadata.other || {}; metadata.other['fb:app_id'] = config.facebook.appId; } return metadata; } /** * Type-safe helper for creating Next.js metadata with autocomplete. * Simply re-exports generateNextMetadata for semantic clarity. */ var createMetadata = generateNextMetadata; exports.SeoProvider = SeoProvider; exports.SeoTags = SeoTags; exports.blogPostPreset = blogPostPreset; exports.createMetadata = createMetadata; exports.deepMerge = deepMerge; exports.defaultPreset = defaultPreset; exports.escapeHtml = escapeHtml; exports.generateNextMetadata = generateNextMetadata; exports.generateSeoMarkup = generateSeoMarkup; exports.isClient = isClient; exports.isServer = isServer; exports.pagePreset = pagePreset; exports.productPreset = productPreset; exports.socialPreset = socialPreset; exports.useSeo = useSeo; //# sourceMappingURL=index.js.map