fix(og): fall back to thumbnail when declared cover file is missing
WeChat link cards were pointing og:image at mindspace-cover paths that never landed on disk; serve-time injection now uses thumbnail.png instead. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+38
-3
@@ -5,6 +5,7 @@
|
||||
// <meta name="description"> / hero <img>), reused via extractCoverSignals. Authors who
|
||||
// already wrote their own og:image are left untouched; missing site_name / description /
|
||||
// brand icon are still backfilled.
|
||||
import path from 'node:path';
|
||||
import { extractCoverSignals } from './mindspace-thumbnails.mjs';
|
||||
|
||||
export const PLATFORM_SITE_NAME = 'TKMind 智趣';
|
||||
@@ -40,6 +41,15 @@ function resolveImageUrl(image, { origin, pageDirUrl }) {
|
||||
return `${pageDirUrl}${trimmed}`;
|
||||
}
|
||||
|
||||
/** Relative cover paths must exist beside the HTML file; remote/absolute refs are trusted. */
|
||||
export function coverImageRefExists(imageRef, htmlFilePath, fileExists = () => false) {
|
||||
const ref = String(imageRef ?? '').trim();
|
||||
if (!ref || ref.startsWith('data:')) return false;
|
||||
if (/^https?:\/\//i.test(ref) || ref.startsWith('//') || ref.startsWith('/')) return true;
|
||||
if (!htmlFilePath) return true;
|
||||
return fileExists(path.join(path.dirname(htmlFilePath), ref));
|
||||
}
|
||||
|
||||
function rawMetaContent(html, attr, name) {
|
||||
const pattern = new RegExp(
|
||||
`<meta[^>]+${attr}=["']${name}["'][^>]+content=["']([^"']*)["']|<meta[^>]+content=["']([^"']*)["'][^>]+${attr}=["']${name}["']`,
|
||||
@@ -82,11 +92,23 @@ function resolveShareDescription(html, { siteName = PLATFORM_SITE_NAME, meta = {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveShareImageUrl(html, { origin, pageDirUrl, fallbackImageUrl = '', meta = {} } = {}) {
|
||||
function resolveShareImageUrl(
|
||||
html,
|
||||
{ origin, pageDirUrl, fallbackImageUrl = '', meta = {}, htmlFilePath = '', fileExists = null } = {},
|
||||
) {
|
||||
const existing = rawMetaContent(html, 'property', 'og:image');
|
||||
if (existing) return existing;
|
||||
const signals = extractCoverSignals(html, meta);
|
||||
return resolveImageUrl(signals.image, { origin, pageDirUrl }) || fallbackImageUrl || '';
|
||||
const imageRef = signals.image;
|
||||
if (imageRef) {
|
||||
const exists =
|
||||
typeof fileExists === 'function' ? coverImageRefExists(imageRef, htmlFilePath, fileExists) : true;
|
||||
if (exists) {
|
||||
const resolved = resolveImageUrl(imageRef, { origin, pageDirUrl });
|
||||
if (resolved) return resolved;
|
||||
}
|
||||
}
|
||||
return fallbackImageUrl || '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,12 +124,21 @@ export function extractSharePreviewMeta(
|
||||
siteName = PLATFORM_SITE_NAME,
|
||||
brandIconUrl = '',
|
||||
meta = {},
|
||||
htmlFilePath = '',
|
||||
fileExists = null,
|
||||
} = {},
|
||||
) {
|
||||
const source = String(html ?? '');
|
||||
const title = rawMetaContent(source, 'property', 'og:title') || rawTitleFromHtml(source) || 'MindSpace 页面';
|
||||
const description = resolveShareDescription(source, { siteName, meta });
|
||||
const imageUrl = resolveShareImageUrl(source, { origin, pageDirUrl, fallbackImageUrl, meta });
|
||||
const imageUrl = resolveShareImageUrl(source, {
|
||||
origin,
|
||||
pageDirUrl,
|
||||
fallbackImageUrl,
|
||||
meta,
|
||||
htmlFilePath,
|
||||
fileExists,
|
||||
});
|
||||
const resolvedSiteName = rawMetaContent(source, 'property', 'og:site_name') || siteName;
|
||||
const iconUrl = brandIconUrl || (origin ? `${origin}${PLATFORM_BRAND_ICON_PATH}` : PLATFORM_BRAND_ICON_PATH);
|
||||
return {
|
||||
@@ -136,6 +167,8 @@ export function injectOgTags(
|
||||
siteName = PLATFORM_SITE_NAME,
|
||||
brandIconUrl = '',
|
||||
meta = {},
|
||||
htmlFilePath = '',
|
||||
fileExists = null,
|
||||
} = {},
|
||||
) {
|
||||
const source = String(html);
|
||||
@@ -147,6 +180,8 @@ export function injectOgTags(
|
||||
siteName,
|
||||
brandIconUrl,
|
||||
meta,
|
||||
htmlFilePath,
|
||||
fileExists,
|
||||
});
|
||||
|
||||
const tags = [];
|
||||
|
||||
@@ -39,13 +39,30 @@ test('falls back to the thumbnail png when the page has no cover of its own', ()
|
||||
assert.match(out, /<meta name="twitter:card" content="summary_large_image">/);
|
||||
});
|
||||
|
||||
test('prefers the page cover over the thumbnail fallback', () => {
|
||||
test('prefers the page cover over the thumbnail fallback when the cover file exists', () => {
|
||||
const html = `<head><title>X</title><meta name="mindspace-cover" content='{"cover":"assets/hero.jpg"}'></head>`;
|
||||
const out = injectOgTags(html, { ...ctx, fallbackImageUrl: 'https://m.tkmind.cn/x.thumbnail.png' });
|
||||
const out = injectOgTags(html, {
|
||||
...ctx,
|
||||
fallbackImageUrl: 'https://m.tkmind.cn/x.thumbnail.png',
|
||||
htmlFilePath: '/tmp/public/page.html',
|
||||
fileExists: (target) => String(target).endsWith('assets/hero.jpg'),
|
||||
});
|
||||
assert.match(out, /og:image" content="https:\/\/m\.tkmind\.cn\/MindSpace\/john\/public\/assets\/hero\.jpg"/);
|
||||
assert.doesNotMatch(out, /thumbnail\.png/);
|
||||
});
|
||||
|
||||
test('falls back to thumbnail png when the declared cover file is missing on disk', () => {
|
||||
const html = `<head><title>X</title><meta name="mindspace-cover" content='{"cover":"assets/hero.jpg"}'></head>`;
|
||||
const out = injectOgTags(html, {
|
||||
...ctx,
|
||||
fallbackImageUrl: 'https://m.tkmind.cn/x.thumbnail.png',
|
||||
htmlFilePath: '/tmp/public/page.html',
|
||||
fileExists: () => false,
|
||||
});
|
||||
assert.match(out, /<meta property="og:image" content="https:\/\/m\.tkmind\.cn\/x\.thumbnail\.png">/);
|
||||
assert.doesNotMatch(out, /<meta property="og:image" content="[^"]*assets\/hero\.jpg"/);
|
||||
});
|
||||
|
||||
test('keeps an author-provided og:image and still backfills site_name', () => {
|
||||
const html = `<head><title>T</title><meta property="og:image" content="https://x/y.png"></head>`;
|
||||
const out = injectOgTags(html, ctx);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const INLINE_SCRIPT_PATTERN = /<script\b(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi;
|
||||
@@ -47,6 +48,8 @@ export function decorateMindSpacePublishedHtml({
|
||||
html,
|
||||
embed = false,
|
||||
context,
|
||||
htmlFilePath = '',
|
||||
fileExists = fs.existsSync,
|
||||
userAgent = '',
|
||||
preparePublicationHtmlForEmbed,
|
||||
injectOgTags,
|
||||
@@ -69,6 +72,8 @@ export function decorateMindSpacePublishedHtml({
|
||||
pageUrl: context.pageUrl,
|
||||
pageDirUrl: context.pageDirUrl,
|
||||
fallbackImageUrl: context.fallbackImageUrl,
|
||||
htmlFilePath,
|
||||
fileExists,
|
||||
});
|
||||
const wechatShare = !embed && isWechatUserAgent(userAgent || '');
|
||||
if (wechatShare) {
|
||||
|
||||
@@ -5493,6 +5493,7 @@ async function sendPublishFile(req, res, filePath) {
|
||||
html,
|
||||
embed,
|
||||
context,
|
||||
htmlFilePath: filePath,
|
||||
userAgent: req.get('user-agent') || '',
|
||||
preparePublicationHtmlForEmbed,
|
||||
injectOgTags,
|
||||
|
||||
Reference in New Issue
Block a user