Files
memind/page-template-enforcement.mjs
T
john fde6503bdf
Memind CI / Test, build, and release guards (push) Has been cancelled
feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
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>
2026-08-10 08:06:12 +08:00

193 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };