返回 oh-my-ppt
html-utils.ts
根目录 / src / main / presentation / html / html-utils.ts
1 import * as cheerio from 'cheerio'
2 import {
3 SHARED_PAGE_STYLES_END,
4 SHARED_PAGE_STYLES_START,
5 pageContentEndMarker,
6 pageContentStartMarker
7 } from './page-contract'
8 import { validateDataAnimContract } from '../../animation/data-anim-validator'
9 import {
10 CHART_SKILL_NAME,
11 DATA_ANIM_SKILL_NAME,
12 formatSkillUsageRequirement
13 } from '../../product-skills/contract'
14 import {
15 CHART_FRAME_HEIGHT_COMMENT_MARKER,
16 parseChartHeightClass,
17 resolveChartHeightFromNearbyComment
18 } from './chart-height'
19 import { normalizeDataAnimTrigger } from '@shared/element-animation'
20
21 // ── HTML parsing ──
22
23 export const extractBodyHtml = (html: string): string => {
24 const $ = cheerio.load(html, { scriptingEnabled: false })
25 $('script').remove()
26 const bodyHtml = $('body').html()
27 return (bodyHtml || '').trim()
28 }
29
30 export const extractStyleCss = (html: string): string =>
31 (html.match(/<style[^>]*>([\s\S]*?)<\/style>/i)?.[1] || '').trim()
32
33 export const normalizePageCss = (css: string): string =>
34 css
35 .replace(/body\s*\{/g, '.ppt-page-root {')
36 .replace(/\s+$/g, '')
37 .trim()
38
39 export const unwrapCss = (input: string): string => {
40 const styleMatch = input.match(/<style[^>]*>([\s\S]*?)<\/style>/i)
41 return normalizePageCss((styleMatch?.[1] || input).trim())
42 }
43
44 // ── Marker-based replacement ──
45
46 export const replaceBetweenMarkers = (
47 source: string,
48 startMarker: string,
49 endMarker: string,
50 replacement: string
51 ): string | null => {
52 const startIndex = source.indexOf(startMarker)
53 const endIndex = source.indexOf(endMarker)
54 if (startIndex < 0 || endIndex < 0 || endIndex < startIndex) {
55 return null // marker block not found, caller should handle
56 }
57 const before = source.slice(0, startIndex + startMarker.length)
58 const after = source.slice(endIndex)
59 return `${before}\n${replacement.trim()}\n${after}`
60 }
61
62 // ── Validation ──
63
64 // Tags that should be strictly balanced (any imbalance is an error)
65 const STRICT_TAGS = [
66 'div',
67 'section',
68 'main',
69 'ul',
70 'ol',
71 'li',
72 'table',
73 'thead',
74 'tbody',
75 'tr',
76 'p',
77 'h1',
78 'h2',
79 'h3',
80 'h4',
81 'h5',
82 'h6',
83 'article',
84 'header',
85 'footer',
86 'aside',
87 'figure',
88 'figcaption',
89 'blockquote'
90 ]
91
92 const SCRIPT_SRC_RE = /<script[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi
93 const INLINE_SCRIPT_RE = /<script\b(?![^>]*\bsrc\s*=)([^>]*)>([\s\S]*?)<\/script>/gi
94 const REMOTE_SCRIPT_OR_LINK_RE =
95 /<(script|link)\b[^>]*(?:src|href)\s*=\s*["'](?:https?:)?\/\/[^"']+["'][^>]*>/i
96 const HIDDEN_STYLE_RULE_RE =
97 /(?:^|[;}])\s*[^{}]+\{\s*[^{}]*(?:opacity\s*:\s*0(?:\.0+)?|visibility\s*:\s*hidden)[^{}]*\}/i
98 const CHART_LABELS_ARRAY_RE = /\blabels\s*:\s*\[([\s\S]*?)\]/gi
99 const HTML_TAG_IN_STRING_RE = /<\s*\/?\s*[a-z][^>]*>/i
100 export const PAGE_PLACEHOLDER_TEXT = '等待模型填充这一页内容'
101
102 export const isPlaceholderPageHtml = (html: string): boolean =>
103 html.includes(PAGE_PLACEHOLDER_TEXT) || /data-placeholder-page\s*=\s*["']1["']/i.test(html)
104
105 const LEGACY_DATA_ANIM_TYPE_ALIASES: Record<string, string> = {
106 'fade-in': 'fade',
107 'fade-in-up': 'fade-up',
108 'fade-in-down': 'fade-down',
109 'fade-in-left': 'fade-left',
110 'fade-in-right': 'fade-right'
111 }
112
113 /**
114 * Normalize only the legacy animation spellings the editor already understands.
115 * Persisted pages remain strict: unknown values still fail validateDataAnimContract.
116 */
117 export const normalizeLegacyDataAnimAttributes = (html: string): string => {
118 try {
119 const $ = cheerio.load(html, { scriptingEnabled: false }, false)
120 $('[data-anim]').each((_index, node) => {
121 const raw = ($(node).attr('data-anim') || '').trim().toLowerCase()
122 const normalized = LEGACY_DATA_ANIM_TYPE_ALIASES[raw]
123 if (normalized) $(node).attr('data-anim', normalized)
124 })
125 $('[data-anim-trigger]').each((_index, node) => {
126 const normalized = normalizeDataAnimTrigger($(node).attr('data-anim-trigger'))
127 if (normalized) $(node).attr('data-anim-trigger', normalized)
128 })
129 return $.root().html() || html
130 } catch {
131 return html
132 }
133 }
134
135 const getInlineScriptSyntaxErrors = (html: string): string[] => {
136 const errors: string[] = []
137 let scriptIndex = 0
138 for (const match of html.matchAll(INLINE_SCRIPT_RE)) {
139 const attrs = match[1] || ''
140 const type = attrs.match(/\btype\s*=\s*["']([^"']+)["']/i)?.[1]?.trim().toLowerCase()
141 if (type && type !== 'text/javascript' && type !== 'application/javascript') {
142 continue
143 }
144 const scriptBody = (match[2] || '').trim()
145 if (!scriptBody) continue
146 scriptIndex += 1
147 try {
148 new Function(scriptBody)
149 } catch (error) {
150 const message = error instanceof Error ? error.message : String(error)
151 errors.push(`第 ${scriptIndex} 个内联 script 语法错误:${message}`)
152 }
153 }
154 return errors
155 }
156
157 // All explicit h-[Npx] heights on the frame, as positive pixel values. Deliberately
158 // NOT range-clamped: the marker/class contract is "must match", so an out-of-range
159 // class (e.g. h-[100px]) still counts and is compared against the marker instead of
160 // being silently dropped as "missing".
161 const getFixedChartHeightClasses = (classRaw: string): number[] =>
162 classRaw
163 .split(/\s+/)
164 .map((cls) => cls.split(':').pop() || cls)
165 .map(parseChartHeightClass)
166 .filter((value): value is number => value !== null)
167
168 const getChartHeightMarkerMismatchErrors = (html: string): string[] => {
169 const errors: string[] = []
170 try {
171 const $ = cheerio.load(html, { scriptingEnabled: false })
172 $('canvas').each((index, node) => {
173 const parent = $(node).parent()
174 if (!parent.length) return
175 const markerHeight = resolveChartHeightFromNearbyComment(parent)
176 if (!markerHeight) return
177 const classHeights = getFixedChartHeightClasses(parent.attr('class') || '')
178 if (classHeights.length === 0 || classHeights.includes(markerHeight)) return
179 const actual = classHeights.map((height) => `h-[${height}px]`).join(', ')
180 errors.push(
181 `第 ${index + 1} 个图表高度标记 ${CHART_FRAME_HEIGHT_COMMENT_MARKER}=${markerHeight} 与图表框 class 不一致:${actual}`
182 )
183 })
184 } catch {
185 // Structural parse errors are reported by the existing HTML parser checks.
186 }
187 return errors
188 }
189
190 const getVisibleChartHeightMarkerErrors = (html: string): string[] => {
191 const withoutComments = html.replace(/<!--[\s\S]*?-->/g, '')
192 if (!new RegExp(`${CHART_FRAME_HEIGHT_COMMENT_MARKER}\\s*=`, 'i').test(withoutComments)) {
193 return []
194 }
195 return [
196 `图表高度标记 ${CHART_FRAME_HEIGHT_COMMENT_MARKER}=N 必须写在 HTML 注释中,不能作为可见文本放进图表框。`
197 ]
198 }
199
200 const getChartHtmlLabelErrors = (html: string): string[] => {
201 const errors: string[] = []
202 let scriptIndex = 0
203 for (const match of html.matchAll(INLINE_SCRIPT_RE)) {
204 scriptIndex += 1
205 const scriptBody = match[2] || ''
206 if (!/PPT\.createChart\s*\(|new\s+Chart\s*\(/i.test(scriptBody)) continue
207 for (const labelsMatch of scriptBody.matchAll(CHART_LABELS_ARRAY_RE)) {
208 const labelsSource = labelsMatch[1] || ''
209 if (!HTML_TAG_IN_STRING_RE.test(labelsSource)) continue
210 errors.push(
211 `第 ${scriptIndex} 个图表 labels 包含 HTML 标签。Chart.js 不会渲染 <br>/<span>,请使用纯文本标签、字符串数组换行,或 tooltip/注释承载补充信息。`
212 )
213 break
214 }
215 }
216 return errors
217 }
218
219 const isAllowedRuntimeAsset = (src: string): boolean => {
220 const normalized = src.trim().toLowerCase()
221 const clean = normalized.split('?')[0].split('#')[0]
222 return (
223 clean.endsWith('/assets/anime.v4.js') ||
224 clean.endsWith('./assets/anime.v4.js') ||
225 clean.endsWith('assets/anime.v4.js') ||
226 clean.endsWith('/assets/ppt-runtime.js') ||
227 clean.endsWith('./assets/ppt-runtime.js') ||
228 clean.endsWith('assets/ppt-runtime.js') ||
229 clean.endsWith('/assets/chart.v4.js') ||
230 clean.endsWith('./assets/chart.v4.js') ||
231 clean.endsWith('assets/chart.v4.js') ||
232 clean.endsWith('/assets/tailwindcss.v3.js') ||
233 clean.endsWith('./assets/tailwindcss.v3.js') ||
234 clean.endsWith('assets/tailwindcss.v3.js') ||
235 clean.endsWith('/assets/katex/katex.min.js') ||
236 clean.endsWith('./assets/katex/katex.min.js') ||
237 clean.endsWith('assets/katex/katex.min.js') ||
238 clean.endsWith('/assets/katex/katex-auto-render.min.js') ||
239 clean.endsWith('./assets/katex/katex-auto-render.min.js') ||
240 clean.endsWith('assets/katex/katex-auto-render.min.js')
241 )
242 }
243
244 export const validateHtmlContent = (html: string): { valid: boolean; errors: string[] } => {
245 const errors: string[] = []
246 const animationCallScanHtml = html.replace(
247 /\bdata-anim-delay\s*=\s*(["'])stagger\s*\(\s*\d+\s*\)\1/gi,
248 'data-anim-delay=$1__DATA_ANIM_STAGGER__$1'
249 )
250 const hasUnqualifiedCall = (fnName: string): boolean =>
251 new RegExp(`(^|[^\\w$.])${fnName}\\s*\\(`, 'm').test(animationCallScanHtml)
252 if (!html || html.trim().length === 0) {
253 errors.push('HTML 内容为空')
254 return { valid: false, errors }
255 }
256 // Creative fragment mode: content must be a fragment, while write tools add page semantics.
257 if (/<!doctype[\s>]/i.test(html)) {
258 errors.push('检测到 <!doctype>。请仅传页面片段,不要传完整文档。')
259 }
260 if (/<html[\s>]/i.test(html) || /<\/html>/i.test(html)) {
261 errors.push('检测到 <html> 标签。请仅传页面片段,不要传完整文档。')
262 }
263 if (/<head[\s>]/i.test(html) || /<\/head>/i.test(html)) {
264 errors.push('检测到 <head> 标签。请仅传页面片段,不要传完整文档。')
265 }
266 if (/<body[\s>]/i.test(html) || /<\/body>/i.test(html)) {
267 errors.push('检测到 <body> 标签。请仅传页面片段,不要传完整文档。')
268 }
269 if (/<meta[\s>]/i.test(html)) {
270 errors.push('检测到 <meta> 标签。页面片段中禁止包含 head 元信息。')
271 }
272 if (/<title[\s>]/i.test(html) || /<\/title>/i.test(html)) {
273 errors.push('检测到 <title> 标签。页面片段中禁止包含标题标签。')
274 }
275 if (/<link\b[^>]*>/i.test(html)) {
276 errors.push('检测到 <link> 标签。页面片段中禁止引入字体或外部资源,字体由系统统一注入。')
277 }
278 if (/@font-face\b/i.test(html)) {
279 errors.push('检测到 @font-face。页面片段中禁止声明字体,字体由系统统一注入。')
280 }
281 if (/url\(\s*["']?(?:https?:)?\/\//i.test(html)) {
282 errors.push('检测到远程 CSS URL。页面片段中禁止引入远程字体或样式资源。')
283 }
284 if (/data-ppt-guard-root\s*=\s*["']1["']/i.test(html)) {
285 errors.push('检测到 data-ppt-guard-root。禁止传入页面骨架根节点,请仅传主体片段。')
286 }
287 if (
288 /\bppt-page-root\b/i.test(html) ||
289 /\bppt-page-content\b/i.test(html) ||
290 /\bppt-page-fit-scope\b/i.test(html)
291 ) {
292 errors.push('检测到页面骨架类(ppt-page-root/content/fit-scope)。请仅传主体片段。')
293 }
294 if (/<script[^>]*id=["']ppt-(?:page-fit|default-motion|page-guard-style)["'][^>]*>/i.test(html)) {
295 errors.push('检测到内置运行时脚本/样式块。请不要自行注入,系统会自动注入。')
296 }
297 if (/<iframe[\s>]/gi.test(html)) {
298 errors.push('内容中包含 iframe 标签,页面内不允许嵌套 iframe')
299 }
300 const scriptSrcHits = Array.from(html.matchAll(SCRIPT_SRC_RE)).map((m) => (m[1] || '').trim())
301 const disallowedScriptSrc = scriptSrcHits.filter((src) => !isAllowedRuntimeAsset(src))
302 if (disallowedScriptSrc.length > 0) {
303 const preview = disallowedScriptSrc.slice(0, 3).join(', ')
304 errors.push(`检测到不允许的 script src:${preview}。页面片段禁止引入脚本资源,运行时已预注入。`)
305 }
306 errors.push(...getInlineScriptSyntaxErrors(html))
307 errors.push(...getVisibleChartHeightMarkerErrors(html))
308 errors.push(...getChartHeightMarkerMismatchErrors(html))
309 errors.push(...getChartHtmlLabelErrors(html))
310 errors.push(...validateDataAnimContract(html).errors)
311 if (/anime\s*\(\s*\{[\s\S]{0,240}?targets\s*:/im.test(html)) {
312 errors.push(`检测到旧版 anime({ targets, ... }) 写法;修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
313 }
314 if (/(^|[^\w$])anime\.(?:animate|stagger|createTimeline|timeline)\s*\(/i.test(html)) {
315 errors.push(`检测到直接 anime.* 调用;修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
316 }
317 if (/\banime\.(?:svg\.)?(?:createMotionPath|createDrawable|morphTo)\s*\(/i.test(html)) {
318 errors.push(`检测到 anime 的 SVG/path/morph 高级能力;这些能力当前属于 preview-only 方向,不应进入标准可编辑页面。修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
319 }
320 if (/\b(?:anime\.)?splitText\s*\(/i.test(html)) {
321 errors.push(`检测到 splitText 文本碎片动画;该能力当前属于 preview-only 方向,不应进入标准可编辑页面。修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
322 }
323 if (/PPT\.animate\s*\(\s*\{[\s\S]{0,240}?targets\s*:/im.test(html)) {
324 errors.push(`检测到 PPT.animate({ targets, ... }) 写法;修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
325 }
326 if (
327 hasUnqualifiedCall('animate') ||
328 hasUnqualifiedCall('stagger') ||
329 hasUnqualifiedCall('createTimeline')
330 ) {
331 errors.push(`检测到未命名空间的动画调用(animate/stagger/createTimeline);修改动画前请先 ${formatSkillUsageRequirement(DATA_ANIM_SKILL_NAME)}`)
332 }
333 if (/new\s+Chart\s*\(/i.test(html)) {
334 errors.push(
335 `检测到直接 new Chart(...) 调用;修改图表前请先 ${formatSkillUsageRequirement(CHART_SKILL_NAME)}`
336 )
337 }
338 if (/addEventListener\s*\(\s*['"](?:ppt-ready|ppt-rendered|ppt-page-ready)['"]/i.test(html)) {
339 errors.push(
340 `检测到自定义事件(ppt-ready/ppt-rendered/ppt-page-ready)绑定 chart 代码,这些事件运行时不会触发。请改用 DOMContentLoaded。${formatSkillUsageRequirement(CHART_SKILL_NAME)}`
341 )
342 }
343 if (/PPT\.createChart/i.test(html) && !/DOMContentLoaded/i.test(html)) {
344 errors.push(
345 `PPT.createChart 未包裹在 DOMContentLoaded 回调中,图表可能无法渲染。${formatSkillUsageRequirement(CHART_SKILL_NAME)}`
346 )
347 }
348 if (/<[^>]*$/.test(html.trim())) {
349 errors.push('HTML 末尾存在未闭合标签,内容可能被截断')
350 }
351 const normalized = html.trim()
352 if (/<html[\s>]/i.test(normalized) && !/<\/html>\s*$/i.test(normalized)) {
353 errors.push('检测到 <html> 但缺少结尾 </html>,内容可能被截断')
354 }
355 if (/<body[\s>]/i.test(normalized) && !/<\/body>/i.test(normalized)) {
356 errors.push('检测到 <body> 但缺少 </body>,内容可能被截断')
357 }
358
359 // Remove comments/script/style to avoid counting pseudo tags in JS/CSS/comment text.
360 const structuralHtml = html
361 .replace(/<!--[\s\S]*?-->/g, '')
362 .replace(/<script[\s\S]*?<\/script>/gi, '')
363 .replace(/<style[\s\S]*?<\/style>/gi, '')
364
365 // Check for orphan closing tags (closing tag without a matching open)
366 for (const tag of STRICT_TAGS) {
367 const opens = (structuralHtml.match(new RegExp(`<${tag}[\\s>]`, 'gi')) || []).length
368 const closes = (structuralHtml.match(new RegExp(`</${tag}>`, 'gi')) || []).length
369 if (opens < closes) {
370 errors.push(`</${tag}> 闭标签多于开标签(${opens} 个开, ${closes} 个闭),可能是内容被截断`)
371 } else if (opens !== closes) {
372 errors.push(`<${tag}> 开闭标签数量不一致(${opens} 个开, ${closes} 个闭),内容可能被截断`)
373 }
374 }
375 try {
376 const $ = cheerio.load(html, { scriptingEnabled: false })
377 const blockIds = new Map<string, number>()
378 $('[data-block-id]').each((_, node) => {
379 const id = ($(node).attr('data-block-id') || '').trim()
380 if (!id) return
381 blockIds.set(id, (blockIds.get(id) || 0) + 1)
382 })
383 const duplicatedBlockIds = Array.from(blockIds.entries())
384 .filter(([, count]) => count > 1)
385 .map(([id]) => id)
386 if (duplicatedBlockIds.length > 0) {
387 errors.push(`data-block-id 必须唯一,重复项:${duplicatedBlockIds.join(', ')}`)
388 }
389 } catch {
390 errors.push('HTML 片段结构解析失败')
391 }
392 return { valid: errors.length === 0, errors }
393 }
394
395 export const validatePersistedPageHtml = (
396 html: string,
397 pageId: string
398 ): { valid: boolean; errors: string[] } => {
399 const errors: string[] = []
400 if (!html || html.trim().length === 0) {
401 return { valid: false, errors: [`${pageId}.html 内容为空`] }
402 }
403 if (isPlaceholderPageHtml(html)) {
404 errors.push('仍包含页面占位文案')
405 }
406 const $ = cheerio.load(html, { scriptingEnabled: false })
407 errors.push(...getInlineScriptSyntaxErrors(html))
408 errors.push(...getVisibleChartHeightMarkerErrors(html))
409 errors.push(...getChartHeightMarkerMismatchErrors(html))
410 errors.push(...getChartHtmlLabelErrors(html))
411 errors.push(...validateDataAnimContract(html).errors)
412 if (REMOTE_SCRIPT_OR_LINK_RE.test(html)) {
413 errors.push('包含远程资源引用(字体已改为本地加载,禁止 CDN 链接)')
414 }
415 $('style').each((_, node) => {
416 const el = $(node)
417 const css = el.text()
418 const fontMarker = el.attr('data-ppt-fonts')
419 if (/@font-face\b/i.test(css) && fontMarker !== 'user' && fontMarker !== 'google') {
420 errors.push('@font-face 只能由系统字体注入块声明')
421 return false
422 }
423 if (/url\(\s*["']?(?:https?:)?\/\//i.test(css)) {
424 errors.push('样式块中包含远程 URL')
425 return false
426 }
427 if (/url\(\s*"(?!\.\/assets\/fonts\/user-fonts\/)[^)]+/i.test(css) && fontMarker === 'user') {
428 errors.push('@font-face 只能引用 ./assets/fonts/user-fonts/ 下的字体文件')
429 return false
430 }
431 if (/url\(\s*"(?!\.\/assets\/fonts\/google-fonts\/)[^)]+/i.test(css) && fontMarker === 'google') {
432 errors.push('Google 字体只能引用 ./assets/fonts/google-fonts/ 下的字体文件')
433 return false
434 }
435 return undefined
436 })
437 $('style').each((_, node) => {
438 const css = $(node).text()
439 if (HIDDEN_STYLE_RULE_RE.test(css)) {
440 errors.push('样式块包含默认隐藏态规则,可能导致内容不可见')
441 return false
442 }
443 return undefined
444 })
445 $('[class], [style]').each((_, node) => {
446 const el = $(node)
447 const classRaw = el.attr('class') || ''
448 const styleRaw = el.attr('style') || ''
449 const animation = (el.attr('data-anim') || '').trim().toLowerCase()
450 const hasEnteringAnimation = Boolean(animation) && !animation.startsWith('exit-')
451 if (/\binvisible\b/i.test(classRaw)) {
452 errors.push('包含默认隐藏态 class,可能导致内容不可见')
453 return false
454 }
455 if (/\bopacity-0\b/i.test(classRaw) && !hasEnteringAnimation) {
456 errors.push('包含默认隐藏态 class,可能导致内容不可见')
457 return false
458 }
459 if (/visibility\s*:\s*hidden/i.test(styleRaw)) {
460 errors.push('包含默认隐藏态 style,可能导致内容不可见')
461 return false
462 }
463 if (/opacity\s*:\s*0(?:\.0+)?(?:\s*!important)?(?:;|$)/i.test(styleRaw) && !hasEnteringAnimation) {
464 errors.push('包含默认隐藏态 style,可能导致内容不可见')
465 return false
466 }
467 return undefined
468 })
469 const root = $('.ppt-page-root[data-ppt-guard-root="1"]').first()
470 if (!root.length) {
471 errors.push('缺少 .ppt-page-root[data-ppt-guard-root="1"]')
472 }
473 const content = $('.ppt-page-content').first()
474 if (!content.length) {
475 errors.push('缺少 .ppt-page-content')
476 }
477 const blockIds = new Map<string, number>()
478 $('[data-block-id]').each((_, node) => {
479 const id = ($(node).attr('data-block-id') || '').trim()
480 if (!id) return
481 blockIds.set(id, (blockIds.get(id) || 0) + 1)
482 })
483 const duplicatedBlockIds = Array.from(blockIds.entries())
484 .filter(([, count]) => count > 1)
485 .map(([id]) => id)
486 if (duplicatedBlockIds.length > 0) {
487 errors.push(`data-block-id 重复:${duplicatedBlockIds.join(', ')}`)
488 }
489
490 $('video').each((index, node) => {
491 const video = $(node)
492 const missingAttrs = ['controls', 'playsinline'].filter(
493 (attr) => video.attr(attr) === undefined
494 )
495 if (missingAttrs.length > 0) {
496 errors.push(`第 ${index + 1} 个 video 缺少属性:${missingAttrs.join(', ')}`)
497 }
498 const preload = (video.attr('preload') || '').toLowerCase()
499 if (preload && !['metadata', 'auto', 'none'].includes(preload)) {
500 errors.push(`第 ${index + 1} 个 video 的 preload 只能是 metadata、auto 或 none`)
501 }
502 })
503
504 return { valid: errors.length === 0, errors }
505 }
506
507 // ── Section content normalization ──
508
509 export const normalizeSectionContent = (pageId: string, html: string): string => {
510 const trimmed = html.trim()
511 const bodyHtml = extractBodyHtml(trimmed)
512 const css = extractStyleCss(trimmed)
513 const normalizedBody = (bodyHtml || trimmed).trim()
514 const normalizedCss = normalizePageCss(css)
515 if (!normalizedCss) return normalizedBody
516 return `<style data-page-style="${pageId}">
517 ${normalizedCss}
518 </style>
519 ${normalizedBody}`
520 }
521
522 // ── Re-export markers for convenience ──
523
524 export {
525 SHARED_PAGE_STYLES_START,
526 SHARED_PAGE_STYLES_END,
527 pageContentStartMarker,
528 pageContentEndMarker
529 }
530
530 lines TYPESCRIPT