返回 oh-my-ppt
page-writer-core.ts
根目录 / src / main / presentation / html / page-writer-core.ts
1 import fs from 'fs'
2 import * as cheerio from 'cheerio'
3 import type { AnyNode } from 'domhandler'
4 import { SLIDE_SIZE_PRESETS, type SlideSizePreset } from '@shared/slide-size'
5 import {
6 isPlaceholderPageHtml,
7 normalizeLegacyDataAnimAttributes,
8 validateHtmlContent,
9 validatePersistedPageHtml
10 } from './html-utils'
11 import {
12 parseChartHeightClass,
13 resolveChartHeightFromNearbyComment
14 } from './chart-height'
15 import { normalizeCreativePageFragment } from './page-fragment-normalizer'
16 import { extractRemoteRuntimeResources } from './resource-policy'
17 import { buildFontHeadTags } from '../fonts/font-registry'
18 import { buildSessionAssetHeadTags } from '../assets/page-assets'
19 import { validateTemplateSkeletonPreserved } from '../templates/template-skeleton-validator'
20 import { serializedWrite } from './write-serialization'
21 import {
22 buildBasePageStyleTag,
23 buildFitScript,
24 DEFAULT_MOTION_SCRIPT,
25 VIDEO_INTERACTION_SCRIPT
26 } from './page-shell'
27 import { buildMasterStyleLink } from './master-link'
28
29 export {
30 buildBasePageStyleTag,
31 buildFitScript,
32 DEFAULT_MOTION_SCRIPT,
33 VIDEO_INTERACTION_SCRIPT
34 } from './page-shell'
35
36 function extractBackgroundStyle(styleAttr: string): string {
37 const declarations = styleAttr
38 .split(';')
39 .map((item) => item.trim())
40 .filter(Boolean)
41 const kept = declarations.filter((decl) => {
42 const normalized = decl.toLowerCase().replace(/\s+/g, ' ')
43 return (
44 normalized.startsWith('background:') ||
45 normalized.startsWith('background-color:') ||
46 normalized.startsWith('background-image:')
47 )
48 })
49 return kept.join('; ')
50 }
51
52 function isBackgroundUtilityClass(cls: string): boolean {
53 const base = cls.split(':').pop() || cls
54 return (
55 base.startsWith('bg-') ||
56 base.startsWith('from-') ||
57 base.startsWith('via-') ||
58 base.startsWith('to-')
59 )
60 }
61
62 export function syncRootBackgroundFromScaffold(html: string): string {
63 try {
64 const $ = cheerio.load(html, { scriptingEnabled: false })
65 const root = $('.ppt-page-root[data-ppt-guard-root="1"]').first()
66 if (!root.length) return html
67
68 const scaffold = root.find('[data-page-scaffold="1"]').first()
69 if (!scaffold.length) return html
70
71 const rootClassRaw = (root.attr('class') || '').trim()
72 const rootClasses = rootClassRaw.split(/\s+/).filter(Boolean)
73 const rootHasBgClass = rootClasses.some((cls) => isBackgroundUtilityClass(cls))
74
75 if (!rootHasBgClass) {
76 const scaffoldClassRaw = (scaffold.attr('class') || '').trim()
77 const scaffoldBgClasses = scaffoldClassRaw
78 .split(/\s+/)
79 .filter(Boolean)
80 .filter((cls) => isBackgroundUtilityClass(cls))
81 if (scaffoldBgClasses.length > 0) {
82 const classSet = new Set(rootClasses)
83 for (const cls of scaffoldBgClasses) classSet.add(cls)
84 root.attr('class', Array.from(classSet).join(' '))
85 }
86 }
87
88 const rootStyleRaw = (root.attr('style') || '').trim()
89 const rootBgStyle = extractBackgroundStyle(rootStyleRaw)
90 if (!rootBgStyle) {
91 const scaffoldStyleRaw = (scaffold.attr('style') || '').trim()
92 const scaffoldBgStyle = extractBackgroundStyle(scaffoldStyleRaw)
93 if (scaffoldBgStyle) {
94 const finalStyle = [rootStyleRaw, scaffoldBgStyle].filter(Boolean).join('; ')
95 root.attr('style', finalStyle)
96 }
97 }
98
99 return $.html()
100 } catch {
101 return html
102 }
103 }
104
105 const PRESET_DIMENSIONS_PATTERN = Array.from(
106 new Set(SLIDE_SIZE_PRESETS.flatMap((preset) => [preset.width, preset.height]))
107 ).join('|')
108 const PRESET_ASPECTS_PATTERN = SLIDE_SIZE_PRESETS.flatMap((preset) => [
109 `${preset.width}\\/${preset.height}`,
110 preset.id === 'wide-16-9'
111 ? '16\\/9'
112 : preset.id === 'vertical-9-16'
113 ? '9\\/16'
114 : preset.id === 'standard-4-3'
115 ? '4\\/3'
116 : preset.id === 'square-1-1'
117 ? '1\\/1'
118 : '3\\/4'
119 ]).join('|')
120
121 const CANVAS_LOCK_CLASS_PATTERNS = [
122 new RegExp(
123 `^(w|h|min-w|min-h|max-w|max-h)-\\[(?:(?:${PRESET_DIMENSIONS_PATTERN})px|100vw|100vh|100dvw|100dvh)\\]$`,
124 'i'
125 ),
126 /^(w|h|min-w|min-h|max-w|max-h)-screen$/i,
127 new RegExp(`^aspect-\\[(?:${PRESET_ASPECTS_PATTERN})\\]$`, 'i'),
128 new RegExp(`^size-\\[(?:${PRESET_DIMENSIONS_PATTERN})px\\]$`, 'i')
129 ]
130
131 function stripCanvasLockClasses(classAttr: string): string {
132 const classes = classAttr.split(/\s+/).filter(Boolean)
133 const kept = classes.filter(
134 (cls) => !CANVAS_LOCK_CLASS_PATTERNS.some((pattern) => pattern.test(cls))
135 )
136 return kept.join(' ')
137 }
138
139 function stripCanvasInlineSizes(styleAttr: string): string {
140 const declarations = styleAttr
141 .split(';')
142 .map((item) => item.trim())
143 .filter(Boolean)
144 const kept = declarations.filter((decl) => {
145 const normalized = decl.toLowerCase().replace(/\s+/g, ' ')
146 const dimensionValuePattern = `(?:${PRESET_DIMENSIONS_PATTERN})px`
147 if (
148 new RegExp(
149 `^(width|min-width|max-width): (${dimensionValuePattern}|100vw|100dvw)$`
150 ).test(normalized)
151 )
152 return false
153 if (
154 new RegExp(
155 `^(height|min-height|max-height): (${dimensionValuePattern}|100vh|100dvh)$`
156 ).test(normalized)
157 )
158 return false
159 return true
160 })
161 return kept.join('; ')
162 }
163
164 const CHART_FRAME_DEFAULT_HEIGHT_CLASS = 'h-[240px]'
165
166 function splitClassNames(classRaw: string): string[] {
167 return classRaw
168 .split(/\s+/)
169 .map((cls) => cls.trim())
170 .filter(Boolean)
171 }
172
173 function classBaseName(cls: string): string {
174 return cls.split(':').pop() || cls
175 }
176
177 function isChartCanvasLayoutClass(cls: string): boolean {
178 const base = classBaseName(cls)
179 return base === 'flex-1' || /^h-/.test(base) || /^min-h-/.test(base) || /^max-h-/.test(base)
180 }
181
182 function isMarginUtilityClass(cls: string): boolean {
183 return /^-?m[trblxy]?-[^\s]+$/.test(classBaseName(cls))
184 }
185
186 function hasFixedChartHeightClass(classes: Iterable<string>): boolean {
187 return Array.from(classes).some((cls) => parseChartHeightClass(classBaseName(cls)) !== null)
188 }
189
190 function isUnstableChartFrameLayoutClass(cls: string): boolean {
191 const base = classBaseName(cls)
192 return (
193 base === 'flex-1' ||
194 (/^h-/.test(base) && parseChartHeightClass(base) === null) ||
195 /^min-h-/.test(base) ||
196 /^max-h-/.test(base)
197 )
198 }
199
200 function hasFixedChartHeightStyle(styleRaw: string): boolean {
201 return /(?:^|;)\s*height\s*:\s*(?!\s*(?:auto|0(?:px|rem|em|%)?|100%|inherit|initial|unset)\b)[^;]+/i.test(
202 styleRaw
203 )
204 }
205
206 function resolveChartFrameHeightClassFromNearbyComment(
207 parent: cheerio.Cheerio<AnyNode>
208 ): string | null {
209 const height = resolveChartHeightFromNearbyComment(parent)
210 return height === null ? null : `h-[${height}px]`
211 }
212
213 /**
214 * Merged single-pass cheerio preprocessing: canvas lock styles, chart stabilization,
215 * and unsafe hidden states. Replaces 3 separate cheerio.load calls with one.
216 */
217 export function preprocessPageHtml(html: string): string {
218 try {
219 const $ = cheerio.load(html.trim(), { scriptingEnabled: false })
220
221 $('[class]').each((_, node) => {
222 const classValue = ($(node).attr('class') || '').trim()
223 if (!classValue) return
224 const cleaned = stripCanvasLockClasses(classValue)
225 if (cleaned.length > 0) {
226 $(node).attr('class', cleaned)
227 } else {
228 $(node).removeAttr('class')
229 }
230 })
231 $('[style]').each((_, node) => {
232 const styleValue = ($(node).attr('style') || '').trim()
233 if (!styleValue) return
234 const cleaned = stripCanvasInlineSizes(styleValue)
235 if (cleaned.length > 0) {
236 $(node).attr('style', cleaned)
237 } else {
238 $(node).removeAttr('style')
239 }
240 })
241
242 $('canvas').each((_, node) => {
243 const canvas = $(node)
244 canvas.removeAttr('width')
245 canvas.removeAttr('height')
246 const originalCanvasClasses = splitClassNames(canvas.attr('class') || '')
247 const wrapperClasses = originalCanvasClasses.filter(isMarginUtilityClass)
248 const canvasClassSet = new Set(
249 originalCanvasClasses.filter(
250 (cls) => !isChartCanvasLayoutClass(cls) && !isMarginUtilityClass(cls)
251 )
252 )
253 canvasClassSet.add('h-full')
254 canvasClassSet.add('w-full')
255 canvas.attr('class', Array.from(canvasClassSet).join(' '))
256
257 const parent = canvas.parent()
258 if (!parent.length) return
259
260 const parentClassRaw = (parent.attr('class') || '').trim()
261 const originalParentClasses = splitClassNames(parentClassRaw)
262 const parentStyle = parent.attr('style') || ''
263 const hasFixedHeightStyle = hasFixedChartHeightStyle(parentStyle)
264 const hasFixedHeightClass = hasFixedChartHeightClass(originalParentClasses)
265 const parentClassSet = new Set(
266 originalParentClasses.filter((cls) => !isUnstableChartFrameLayoutClass(cls))
267 )
268
269 if (!hasFixedHeightClass && !hasFixedHeightStyle) {
270 parentClassSet.add(
271 resolveChartFrameHeightClassFromNearbyComment(parent) || CHART_FRAME_DEFAULT_HEIGHT_CLASS
272 )
273 }
274
275 if (!parentClassSet.has('ppt-chart-frame')) parentClassSet.add('ppt-chart-frame')
276 if (!parentClassSet.has('relative')) parentClassSet.add('relative')
277 if (!parentClassSet.has('overflow-hidden')) parentClassSet.add('overflow-hidden')
278 if (wrapperClasses.length > 0) {
279 for (const cls of wrapperClasses) parentClassSet.add(cls)
280 }
281 parent.attr('class', Array.from(parentClassSet).join(' '))
282 })
283
284 $('video').each((_, node) => {
285 const video = $(node)
286 video.attr('controls', '')
287 video.attr('playsinline', '')
288 if (video.attr('preload') === undefined) {
289 video.attr('preload', 'metadata')
290 }
291 })
292
293 $('*').each((_, node) => {
294 const el = $(node)
295
296 const classRaw = (el.attr('class') || '').trim()
297 if (classRaw) {
298 const kept = classRaw
299 .split(/\s+/)
300 .filter(Boolean)
301 .filter((cls) => {
302 const base = cls.split(':').pop() || cls
303 return base !== 'opacity-0' && base !== 'invisible'
304 })
305 if (kept.length > 0) {
306 el.attr('class', kept.join(' '))
307 } else {
308 el.removeAttr('class')
309 }
310 }
311
312 const styleRaw = (el.attr('style') || '').trim()
313 if (styleRaw) {
314 const keptDecls = styleRaw
315 .split(';')
316 .map((decl) => decl.trim())
317 .filter(Boolean)
318 .filter((decl) => {
319 const idx = decl.indexOf(':')
320 if (idx < 0) return true
321 const key = decl.slice(0, idx).trim().toLowerCase()
322 const value = decl
323 .slice(idx + 1)
324 .trim()
325 .toLowerCase()
326 if (key === 'opacity' && /^0(?:\.0+)?$/.test(value)) return false
327 if (key === 'visibility' && value === 'hidden') return false
328 return true
329 })
330 if (keptDecls.length > 0) {
331 el.attr('style', keptDecls.join('; '))
332 } else {
333 el.removeAttr('style')
334 }
335 }
336 })
337
338 return $.html()
339 } catch {
340 return html
341 }
342 }
343
344 type HtmlContentValidation = ReturnType<typeof validateHtmlContent>
345
346 export type PageWriteValidationFailureKind =
347 | 'remote-resource'
348 | 'content-validation'
349 | 'template-skeleton'
350 | 'persisted-validation'
351
352 /** A presentation-domain validation failure with machine-readable diagnostics for adapters. */
353 export class PageWriteValidationError extends Error {
354 constructor(
355 readonly kind: PageWriteValidationFailureKind,
356 readonly pageId: string,
357 readonly details: readonly string[],
358 message: string
359 ) {
360 super(message)
361 this.name = 'PageWriteValidationError'
362 }
363 }
364
365 const STRUCTURAL_FRAGMENT_ERROR_RE =
366 /HTML 末尾存在未闭合标签|开闭标签数量不一致|闭标签多于开标签|缺少结尾|缺少 <\/body>/i
367
368 function trimTrailingPartialTag(content: string): string {
369 const trimmed = content.trim()
370 if (!/<[^>]*$/.test(trimmed)) return trimmed
371 return trimmed.replace(/<[^>]*$/, '').trim()
372 }
373
374 function repairMalformedCreativeFragment(content: string): string | null {
375 const repairInput = trimTrailingPartialTag(content)
376 if (!repairInput) return null
377 try {
378 const $ = cheerio.load(repairInput, { scriptingEnabled: false }, false)
379 const repaired = ($.root().html() || repairInput).trim()
380 return repaired && repaired !== content.trim() ? repaired : null
381 } catch {
382 return null
383 }
384 }
385
386 export function countHtmlTag(content: string, tagName: string): { open: number; close: number } {
387 const withoutNonStructuralBlocks = content
388 .replace(/<!--[\s\S]*?-->/g, '')
389 .replace(/<script[\s\S]*?<\/script>/gi, '')
390 .replace(/<style[\s\S]*?<\/style>/gi, '')
391 return {
392 open: (withoutNonStructuralBlocks.match(new RegExp(`<${tagName}[\\s>]`, 'gi')) || []).length,
393 close: (withoutNonStructuralBlocks.match(new RegExp(`</${tagName}>`, 'gi')) || []).length
394 }
395 }
396
397 export function validateOrRepairHtmlContent(content: string): {
398 content: string
399 validation: HtmlContentValidation
400 repaired: boolean
401 originalErrors?: string[]
402 } {
403 const validation = validateHtmlContent(content)
404 if (validation.valid) {
405 return { content, validation, repaired: false }
406 }
407
408 const onlyStructuralErrors = validation.errors.every((error) =>
409 STRUCTURAL_FRAGMENT_ERROR_RE.test(error)
410 )
411 if (!onlyStructuralErrors) {
412 return { content, validation, repaired: false }
413 }
414
415 const repairedContent = repairMalformedCreativeFragment(content)
416 if (!repairedContent) {
417 return { content, validation, repaired: false }
418 }
419
420 const repairedValidation = validateHtmlContent(repairedContent)
421 if (!repairedValidation.valid) {
422 return { content, validation: repairedValidation, repaired: false }
423 }
424
425 return {
426 content: repairedContent,
427 validation: repairedValidation,
428 repaired: true,
429 originalErrors: validation.errors
430 }
431 }
432
433 export function replacePageContentFragment(args: {
434 originalHtml: string
435 content: string
436 pageId: string
437 }): { html: string; content: string; repaired: boolean } {
438 const remoteResources = extractRemoteRuntimeResources(args.content)
439 if (remoteResources.length > 0) {
440 throw new Error(
441 `检测到禁止的 CDN/远程资源引用 (${args.pageId}),仅允许使用系统预注入的本地 ./assets/*。`
442 )
443 }
444 const inputContent = normalizeLegacyDataAnimAttributes(args.content)
445 const normalizedFragment = normalizeCreativePageFragment(preprocessPageHtml(inputContent), {
446 blockIdMode: 'strip'
447 })
448 const prepared = validateOrRepairHtmlContent(normalizedFragment)
449 const normalizedValidation = validateHtmlContent(prepared.content)
450 if (!normalizedValidation.valid) {
451 throw new Error(
452 `HTML 验证失败 (${args.pageId}): ${normalizedValidation.errors.join('; ')}。请修正后重试。`
453 )
454 }
455
456 const $ = cheerio.load(args.originalHtml, { scriptingEnabled: false })
457 const contentNode = $('.ppt-page-root[data-ppt-guard-root="1"] .ppt-page-content').first()
458 if (!contentNode.length) {
459 throw new Error(
460 `无法定位页面主体容器 (${args.pageId}):页面骨架已被破坏,请先修复页面后再编辑。`
461 )
462 }
463 contentNode.html(prepared.content)
464 const html = syncRootBackgroundFromScaffold($.html())
465 const persistedValidation = validatePersistedPageHtml(html, args.pageId)
466 if (!persistedValidation.valid) {
467 throw new Error(
468 `HTML 落盘校验失败 (${args.pageId}): ${persistedValidation.errors.join('; ')}。请修正页面片段后重试。`
469 )
470 }
471 return { html, content: inputContent, repaired: prepared.repaired }
472 }
473
474 function hasDataAnim(html: string): boolean {
475 return /\bdata-anim\b/i.test(html)
476 }
477
478 function hasCustomPageAnimation(html: string): boolean {
479 return (
480 /(?:anime\s*\(|anime\.(?:createTimeline|timeline|animate|stagger)\s*\()/m.test(html) ||
481 /PPT\.(?:animate|stagger|createTimeline)\s*\(/m.test(html) ||
482 /data-(?:anime|animate)\b/i.test(html)
483 )
484 }
485
486 async function buildScaffoldDocument(args: {
487 pageId: string
488 pageNumber?: number
489 innerContent: string
490 includeDefaultMotion: boolean
491 projectDir: string
492 designFonts?: { titleFont: string; bodyFont: string }
493 slideSize: SlideSizePreset
494 }): Promise<string> {
495 const { pageId, pageNumber, innerContent, includeDefaultMotion, projectDir, designFonts, slideSize } =
496 args
497 const pageNumberAttribute =
498 typeof pageNumber === 'number' && Number.isFinite(pageNumber) && pageNumber > 0
499 ? ` data-ppt-page-number="${Math.floor(pageNumber)}"`
500 : ''
501 const motionScript = includeDefaultMotion ? `\n ${DEFAULT_MOTION_SCRIPT}` : ''
502 const fontInjection =
503 designFonts
504 ? `\n ${await buildFontHeadTags({ ...designFonts, projectDir })}`
505 : ''
506 return `<!doctype html>
507 <html lang="zh-CN">
508 <head>
509 <meta charset="UTF-8" />
510 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
511 ${buildSessionAssetHeadTags()}${fontInjection}
512 ${buildBasePageStyleTag(slideSize)}
513 ${buildMasterStyleLink()}
514 </head>
515 <body data-page-id="${pageId}"${pageNumberAttribute}>
516 <main class="ppt-page-root" data-ppt-guard-root="1" data-ppt-slide-size-id="${slideSize.id}" data-ppt-width="${slideSize.width}" data-ppt-height="${slideSize.height}"${pageNumberAttribute}>
517 <div class="ppt-page-fit-scope">
518 <div class="ppt-page-content">
519 ${innerContent}
520 </div>
521 </div>
522 </main>
523 ${buildFitScript(slideSize)}
524 ${VIDEO_INTERACTION_SCRIPT}
525 ${motionScript}
526 </body>
527 </html>`
528 }
529
530 export async function normalizeAndInjectPageRuntime(
531 content: string,
532 pageId: string,
533 projectDir: string,
534 slideSize: SlideSizePreset,
535 designFonts?: { titleFont: string; bodyFont: string },
536 pageNumber?: number
537 ): Promise<string> {
538 const fragment = normalizeCreativePageFragment(
539 preprocessPageHtml(normalizeLegacyDataAnimAttributes(content))
540 )
541 const document = await buildScaffoldDocument({
542 pageId,
543 pageNumber,
544 innerContent: fragment,
545 includeDefaultMotion: hasDataAnim(content) || !hasCustomPageAnimation(content),
546 projectDir,
547 slideSize,
548 designFonts
549 })
550 return syncRootBackgroundFromScaffold(document)
551 }
552
553 /**
554 * Turn a creative page fragment into a validated standalone page document.
555 * This capability deliberately stops before filesystem writes; callers own
556 * their domain-specific atomic write and rollback strategy.
557 */
558 export async function buildPersistedPageHtmlFromFragment(args: {
559 content: string
560 pageId: string
561 pageNumber?: number
562 projectDir: string
563 slideSize: SlideSizePreset
564 designFonts?: { titleFont: string; bodyFont: string }
565 }): Promise<{ html: string; content: string; repaired: boolean; originalErrors?: string[] }> {
566 const remoteResources = extractRemoteRuntimeResources(args.content)
567 if (remoteResources.length > 0) {
568 throw new PageWriteValidationError(
569 'remote-resource',
570 args.pageId,
571 remoteResources,
572 [
573 `检测到禁止的 CDN/远程资源引用 (${args.pageId}),已拒绝写入。`,
574 '请移除所有 script/link 的 http(s) 或 // 外链,仅使用系统预注入的本地 ./assets/* 资源。',
575 '示例命中:',
576 ...remoteResources
577 ].join('\n')
578 )
579 }
580 const inputContent = normalizeLegacyDataAnimAttributes(args.content)
581 const prepared = validateOrRepairHtmlContent(inputContent)
582 const normalizedContent = normalizeCreativePageFragment(preprocessPageHtml(prepared.content))
583 const normalizedValidation = validateHtmlContent(normalizedContent)
584 if (!normalizedValidation.valid) {
585 throw new PageWriteValidationError(
586 'content-validation',
587 args.pageId,
588 normalizedValidation.errors,
589 `HTML 验证失败 (${args.pageId}): ${normalizedValidation.errors.join('; ')}。请修正后重试。`
590 )
591 }
592 const html = await normalizeAndInjectPageRuntime(
593 normalizedContent,
594 args.pageId,
595 args.projectDir,
596 args.slideSize,
597 args.designFonts,
598 args.pageNumber
599 )
600 const persistedValidation = validatePersistedPageHtml(html, args.pageId)
601 if (!persistedValidation.valid) {
602 throw new PageWriteValidationError(
603 'persisted-validation',
604 args.pageId,
605 persistedValidation.errors,
606 `HTML 落盘校验失败 (${args.pageId}): ${persistedValidation.errors.join('; ')}。请修正页面片段后重试。`
607 )
608 }
609 return {
610 html,
611 content: prepared.content,
612 repaired: prepared.repaired,
613 originalErrors: prepared.originalErrors
614 }
615 }
616
617 /**
618 * Presentation-owned page persistence capability shared by Agent tools and any
619 * future non-Agent caller. It keeps validation, template-skeleton protection,
620 * and serialized writes out of the Agent adapter layer.
621 */
622 export async function persistPageHtmlFromFragment(args: {
623 content: string
624 pageId: string
625 pageNumber?: number
626 projectDir: string
627 targetPath: string
628 slideSize: SlideSizePreset
629 designFonts?: { titleFont: string; bodyFont: string }
630 preserveTemplateSkeleton?: boolean
631 }): Promise<{ html: string; content: string; repaired: boolean; originalErrors?: string[] }> {
632 const persisted = await buildPersistedPageHtmlFromFragment(args)
633 if (args.preserveTemplateSkeleton) {
634 const beforeHtml = await fs.promises.readFile(args.targetPath, 'utf-8').catch(() => '')
635 const missingTemplateRefs = validateTemplateSkeletonPreserved(beforeHtml, persisted.html)
636 if (missingTemplateRefs.length > 0) {
637 throw new PageWriteValidationError(
638 'template-skeleton',
639 args.pageId,
640 missingTemplateRefs,
641 [
642 `模板骨架资源丢失 (${args.pageId}):${missingTemplateRefs.slice(0, 8).join(', ')}`,
643 '请重新读取目标模板页,把背景图、纹理、装饰图、mask/overlay 或 CSS url(...) 对应结构保留在 update_template_page_file 的 content 中。'
644 ].join(' ')
645 )
646 }
647 }
648 await serializedWrite(args.projectDir, async () => {
649 await fs.promises.writeFile(args.targetPath, persisted.html, 'utf-8')
650 })
651 return persisted
652 }
653
654 export type PresentationPageVerification = {
655 pageId: string
656 filled: boolean
657 hasContent: boolean
658 hasRemoteRuntime: boolean
659 }
660
661 /** Read and validate the persisted presentation pages without leaking fs access to Agent tools. */
662 export async function verifyPresentationPageFiles(args: {
663 pageFileMap: Record<string, string>
664 pageIds: readonly string[]
665 }): Promise<PresentationPageVerification[]> {
666 return Promise.all(
667 args.pageIds.map(async (pageId) => {
668 const pagePath = args.pageFileMap[pageId]
669 if (!pagePath) {
670 return { pageId, filled: false, hasContent: false, hasRemoteRuntime: false }
671 }
672 let content = ''
673 try {
674 content = await fs.promises.readFile(pagePath, 'utf-8')
675 } catch (error) {
676 const code = error && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : undefined
677 if (code === 'ENOENT') {
678 return { pageId, filled: false, hasContent: false, hasRemoteRuntime: false }
679 }
680 throw error
681 }
682 const filled = content.trim().length > 0
683 return {
684 pageId,
685 filled,
686 hasContent: filled && !isPlaceholderPageHtml(content),
687 hasRemoteRuntime: extractRemoteRuntimeResources(content).length > 0
688 }
689 })
690 )
691 }
692
692 lines TYPESCRIPT