feat(mindspace): 公开页分享组件、workspace 路径与 Plaza 发布增强

- 新增 public share widget 与 workspace relative path 解析
- 增强 publications/chat-plaza 发布链路与缩略图 demo
- 补充 schema、cover 检查脚本与回归测试

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-05 23:14:41 +08:00
parent 69be1e5293
commit e2ad3bf62b
39 changed files with 2184 additions and 256 deletions
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env node
/**
* Scan MindSpace public/*.html for mindspace-cover compliance (scheme A gate).
*
* Usage:
* node scripts/check-mindspace-cover.mjs
* node scripts/check-mindspace-cover.mjs --user <uuid>
* node scripts/check-mindspace-cover.mjs --root /path/to/MindSpace
* node scripts/check-mindspace-cover.mjs --file public/thumbnail-demo-samples/vietnam-guide-good.html
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseMindspaceCoverMeta } from '../mindspace-cover-meta.mjs';
import { hasPlatformBrandMarker } from '../mindspace-page-tag.mjs';
import {
hasMindspaceCoverMeta,
hasRasterShareImageHint,
hasShareDescription,
} from '../wechat/verify/share-preview.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const DEFAULT_PUBLISH_ROOT = 'MindSpace';
function parseArgs(argv) {
let root = path.join(repoRoot, DEFAULT_PUBLISH_ROOT);
let userId = null;
let file = null;
for (let i = 2; i < argv.length; i += 1) {
if (argv[i] === '--root' && argv[i + 1]) {
root = path.resolve(argv[i + 1]);
i += 1;
} else if (argv[i] === '--user' && argv[i + 1]) {
userId = argv[i + 1];
i += 1;
} else if (argv[i] === '--file' && argv[i + 1]) {
file = path.resolve(argv[i + 1]);
i += 1;
} else if (argv[i] === '--help' || argv[i] === '-h') {
console.log(`Usage: node scripts/check-mindspace-cover.mjs [--root ${DEFAULT_PUBLISH_ROOT}] [--user <uuid>] [--file <html>]`);
process.exit(0);
}
}
return { root, userId, file };
}
function hasCoverImageHint(html) {
if (hasRasterShareImageHint(html)) return true;
const coverMeta = parseMindspaceCoverMeta(html);
const cover = String(coverMeta?.cover ?? coverMeta?.image ?? '').trim();
if (!cover) return false;
if (/^https?:\/\//i.test(cover)) return true;
if (/\.svg(?:[?#]|$)/i.test(cover)) return false;
return true;
}
function auditCoverHtml(htmlPath, html) {
const issues = [];
if (!hasMindspaceCoverMeta(html)) {
issues.push({ level: 'error', code: 'missing_mindspace_cover', message: '缺少 <meta name="mindspace-cover">' });
}
if (!hasShareDescription(html)) {
issues.push({ level: 'error', code: 'missing_description', message: '缺少 description / og:description' });
}
if (!hasPlatformBrandMarker(html)) {
issues.push({ level: 'warn', code: 'missing_platform_brand', message: '缺少 data-mindspace-page-tag="platform-brand" 页脚' });
}
const coverMeta = parseMindspaceCoverMeta(html);
if (coverMeta) {
if (!String(coverMeta.tag ?? '').trim()) {
issues.push({ level: 'warn', code: 'missing_tag', message: 'mindspace-cover 缺少 tag' });
}
if (!String(coverMeta.subtitle ?? '').trim()) {
issues.push({ level: 'warn', code: 'missing_subtitle', message: 'mindspace-cover 缺少 subtitle' });
}
if (!String(coverMeta.accent ?? '').trim()) {
issues.push({ level: 'warn', code: 'missing_accent', message: 'mindspace-cover 缺少 accent' });
}
}
if (!hasCoverImageHint(html)) {
issues.push({
level: 'warn',
code: 'missing_cover_image',
message: '无 raster 封面图(cover/image/og:image),缩略图会退化为纯色渐变',
});
}
return issues;
}
function collectHtmlFiles(root, userId) {
const files = [];
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '.agents' || entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
walk(full);
continue;
}
if (!entry.name.endsWith('.html') || entry.name.endsWith('.thumbnail.svg')) continue;
files.push(full);
}
};
if (userId) {
walk(path.join(root, userId, 'public'));
return files;
}
if (!fs.existsSync(root)) return files;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === 'wiki' || entry.name.startsWith('.')) continue;
walk(path.join(root, entry.name, 'public'));
}
return files;
}
const { root, userId, file } = parseArgs(process.argv);
const targets = file ? [file] : collectHtmlFiles(root, userId);
const results = [];
for (const htmlPath of targets) {
const html = fs.readFileSync(htmlPath, 'utf8');
const issues = auditCoverHtml(htmlPath, html);
if (issues.length) {
results.push({ htmlPath, issues });
}
}
if (results.length === 0) {
const scope = file ?? `${root}${userId ? ` (user ${userId})` : ''}`;
console.log(`OK: ${targets.length} 个 HTML 页面均符合 mindspace-cover 规范 (${scope})`);
process.exit(0);
}
let errors = 0;
let warns = 0;
console.error(`Found cover issues in ${results.length} / ${targets.length} HTML files:`);
for (const { htmlPath, issues } of results) {
const rel = file ? htmlPath : path.relative(root, htmlPath);
console.error(`\n${rel}`);
for (const issue of issues) {
const prefix = issue.level === 'error' ? ' ✗' : ' ⚠';
console.error(`${prefix} [${issue.code}] ${issue.message}`);
if (issue.level === 'error') errors += 1;
else warns += 1;
}
}
console.error(`\n合计: ${errors} 错误, ${warns} 警告`);
process.exit(errors > 0 ? 1 : 0);
+38
View File
@@ -198,6 +198,29 @@ try {
assert.match(stillV1.body, /V1 内容/);
assert.doesNotMatch(stillV1.body, /V2 内容/);
const blockedRepublish = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
page_version_id: updated.body.data.currentVersionId,
access_mode: 'owner_only',
url_slug: 'immutable-page',
acknowledged_finding_ids: [],
}),
});
assert.equal(blockedRepublish.response.status, 409);
assert.equal(blockedRepublish.body.error.code, 'page_already_online');
const offlineBeforeRepublish = await request(
`/api/mindspace/v1/publications/${published.body.data.id}/offline`,
{
method: 'POST',
headers: authHeaders(owner.cookie),
body: '{}',
},
);
assert.equal(offlineBeforeRepublish.response.status, 200);
const republished = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
method: 'POST',
headers: authHeaders(owner.cookie),
@@ -218,7 +241,21 @@ try {
assert.equal(ownerProtected.response.status, 200);
assert.match(ownerProtected.body, /V2 内容/);
let currentPublicationId = republished.body.data.id;
const offlineCurrentPublication = async () => {
const response = await request(
`/api/mindspace/v1/publications/${currentPublicationId}/offline`,
{
method: 'POST',
headers: authHeaders(owner.cookie),
body: '{}',
},
);
assert.equal(response.response.status, 200, JSON.stringify(response.body));
};
const publishMode = async (accessMode, extra = {}) => {
await offlineCurrentPublication();
const response = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
method: 'POST',
headers: authHeaders(owner.cookie),
@@ -231,6 +268,7 @@ try {
}),
});
assert.equal(response.response.status, 201, JSON.stringify(response.body));
currentPublicationId = response.body.data.id;
return response.body.data;
};
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { upsertMindspaceCoverMeta } from '../mindspace-cover-meta.mjs';
import { ensureWorkspaceHtmlThumbnail } from '../mindspace-workspace-thumbnails.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const PATCHES = [
{
rel: 'MindSpace/02f8634f-8988-4313-80e2-a289bb319faa/public/123.html',
description: 'Tang 的 123 简单展示页',
cover: {
tag: '精选页面',
emoji: '✨',
accent: '#667eea',
accent2: '#764ba2',
subtitle: '欢迎来到 Tang 的 123 页面',
cover: 'https://images.unsplash.com/photo-1557683316-973673baf926?auto=format&fit=crop&w=1200&q=80',
},
},
{
rel: 'MindSpace/02f8634f-8988-4313-80e2-a289bb319faa/public/essay.html',
description: '夏夜的风,带着一丝凉意 — TKMind 散文',
cover: {
tag: '精选页面',
emoji: '🌙',
accent: '#4a5a9c',
accent2: '#0b0e1a',
subtitle: '夏夜随笔 · 星空与萤火',
cover: 'https://images.unsplash.com/photo-1419242902214-272b4217a589?auto=format&fit=crop&w=1200&q=80',
},
},
{
rel: 'MindSpace/02f8634f-8988-4313-80e2-a289bb319faa/public/prose.html',
description: '黄昏时分,河堤漫步 — 散文《黄昏书简》',
cover: {
tag: '精选页面',
emoji: '🌅',
accent: '#c9ad93',
accent2: '#6b4f3a',
subtitle: '黄昏书简 · 与时光温柔和解',
cover: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1200&q=80',
},
},
{
rel: 'MindSpace/1c99b83b-0454-474f-a5d2-129d34506a32/public/MIT-计算机科学课程学习路线.html',
description: 'MIT 计算机科学课程体系 · 分阶段学习路线与资源汇总',
cover: {
tag: '报告',
emoji: '📚',
accent: '#6a67ce',
accent2: '#24243e',
subtitle: '7 大章节 · 从数学基础到 AI 方向',
cover: 'https://images.unsplash.com/photo-1517694712202-9963a793792c?auto=format&fit=crop&w=1200&q=80',
},
},
{
rel: 'MindSpace/1c99b83b-0454-474f-a5d2-129d34506a32/public/太湖三白.html',
description: '太湖三白 · 白鱼、白虾、银鱼 — 江南水中三宝',
cover: {
tag: '美食',
emoji: '🐟',
accent: '#2e86ab',
accent2: '#0b2a44',
subtitle: '千年太湖,一味三鲜',
cover: 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?auto=format&fit=crop&w=1200&q=80',
},
},
{
rel: 'MindSpace/1c99b83b-0454-474f-a5d2-129d34506a32/public/春江花月夜.html',
description: '春江花月夜 · 张若虚 — 孤篇盖全唐',
cover: {
tag: '精选页面',
emoji: '🌕',
accent: '#c9a227',
accent2: '#0a0e1a',
subtitle: '春江潮水连海平 · 海上明月共潮生',
cover: 'https://images.unsplash.com/photo-1444703686981-a3abbc4d4fe3?auto=format&fit=crop&w=1200&q=80',
},
},
];
function upsertDescription(html, description) {
const meta = `<meta name="description" content="${description.replace(/"/g, '&quot;')}">`;
if (/<meta[^>]+name=["']description["']/i.test(html)) {
return html.replace(/<meta[^>]+name=["']description["'][^>]*>/i, meta);
}
if (/<meta[^>]+name=["']viewport["'][^>]*>/i.test(html)) {
return html.replace(/(<meta[^>]+name=["']viewport["'][^>]*>)/i, `$1\n${meta}`);
}
return html.replace(/<head([^>]*)>/i, `<head$1>\n${meta}`);
}
function upsertPlatformBrand(html) {
if (/data-mindspace-page-tag=["']platform-brand["']/i.test(html)) return html;
const brand = '\n<p data-mindspace-page-tag="platform-brand" style="text-align:center;margin:24px 0 12px;font-size:12px;opacity:0.55">TKMind · 智趣</p>\n';
return html.replace(/<\/body>/i, `${brand}</body>`);
}
for (const patch of PATCHES) {
const abs = path.join(root, patch.rel);
let html = await fs.readFile(abs, 'utf8');
html = upsertDescription(html, patch.description);
html = upsertMindspaceCoverMeta(html, patch.cover);
html = upsertPlatformBrand(html);
await fs.writeFile(abs, html, 'utf8');
const publishDir = path.dirname(abs);
const htmlRel = path.basename(abs);
await ensureWorkspaceHtmlThumbnail(publishDir, htmlRel, html, {
title: patch.cover.subtitle.split('·')[0].trim(),
subtitle: patch.description,
force: true,
});
console.log(`${patch.rel}`);
}
console.log('\n完成 6 页 patch + 缩略图重建');
+74 -83
View File
@@ -4,38 +4,29 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildFeedThumbnailSvg,
buildThumbnailSvg,
extractCoverSignals,
generateHtmlThumbnail,
resolveCoverDataUri,
} from '../mindspace-thumbnails.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const sampleDir = path.join(root, 'public', 'thumbnail-demo-samples');
const outputDir = path.join(root, 'public', 'thumbnail-demo');
const samples = [
/** A/B pairs: bad = 缺 mindspace-covergood = 方案 A 完整元数据 + hero 图 */
const pairs = [
{
id: 'malaysia-travel',
file: path.join(root, 'MindSpace', 'john', 'malaysia-travel-guide.html'),
meta: {
tag: '旅行攻略',
subtitle: '马来西亚深度游',
cover: 'https://images.unsplash.com/photo-1596422846543-75c6fc197f07?auto=format&fit=crop&w=1200&q=80',
},
},
{
id: 'mapo-tofu',
file: path.join(root, 'MindSpace', 'john', 'mapo-tofu.html'),
meta: {
tag: '美食专题',
cover: 'https://images.unsplash.com/photo-1525755662778-989d0529657e?auto=format&fit=crop&w=1200&q=80',
},
id: 'vietnam-guide',
label: '越南旅游指南',
bad: path.join(sampleDir, 'vietnam-guide-bad.html'),
good: path.join(sampleDir, 'vietnam-guide-good.html'),
},
];
function galleryCard({ id, title, subtitle }) {
function galleryCard({ id, title, subtitle, variant }) {
return `
<article class="feed-card">
<div class="feed-cover"><img src="./${id}-feed.svg" alt="${title} 预览图" /></div>
<div class="feed-cover"><img src="./${id}-${variant}-feed.svg" alt="${title} 预览图" /></div>
<div class="feed-body">
<h3>${title}</h3>
<p>${subtitle}</p>
@@ -43,47 +34,54 @@ function galleryCard({ id, title, subtitle }) {
</article>`;
}
async function renderVariant({ htmlPath, variant, id }) {
const html = await fs.readFile(htmlPath, 'utf8');
const signals = extractCoverSignals(html);
const coverDataUri = signals.image
? await resolveCoverDataUri({
storageRoot: outputDir,
contentBaseDir: path.dirname(htmlPath),
imageUrl: signals.image,
})
: null;
const feedSvg = buildFeedThumbnailSvg(signals, { coverDataUri });
const feedPath = path.join(outputDir, `${id}-${variant}-feed.svg`);
await fs.writeFile(feedPath, feedSvg, 'utf8');
await generateHtmlThumbnail(outputDir, `${id}-${variant}-generated.svg`, html, {
contentBaseDir: path.dirname(htmlPath),
title: signals.title,
subtitle: signals.subtitle,
});
return { signals, hasPhoto: Boolean(coverDataUri) };
}
async function main() {
await fs.mkdir(outputDir, { recursive: true });
const cards = [];
const sections = [];
for (const sample of samples) {
let html = '';
try {
html = await fs.readFile(sample.file, 'utf8');
} catch {
console.warn(`跳过缺失样本: ${sample.file}`);
continue;
}
for (const pair of pairs) {
const bad = await renderVariant({ htmlPath: pair.bad, variant: 'bad', id: pair.id });
const good = await renderVariant({ htmlPath: pair.good, variant: 'good', id: pair.id });
const signals = extractCoverSignals(html, sample.meta);
const legacySvg = buildThumbnailSvg({
title: signals.title,
subtitle: sample.meta.subtitle ?? signals.subtitle,
accent: signals.accent,
});
const coverDataUri = signals.image
? await resolveCoverDataUri({
storageRoot: outputDir,
contentBaseDir: path.dirname(sample.file),
imageUrl: signals.image,
})
: null;
const feedSvg = buildFeedThumbnailSvg(signals, { coverDataUri });
await fs.writeFile(path.join(outputDir, `${sample.id}-legacy.svg`), legacySvg, 'utf8');
await fs.writeFile(path.join(outputDir, `${sample.id}-feed.svg`), feedSvg, 'utf8');
await generateHtmlThumbnail(outputDir, `${sample.id}-generated.svg`, html, {
...sample.meta,
contentBaseDir: path.dirname(sample.file),
});
cards.push(
galleryCard({
id: sample.id,
title: signals.title,
subtitle: `${signals.tag} · 新版 3:4 封面`,
}),
);
sections.push(`
<section class="sample">
<h2>${pair.label}</h2>
<p class="hint">左侧:未写 mindspace-cover(退化为纯色渐变) · 右侧:方案 A(完整元数据 + hero 主图)</p>
<div class="pair">
<div class="thumb">
<img src="./${pair.id}-bad-feed.svg" alt="缺 cover" />
<span class="bad">❌ 缺 mindspace-cover · ${bad.hasPhoto ? '有图' : '无图'} · tag=${bad.signals.tag}</span>
</div>
<div class="thumb">
<img src="./${pair.id}-good-feed.svg" alt="方案 A" />
<span class="good">✅ 方案 A · ${good.hasPhoto ? '照片封面' : '场景/渐变'} · tag=${good.signals.tag}</span>
</div>
</div>
<div class="feed-grid">
${galleryCard({ id: pair.id, title: pair.label, subtitle: '缺 cover · 信息流效果', variant: 'bad' })}
${galleryCard({ id: pair.id, title: pair.label, subtitle: '方案 A · 信息流效果', variant: 'good' })}
</div>
</section>`);
}
const html = `<!doctype html>
@@ -91,52 +89,45 @@ async function main() {
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MindSpace 预览图 Demo</title>
<title>MindSpace 缩略图 · 方案 A 对比</title>
<style>
:root { color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f4f0e7; color: #18211d; }
body { margin: 0; padding: 24px; }
body { margin: 0; padding: 24px; max-width: 960px; }
h1 { margin: 0 0 8px; font-size: 28px; }
.lead { margin: 0 0 24px; color: #5f6963; line-height: 1.6; }
.compare { display: grid; gap: 28px; }
.sample { padding: 20px; border-radius: 20px; background: #fff; box-shadow: 0 10px 30px rgba(24,33,29,0.08); }
.sample h2 { margin: 0 0 16px; font-size: 20px; }
.pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; }
.sample h2 { margin: 0 0 8px; font-size: 20px; }
.hint { margin: 0 0 16px; font-size: 13px; color: #6d7771; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.thumb { display: grid; gap: 8px; }
.thumb img { width: 100%; border-radius: 12px; box-shadow: 0 8px 24px rgba(24,33,29,0.12); }
.thumb span { font-size: 13px; color: #6d7771; }
.feed-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; max-width: 520px; }
.feed-card { border: none; border-radius: 12px; overflow: hidden; background: #fff; box-shadow: 0 2px 10px rgba(24,33,29,0.07); }
.thumb img { width: 100%; max-width: 270px; border-radius: 12px; box-shadow: 0 8px 24px rgba(24,33,29,0.12); }
.thumb span { font-size: 12px; line-height: 1.5; }
.thumb span.bad { color: #b45309; }
.thumb span.good { color: #2f6f57; }
.feed-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; max-width: 520px; margin-top: 18px; }
.feed-card { border-radius: 12px; overflow: hidden; background: #fff; box-shadow: 0 2px 10px rgba(24,33,29,0.07); }
.feed-cover { aspect-ratio: 3 / 4; background: #ece7df; }
.feed-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.feed-body { padding: 10px 12px 12px; }
.feed-body h3 { margin: 0; font-size: 14px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.feed-body p { margin: 6px 0 0; font-size: 12px; color: #939c97; }
code { background: #f0ebe3; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
</style>
</head>
<body>
<h1>MindSpace 预览图升级 Demo</h1>
<p class="lead">左侧为旧版横版缩略图,中间为新版 3:4 信息流封面(自动从 HTML 提取标题、配色、emoji),右侧为保存页面时实际写入的 thumbnail.svg。下方是小红书风格卡片效果。</p>
<div class="compare">
${cards
.map(
(card, index) => `
<section class="sample">
<h2>样本 ${index + 1}</h2>
<div class="pair">
<div class="thumb"><img src="./${samples[index].id}-legacy.svg" alt="旧版" /><span>旧版(横版字卡)</span></div>
<div class="thumb"><img src="./${samples[index].id}-feed.svg" alt="新版" /><span>新版(3:4 精美封面)</span></div>
<div class="thumb"><img src="./${samples[index].id}-generated.svg" alt="生成结果" /><span>generateHtmlThumbnail 输出</span></div>
</div>
<div class="feed-grid" style="margin-top:18px">${card}</div>
</section>`,
)
.join('')}
</div>
<h1>方案 Amindspace-cover 对比 Demo</h1>
<p class="lead">
页面生成时在 <code>&lt;head&gt;</code> 写入 <code>mindspace-cover</code> 元数据并引用 hero 主图,
缩略图会从「纯色渐变」升级为「照片封面 + 标题」。
交付前可跑 <code>npm run check:mindspace-cover</code> 自检。
</p>
<div class="compare">${sections.join('')}</div>
</body>
</html>`;
await fs.writeFile(path.join(outputDir, 'index.html'), html, 'utf8');
console.log(`已生成预览页: ${path.join(outputDir, 'index.html')}`);
console.log(`已生成对比页: ${path.join(outputDir, 'index.html')}`);
console.log('本地查看: http://localhost:5173/thumbnail-demo/');
}