返回 oh-my-ppt
index-transition.ts
根目录 / src / main / presentation / html / index-transition.ts
1 import fs from 'fs'
2 import {
3 DEFAULT_INDEX_TRANSITION_CONFIG,
4 INDEX_TRANSITION_TYPES,
5 clampIndexTransitionDuration,
6 normalizeIndexTransitionConfig,
7 normalizeIndexTransitionType,
8 type IndexTransitionConfig,
9 type IndexTransitionType
10 } from '@shared/index-transition'
11 import { serializedWrite } from './write-serialization'
12
13 export {
14 DEFAULT_INDEX_TRANSITION_CONFIG,
15 INDEX_TRANSITION_TYPES,
16 clampIndexTransitionDuration,
17 normalizeIndexTransitionConfig,
18 normalizeIndexTransitionType
19 }
20 export type { IndexTransitionConfig, IndexTransitionType }
21
22 const INDEX_TRANSITION_STYLE_RE =
23 /\n?\s*<style\b[^>]*id=["']ppt-index-transition-style["'][^>]*>[\s\S]*?<\/style>/gi
24 const INDEX_TRANSITION_CONFIG_RE =
25 /\n?\s*<script\b[^>]*id=["']ppt-index-transition-config["'][^>]*>[\s\S]*?<\/script>/gi
26 const INDEX_RUNTIME_SCRIPT_RE =
27 /<script\b[^>]*\bsrc=["'][^"']*assets\/index-runtime\.js(?:[?#][^"']*)?["'][^>]*>\s*<\/script>/i
28 const ANIME_SCRIPT_RE =
29 /<script\b[^>]*\bsrc=["'][^"']*assets\/anime\.v4\.js(?:[?#][^"']*)?["'][^>]*>\s*<\/script>/i
30 const PRESENT_BACKGROUND_STYLE_RE =
31 /\n?\s*<style\b[^>]*id=["']ppt-present-background-style["'][^>]*>[\s\S]*?<\/style>/i
32
33 const PRESENT_BACKGROUND_STYLE = `<style id="ppt-present-background-style">
34 body.present { background: #000000 !important; }
35 body.present .ppt-layout,
36 body.present .ppt-stage,
37 body.present .ppt-preview-viewport {
38 background: #000000 !important;
39 }
40 </style>`
41
42 export function validateIndexShellHtml(content: string): string[] {
43 const errors: string[] = []
44 if (!/<html[\s>]/i.test(content)) errors.push('缺少 <html> 标签')
45 if (!/<body[\s>]/i.test(content)) errors.push('缺少 <body> 标签')
46 if (!/<\/body>/i.test(content)) errors.push('缺少 </body> 闭合标签')
47 if (!/<\/html>/i.test(content)) errors.push('缺少 </html> 闭合标签')
48 if (!/id=["']frameViewport["']/i.test(content)) errors.push('缺少 frameViewport 容器')
49 if (!/id=["']pages-data["']/i.test(content)) errors.push('缺少 pages-data 元数据脚本')
50 if (!/ppt-preview-frame/i.test(content)) errors.push('缺少 .ppt-preview-frame 预览 iframe 壳')
51 if (!/ppt-controls/i.test(content)) errors.push('缺少 .ppt-controls 控制栏')
52
53 const openScriptCount = (content.match(/<script\b/gi) || []).length
54 const closeScriptCount = (content.match(/<\/script>/gi) || []).length
55 if (closeScriptCount < openScriptCount) {
56 errors.push('存在未闭合的 <script> 标签')
57 }
58
59 const pagesDataMatch = content.match(
60 /<script\b[^>]*id=["']pages-data["'][^>]*>([\s\S]*?)<\/script>/i
61 )
62 if (!pagesDataMatch) {
63 errors.push('pages-data 脚本缺失或未闭合')
64 } else {
65 try {
66 const parsed = JSON.parse((pagesDataMatch[1] || '').trim() || '[]')
67 if (!Array.isArray(parsed)) errors.push('pages-data 必须是 JSON 数组')
68 } catch (error) {
69 errors.push(
70 `pages-data JSON 解析失败: ${error instanceof Error ? error.message : String(error)}`
71 )
72 }
73 }
74
75 const hasExternalRuntime = INDEX_RUNTIME_SCRIPT_RE.test(content)
76 const inlineScriptMatches = Array.from(
77 content.matchAll(
78 /<script\b(?![^>]*\bsrc=)(?![^>]*type=["']application\/json["'])[^>]*>([\s\S]*?)<\/script>/gi
79 )
80 )
81 if (inlineScriptMatches.length === 0 && !hasExternalRuntime) {
82 errors.push('缺少主逻辑脚本')
83 }
84
85 for (const [index, match] of inlineScriptMatches.entries()) {
86 const scriptBody = (match[1] || '').trim()
87 if (!scriptBody) {
88 errors.push(`第 ${index + 1} 个内联脚本为空`)
89 continue
90 }
91 try {
92 new Function(scriptBody)
93 } catch (error) {
94 errors.push(
95 `第 ${index + 1} 个内联脚本语法错误: ${error instanceof Error ? error.message : String(error)}`
96 )
97 }
98 }
99
100 if (!hasExternalRuntime && inlineScriptMatches.length > 0) {
101 const mergedInlineScripts = inlineScriptMatches
102 .map((match) => String(match[1] || ''))
103 .join('\n')
104 if (!/hashchange/i.test(mergedInlineScripts)) errors.push('缺少 hashchange 路由监听逻辑')
105 if (!/applyPage/i.test(mergedInlineScripts)) errors.push('缺少 applyPage 页面切换逻辑')
106 if (!/framePool/i.test(mergedInlineScripts)) errors.push('缺少 framePool iframe 池逻辑')
107 }
108
109 return errors
110 }
111
112 export function buildIndexTransitionConfigScript(config: IndexTransitionConfig): string {
113 return `<script id="ppt-index-transition-config" type="application/json">${JSON.stringify(config)}</script>`
114 }
115
116 export function parseIndexTransitionConfig(html: string): IndexTransitionConfig {
117 const match = html.match(
118 /<script\b[^>]*id=["']ppt-index-transition-config["'][^>]*>([\s\S]*?)<\/script>/i
119 )
120 if (!match) return DEFAULT_INDEX_TRANSITION_CONFIG
121 try {
122 const parsed = JSON.parse((match[1] || '').trim() || '{}') as {
123 type?: unknown
124 durationMs?: unknown
125 }
126 return normalizeIndexTransitionConfig(parsed)
127 } catch {
128 return DEFAULT_INDEX_TRANSITION_CONFIG
129 }
130 }
131
132 export function ensureIndexAnimeScript(html: string): string {
133 if (ANIME_SCRIPT_RE.test(html)) return html
134 const animeScript = '<script src="./assets/anime.v4.js"></script>'
135 if (INDEX_RUNTIME_SCRIPT_RE.test(html)) {
136 return html.replace(INDEX_RUNTIME_SCRIPT_RE, `${animeScript}\n $&`)
137 }
138 if (/<\/body>/i.test(html)) {
139 return html.replace(/<\/body>/i, ` ${animeScript}\n </body>`)
140 }
141 return `${html}\n${animeScript}`
142 }
143
144 export function ensureIndexPresentBackgroundStyle(html: string): string {
145 if (PRESENT_BACKGROUND_STYLE_RE.test(html)) return html
146 if (/<\/head>/i.test(html)) {
147 return html.replace(/<\/head>/i, ` ${PRESENT_BACKGROUND_STYLE}\n </head>`)
148 }
149 if (/<\/body>/i.test(html)) {
150 return html.replace(/<\/body>/i, ` ${PRESENT_BACKGROUND_STYLE}\n </body>`)
151 }
152 return `${html}\n${PRESENT_BACKGROUND_STYLE}`
153 }
154
155 export function patchIndexTransitionConfig(
156 html: string,
157 input: { type?: unknown; durationMs?: unknown }
158 ): string {
159 const config = normalizeIndexTransitionConfig(input)
160 const withoutOldArtifacts = html
161 .replace(INDEX_TRANSITION_STYLE_RE, '')
162 .replace(INDEX_TRANSITION_CONFIG_RE, '')
163 const withAnime = ensureIndexAnimeScript(withoutOldArtifacts)
164 const configScript = buildIndexTransitionConfigScript(config)
165 if (INDEX_RUNTIME_SCRIPT_RE.test(withAnime)) {
166 return withAnime.replace(INDEX_RUNTIME_SCRIPT_RE, `${configScript}\n $&`)
167 }
168 if (/<\/body>/i.test(withAnime)) {
169 return withAnime.replace(/<\/body>/i, ` ${configScript}\n </body>`)
170 }
171 if (/<\/head>/i.test(withAnime)) {
172 return withAnime.replace(/<\/head>/i, ` ${configScript}\n </head>`)
173 }
174 return `${withAnime}\n${configScript}`
175 }
176
177 export function carryIndexTransitionConfig(previousHtml: string, nextHtml: string): string {
178 return patchIndexTransitionConfig(nextHtml, parseIndexTransitionConfig(previousHtml))
179 }
180
181 export type IndexShellFileVerification =
182 | { status: 'valid' }
183 | { status: 'missing' }
184 | { status: 'invalid'; errors: string[] }
185
186 const readIndexHtml = async (
187 indexPath: string
188 ): Promise<{ status: 'found'; html: string } | { status: 'missing' }> => {
189 try {
190 return { status: 'found', html: await fs.promises.readFile(indexPath, 'utf-8') }
191 } catch (error) {
192 const code = error && typeof error === 'object' ? (error as NodeJS.ErrnoException).code : undefined
193 if (code === 'ENOENT') return { status: 'missing' }
194 throw error
195 }
196 }
197
198 /** Presentation-owned index shell validation for Agent and non-Agent callers. */
199 export async function verifyIndexShellFile(indexPath: string): Promise<IndexShellFileVerification> {
200 const source = await readIndexHtml(indexPath)
201 if (source.status === 'missing') return source
202 const errors = validateIndexShellHtml(source.html)
203 return errors.length > 0 ? { status: 'invalid', errors } : { status: 'valid' }
204 }
205
206 export type PersistIndexTransitionResult =
207 | { status: 'updated'; config: IndexTransitionConfig }
208 | { status: 'missing' }
209 | { status: 'invalid'; errors: string[] }
210
211 /**
212 * Patch and persist the index transition while preserving the complete index
213 * shell contract. File ownership stays in presentation/, not an Agent tool.
214 */
215 export async function persistIndexTransition(args: {
216 indexPath: string
217 projectDir: string
218 input: { type?: unknown; durationMs?: unknown }
219 }): Promise<PersistIndexTransitionResult> {
220 const source = await readIndexHtml(args.indexPath)
221 if (source.status === 'missing') return source
222 const config = normalizeIndexTransitionConfig(args.input)
223 const html = patchIndexTransitionConfig(source.html, config)
224 const errors = validateIndexShellHtml(html)
225 if (errors.length > 0) return { status: 'invalid', errors }
226 await serializedWrite(args.projectDir, async () => {
227 await fs.promises.writeFile(args.indexPath, html, 'utf-8')
228 })
229 return { status: 'updated', config }
230 }
231
231 lines TYPESCRIPT