// Inject Open Graph / Twitter Card tags into published MindSpace HTML at serve time, // so forwarded links unfurl with a cover image in WeChat / browsers / IM clients. // // Source of truth for the cover is the page's own (mindspace-cover JSON / // / hero ), reused via extractCoverSignals. Authors who // already wrote their own og:image are left untouched. import { extractCoverSignals } from './mindspace-thumbnails.mjs'; function rawTitleFromHtml(html) { // Keep the original verbatim (emoji included) for og:title. return String(html).match(/<title[^>]*>([^<]*)<\/title>/i)?.[1]?.trim() ?? ''; } function escapeAttr(value) { return String(value) .replaceAll('&', '&') .replaceAll('"', '"') .replaceAll('<', '<') .replaceAll('>', '>'); } /** * Resolve a cover reference (relative path, root-absolute, or full URL) to an * absolute https URL the unfurling client can fetch. * @returns {string|null} absolute URL, or null when no usable raster cover exists. */ function resolveImageUrl(image, { origin, pageDirUrl }) { if (!image) return null; const trimmed = String(image).trim(); if (!trimmed || trimmed.startsWith('data:')) return null; // og:image must be a raster the client can render; SVG is not honored by WeChat/most unfurlers. if (/\.svg(?:[?#]|$)/i.test(trimmed)) return null; if (/^https?:\/\//i.test(trimmed)) return trimmed; if (trimmed.startsWith('//')) return `https:${trimmed}`; if (trimmed.startsWith('/')) return `${origin}${trimmed}`; return `${pageDirUrl}${trimmed}`; } /** * Inject og:/twitter: meta into an HTML document. * @param {string} html raw page HTML * @param {{ origin: string, pageUrl: string, pageDirUrl: string, fallbackImageUrl?: string }} ctx * origin: https://host pageUrl: canonical page URL pageDirUrl: page directory URL (trailing '/') * fallbackImageUrl: absolute raster URL to use when the page has no cover of its own * @returns {string} HTML with tags injected (or unchanged when not applicable) */ export function injectOgTags(html, { origin, pageUrl, pageDirUrl, fallbackImageUrl = '' }) { const source = String(html); // Respect a page that already declares its own Open Graph image. if (/<meta[^>]+property=["']og:image["']/i.test(source)) return source; const signals = extractCoverSignals(source); const title = rawTitleFromHtml(source) || signals.title; const description = signals.subtitle || ''; const imageUrl = resolveImageUrl(signals.image, { origin, pageDirUrl }) || fallbackImageUrl || null; const tags = [ '<meta property="og:type" content="article">', pageUrl ? `<meta property="og:url" content="${escapeAttr(pageUrl)}">` : '', title ? `<meta property="og:title" content="${escapeAttr(title)}">` : '', description ? `<meta property="og:description" content="${escapeAttr(description)}">` : '', imageUrl ? `<meta property="og:image" content="${escapeAttr(imageUrl)}">` : '', `<meta name="twitter:card" content="${imageUrl ? 'summary_large_image' : 'summary'}">`, title ? `<meta name="twitter:title" content="${escapeAttr(title)}">` : '', description ? `<meta name="twitter:description" content="${escapeAttr(description)}">` : '', imageUrl ? `<meta name="twitter:image" content="${escapeAttr(imageUrl)}">` : '', ].filter(Boolean); const block = `\n${tags.map((t) => ` ${t}`).join('\n')}\n`; if (/<\/head>/i.test(source)) { return source.replace(/<\/head>/i, `${block}</head>`); } if (/<head[^>]*>/i.test(source)) { return source.replace(/(<head[^>]*>)/i, `$1${block}`); } return source; }