178 lines
7.0 KiB
JavaScript
178 lines
7.0 KiB
JavaScript
// 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 <head> (mindspace-cover JSON /
|
|
// <meta name="description"> / hero <img>), 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 <title> 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}`;
|
|
}
|
|
|
|
function rawMetaContent(html, attr, name) {
|
|
const pattern = new RegExp(
|
|
`<meta[^>]+${attr}=["']${name}["'][^>]+content=["']([^"']*)["']|<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${name}["']`,
|
|
'i',
|
|
);
|
|
const match = String(html).match(pattern);
|
|
return match?.[1] || match?.[2] || '';
|
|
}
|
|
|
|
function escapeScriptString(value) {
|
|
return JSON.stringify(String(value ?? '')).replace(/</g, '\\u003c');
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
export function injectWechatShareBridge(
|
|
html,
|
|
{
|
|
pageUrl,
|
|
signatureEndpoint = '/auth/wechat/public-js-sdk-signature',
|
|
} = {},
|
|
) {
|
|
const source = String(html ?? '');
|
|
if (!pageUrl) return source;
|
|
if (source.includes('data-tkmind-wechat-share="1"')) return source;
|
|
|
|
const title = rawMetaContent(source, 'property', 'og:title') || rawTitleFromHtml(source);
|
|
const description =
|
|
rawMetaContent(source, 'property', 'og:description') ||
|
|
rawMetaContent(source, 'name', 'description') ||
|
|
'';
|
|
const imageUrl = rawMetaContent(source, 'property', 'og:image') || '';
|
|
|
|
const script = `
|
|
<script data-tkmind-wechat-share="1">
|
|
(function () {
|
|
var ua = navigator.userAgent || '';
|
|
if (!/MicroMessenger|WindowsWechat/i.test(ua)) return;
|
|
var pageUrl = ${escapeScriptString(String(pageUrl).split('#')[0])};
|
|
var endpoint = ${escapeScriptString(signatureEndpoint)};
|
|
var shareData = {
|
|
title: ${escapeScriptString(title)},
|
|
desc: ${escapeScriptString(description)},
|
|
link: pageUrl,
|
|
imgUrl: ${escapeScriptString(imageUrl)}
|
|
};
|
|
function applyShare(wx) {
|
|
if (!wx) return;
|
|
var onReady = function () {
|
|
if (typeof wx.updateAppMessageShareData === 'function') wx.updateAppMessageShareData(shareData);
|
|
if (typeof wx.updateTimelineShareData === 'function') wx.updateTimelineShareData(shareData);
|
|
if (typeof wx.onMenuShareAppMessage === 'function') wx.onMenuShareAppMessage(shareData);
|
|
if (typeof wx.onMenuShareTimeline === 'function') wx.onMenuShareTimeline(shareData);
|
|
};
|
|
fetch(endpoint + '?url=' + encodeURIComponent(pageUrl))
|
|
.then(function (res) { return res.ok ? res.json() : Promise.reject(new Error('signature')); })
|
|
.then(function (payload) {
|
|
wx.config({
|
|
debug: false,
|
|
appId: payload.appId,
|
|
timestamp: payload.timestamp,
|
|
nonceStr: payload.nonceStr,
|
|
signature: payload.signature,
|
|
jsApiList: payload.jsApiList || [
|
|
'updateAppMessageShareData',
|
|
'updateTimelineShareData',
|
|
'onMenuShareAppMessage',
|
|
'onMenuShareTimeline'
|
|
]
|
|
});
|
|
wx.ready(onReady);
|
|
})
|
|
.catch(function () {});
|
|
}
|
|
function boot() {
|
|
if (window.wx) {
|
|
applyShare(window.wx);
|
|
return;
|
|
}
|
|
var script = document.createElement('script');
|
|
script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
|
script.async = true;
|
|
script.onload = function () { applyShare(window.wx); };
|
|
document.head.appendChild(script);
|
|
}
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', boot, { once: true });
|
|
} else {
|
|
boot();
|
|
}
|
|
})();
|
|
</script>`;
|
|
|
|
if (/<\/head>/i.test(source)) {
|
|
return source.replace(/<\/head>/i, `${script}\n</head>`);
|
|
}
|
|
if (/<head[^>]*>/i.test(source)) {
|
|
return source.replace(/(<head[^>]*>)/i, `$1${script}`);
|
|
}
|
|
return `${script}\n${source}`;
|
|
}
|