fde6503bdf
Memind CI / Test, build, and release guards (push) Has been cancelled
Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config. Co-authored-by: Cursor <cursoragent@cursor.com>
193 lines
7.2 KiB
JavaScript
193 lines
7.2 KiB
JavaScript
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { PAGE_TEMPLATE_SKILL_PREFIX, isPageTemplateSkill } from './skills-registry.mjs';
|
||
|
||
const PLACEHOLDER_PATTERN = /\{\{[A-Z0-9_]+\}\}/g;
|
||
|
||
export function resolvePageTemplateHtmlPath(skillName, h5Root = process.cwd()) {
|
||
if (!isPageTemplateSkill(skillName)) return null;
|
||
const templatesDir = path.join(h5Root, 'skills', skillName, 'templates');
|
||
if (!fs.existsSync(templatesDir)) return null;
|
||
const files = fs
|
||
.readdirSync(templatesDir)
|
||
.filter((name) => name.endsWith('.html'))
|
||
.sort();
|
||
if (!files.length) return null;
|
||
return path.join(templatesDir, files[0]);
|
||
}
|
||
|
||
export function extractStyleBlock(html) {
|
||
const match = String(html ?? '').match(/<style[^>]*>([\s\S]*?)<\/style>/i);
|
||
return match?.[1] ?? '';
|
||
}
|
||
|
||
export function normalizeTemplateStyleFingerprint(styleText) {
|
||
return String(styleText ?? '')
|
||
.replace(PLACEHOLDER_PATTERN, 'VALUE')
|
||
.replace(/#[0-9a-fA-F]{3,8}/g, 'VALUE')
|
||
.replace(/url\(['"]?[^'")]+['"]?\)/g, 'url(VALUE)')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
export function extractStyleSelectors(styleText) {
|
||
const selectors = new Set();
|
||
const withoutComments = String(styleText ?? '').replace(/\/\*[\s\S]*?\*\//g, '');
|
||
for (const chunk of withoutComments.split('}')) {
|
||
const idx = chunk.lastIndexOf('{');
|
||
if (idx <= 0) continue;
|
||
const selectorPart = chunk.slice(0, idx).trim();
|
||
if (!selectorPart || selectorPart.startsWith('@')) continue;
|
||
for (const sel of selectorPart.split(',')) {
|
||
const normalized = sel.trim().replace(/\s+/g, ' ');
|
||
if (normalized) selectors.add(normalized);
|
||
}
|
||
}
|
||
return selectors;
|
||
}
|
||
|
||
export function resolveSelectedPageTemplateSkill(messages) {
|
||
for (let index = (Array.isArray(messages) ? messages.length : 0) - 1; index >= 0; index -= 1) {
|
||
const message = messages[index];
|
||
if (message?.role !== 'user') continue;
|
||
const metadata = message?.metadata ?? {};
|
||
const runMetadata = metadata.memindRun ?? metadata.agentRun ?? {};
|
||
const skill = String(runMetadata.selectedChatSkill ?? metadata.selectedChatSkill ?? '').trim();
|
||
if (isPageTemplateSkill(skill)) return skill;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function buildPageTemplateMandatoryInstruction(skillName, h5Root = process.cwd()) {
|
||
const templatePath = resolvePageTemplateHtmlPath(skillName, h5Root);
|
||
const workspaceTemplatePath = templatePath
|
||
? `.agents/skills/${skillName}/templates/${path.basename(templatePath)}`
|
||
: `.agents/skills/${skillName}/templates/*.html`;
|
||
|
||
return [
|
||
`【强制页面模板:${skillName}】`,
|
||
`1. 必须先 load_skill → ${skillName},再 read_file → ${workspaceTemplatePath}`,
|
||
'2. 只能替换模板中的 {{PLACEHOLDER}};禁止重写 <!DOCTYPE>、<style>、header/main/footer 结构',
|
||
'3. 禁止在 <style> 中新增模板没有的 CSS 选择器;正文扩展只能写入占位符对应的 HTML 片段(如 MAIN_CONTENT_HTML)',
|
||
'4. 禁止从零手写新页面或复制 static-page-publish 示例另起炉灶',
|
||
'5. write_file 到 public/*.html 后按 static-page-publish 交付 MindSpace 完整 URL',
|
||
].join('\n');
|
||
}
|
||
|
||
export function buildPageTemplateSkillPromptSuffix() {
|
||
return (
|
||
'【硬性要求】必须先 load_skill 并 read_file 官方 templates/*.html,仅替换 {{PLACEHOLDER}};' +
|
||
'禁止修改 <style> 结构或新增模板外 CSS 类;正文内容只能填入占位符 HTML 片段。' +
|
||
'交付链接必须含 /MindSpace/<用户ID>/public/...,禁止 http://127.0.0.1:5173/public/...。'
|
||
);
|
||
}
|
||
|
||
export function validatePublishedPageAgainstTemplate({
|
||
skillName,
|
||
html,
|
||
h5Root = process.cwd(),
|
||
} = {}) {
|
||
if (!isPageTemplateSkill(skillName)) {
|
||
return { ok: true, skipped: 'not_page_template' };
|
||
}
|
||
const templatePath = resolvePageTemplateHtmlPath(skillName, h5Root);
|
||
if (!templatePath) {
|
||
return { ok: false, reason: 'template_missing', skillName };
|
||
}
|
||
|
||
const pageHtml = String(html ?? '');
|
||
if (PLACEHOLDER_PATTERN.test(pageHtml)) {
|
||
return { ok: false, reason: 'placeholder_left', skillName };
|
||
}
|
||
|
||
const templateHtml = fs.readFileSync(templatePath, 'utf8');
|
||
const templateStyleFingerprint = normalizeTemplateStyleFingerprint(extractStyleBlock(templateHtml));
|
||
const pageStyleFingerprint = normalizeTemplateStyleFingerprint(extractStyleBlock(pageHtml));
|
||
if (templateStyleFingerprint !== pageStyleFingerprint) {
|
||
return {
|
||
ok: false,
|
||
reason: 'style_drift',
|
||
skillName,
|
||
templatePath,
|
||
};
|
||
}
|
||
|
||
if (
|
||
templateHtml.includes('data-mindspace-page-tag="platform-brand"') &&
|
||
!pageHtml.includes('data-mindspace-page-tag="platform-brand"')
|
||
) {
|
||
return {
|
||
ok: false,
|
||
reason: 'missing_platform_brand',
|
||
skillName,
|
||
templatePath,
|
||
};
|
||
}
|
||
|
||
return { ok: true, skillName, templatePath };
|
||
}
|
||
|
||
export function collectPageTemplateViolations({
|
||
messages,
|
||
publishDir,
|
||
syncResult = null,
|
||
h5Root = process.cwd(),
|
||
} = {}) {
|
||
const skillName = resolveSelectedPageTemplateSkill(messages);
|
||
if (!skillName) return [];
|
||
|
||
const relativePaths = new Set(
|
||
(syncResult?.publicHtmlRelativePaths ?? [])
|
||
.map((item) => String(item ?? '').trim())
|
||
.filter((item) => item.toLowerCase().endsWith('.html')),
|
||
);
|
||
for (const message of Array.isArray(messages) ? messages : []) {
|
||
for (const item of message?.content ?? []) {
|
||
if (item?.type !== 'toolRequest') continue;
|
||
const toolCall = item.toolCall?.value;
|
||
const name = String(toolCall?.name ?? '').trim().split('__').at(-1);
|
||
if (!['write_file', 'edit_file', 'write', 'edit'].includes(name)) continue;
|
||
const args = toolCall?.arguments ?? {};
|
||
const relativePath = String(args.path ?? args.file_path ?? '').trim().replace(/^\.\//, '');
|
||
if (relativePath.toLowerCase().endsWith('.html') && relativePath.startsWith('public/')) {
|
||
relativePaths.add(relativePath);
|
||
}
|
||
}
|
||
}
|
||
|
||
const violations = [];
|
||
for (const relativePath of [...relativePaths].sort()) {
|
||
const absolutePath = path.join(path.resolve(String(publishDir ?? '')), relativePath);
|
||
if (!fs.existsSync(absolutePath)) continue;
|
||
const html = fs.readFileSync(absolutePath, 'utf8');
|
||
const result = validatePublishedPageAgainstTemplate({ skillName, html, h5Root });
|
||
if (!result.ok) {
|
||
violations.push({ relativePath, ...result });
|
||
}
|
||
}
|
||
return violations;
|
||
}
|
||
|
||
export function buildPageTemplateRepairPrompt(violations = [], skillName) {
|
||
const lines = [
|
||
`【系统模板合规补正】用户已选择页面模板「${skillName}」,当前 public HTML 未按官方模板交付。`,
|
||
`必须先 load_skill → ${skillName},read_file 官方 templates/*.html,仅替换 {{PLACEHOLDER}} 后重新 write_file。`,
|
||
'禁止保留自定义 <style> 或新增模板外 CSS 类;所有正文只能写入占位符 HTML 片段。',
|
||
];
|
||
for (const item of violations) {
|
||
lines.push('', `- ${item.relativePath}:${item.reason}`);
|
||
if (item.extraSelectors?.length) {
|
||
lines.push(` 多余 CSS 选择器:${item.extraSelectors.join(', ')}`);
|
||
}
|
||
if (item.missingSelectors?.length) {
|
||
lines.push(` 缺失 CSS 选择器:${item.missingSelectors.join(', ')}`);
|
||
}
|
||
if (item.missingStructure?.length) {
|
||
lines.push(` 缺失结构:${item.missingStructure.join(', ')}`);
|
||
}
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
export { isPageTemplateSkill };
|