返回 oh-my-ppt
style-package.ts
根目录 / src / main / styles / style-package.ts
1 import { cp, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
2 import fs from 'node:fs'
3 import path from 'node:path'
4 import crypto from 'node:crypto'
5 import * as cheerio from 'cheerio'
6
7 const MAX_PREVIEW_HTML_BYTES = 1024 * 1024
8 const PREVIEW_RESOURCE_ATTRIBUTES = new Set([
9 'action',
10 'archive',
11 'background',
12 'cite',
13 'classid',
14 'codebase',
15 'data',
16 'formaction',
17 'href',
18 'icon',
19 'longdesc',
20 'manifest',
21 'ping',
22 'poster',
23 'profile',
24 'src',
25 'usemap',
26 'xlink:href'
27 ])
28
29 export type StyleSource = 'builtin' | 'custom' | 'override'
30
31 export interface StyleImageGeneration {
32 /** Image-model-specific visual direction. Presence declares automatic-image support. */
33 prompt: string
34 }
35
36 export interface StylePackageJson {
37 style: string
38 name: {
39 zh: string
40 en: string
41 }
42 description: string
43 category: string
44 aliases: string[]
45 styleCase: string
46 imageGeneration?: StyleImageGeneration
47 version: string
48 source: StyleSource
49 }
50
51 const MAX_IMAGE_GENERATION_PROMPT_LENGTH = 4000
52
53 export interface StylePackage {
54 dir: string
55 json: StylePackageJson
56 skillMarkdown: string
57 previewPath?: string
58 }
59
60 export function normalizeStyleVersion(value: unknown): string {
61 const raw = String(value ?? '').trim().replace(/^v/i, '')
62 if (!raw) return '1.0.0'
63 const parts = raw
64 .split(/[.-]/)
65 .slice(0, 3)
66 .map((part) => {
67 const parsed = Number.parseInt(part, 10)
68 return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0
69 })
70 while (parts.length < 3) parts.push(0)
71 if (parts.every((part) => part === 0) && !/^0+(?:[.-]0+){0,2}$/.test(raw)) return '1.0.0'
72 return parts.join('.')
73 }
74
75 export function compareStyleVersion(a: string, b: string): number {
76 const aa = normalizeStyleVersion(a).split('.').map((part) => Number(part) || 0)
77 const bb = normalizeStyleVersion(b).split('.').map((part) => Number(part) || 0)
78 for (let index = 0; index < Math.max(aa.length, bb.length, 3); index += 1) {
79 const diff = (aa[index] || 0) - (bb[index] || 0)
80 if (diff !== 0) return diff
81 }
82 return 0
83 }
84
85 export function styleRowToPackageJson(input: {
86 style: string
87 styleName: string
88 styleNameZh?: string
89 styleNameEn?: string
90 description?: string
91 category?: string
92 aliases?: string[] | string
93 source?: StyleSource | string
94 version?: string | number
95 styleCase?: string
96 imageGenerationPrompt?: string
97 }): StylePackageJson {
98 const aliases = Array.isArray(input.aliases)
99 ? input.aliases
100 : typeof input.aliases === 'string'
101 ? parseAliases(input.aliases)
102 : []
103 return {
104 style: normalizeStyleKey(input.style),
105 name: normalizeStyleName(input),
106 description: String(input.description || '').trim(),
107 category: String(input.category || '').trim(),
108 aliases: aliases.map((alias) => String(alias || '').trim()).filter(Boolean),
109 styleCase: String(input.styleCase || '').trim(),
110 imageGeneration: normalizeImageGeneration(input.imageGenerationPrompt),
111 version: normalizeStyleVersion(input.version),
112 source: normalizeSource(input.source)
113 }
114 }
115
116 export async function readStylePackage(styleDir: string): Promise<StylePackage> {
117 const styleJsonPath = path.join(styleDir, 'style.json')
118 const previewPath = path.join(styleDir, 'preview.html')
119 const skillPath = path.join(styleDir, 'SKILL.md')
120 const raw = await readFile(styleJsonPath, 'utf8')
121 const parsed = JSON.parse(raw) as Record<string, unknown>
122 const json = styleRowToPackageJson({
123 style: String(parsed.style || ''),
124 styleName: readLegacyStyleName(parsed),
125 styleNameZh: readLocalizedName(parsed, 'zh'),
126 styleNameEn: readLocalizedName(parsed, 'en'),
127 description: String(parsed.description || ''),
128 category: String(parsed.category || ''),
129 aliases: Array.isArray(parsed.aliases) ? parsed.aliases.map(String) : [],
130 source: String(parsed.source || 'custom'),
131 version: parsed.version as string | number | undefined,
132 styleCase: String(parsed.styleCase || ''),
133 imageGenerationPrompt: readImageGenerationPrompt(parsed.imageGeneration)
134 })
135 validateStylePackageJson(json, styleJsonPath)
136 const skillMarkdown = await readFile(skillPath, 'utf8')
137 validateStyleSkillMarkdown(skillMarkdown, skillPath)
138 if (fs.existsSync(previewPath)) {
139 const previewHtml = await readFile(previewPath, 'utf8')
140 validatePreviewHtml(previewHtml, previewPath)
141 return { dir: styleDir, json, skillMarkdown, previewPath }
142 }
143 return { dir: styleDir, json, skillMarkdown }
144 }
145
146 export async function writeStylePackage(args: {
147 dir: string
148 json: StylePackageJson
149 skillMarkdown: string
150 previewHtml?: string
151 }): Promise<void> {
152 validateStylePackageJson(args.json, path.join(args.dir, 'style.json'))
153 validateStyleSkillMarkdown(args.skillMarkdown, path.join(args.dir, 'SKILL.md'))
154 if (args.previewHtml !== undefined) {
155 validatePreviewHtml(args.previewHtml, path.join(args.dir, 'preview.html'))
156 }
157 await mkdir(args.dir, { recursive: true })
158 await writeFile(
159 path.join(args.dir, 'style.json'),
160 JSON.stringify(args.json, null, 2) + '\n',
161 'utf8'
162 )
163 await writeFile(path.join(args.dir, 'SKILL.md'), args.skillMarkdown.trim() + '\n', 'utf8')
164 if (args.previewHtml !== undefined) {
165 await writeFile(path.join(args.dir, 'preview.html'), args.previewHtml, 'utf8')
166 }
167 }
168
169 export async function listStylePackageDirectories(rootPath: string): Promise<string[]> {
170 if (!fs.existsSync(rootPath)) return []
171 const entries = await readdir(rootPath, { withFileTypes: true })
172 const names: string[] = []
173 for (const entry of entries) {
174 if (!entry.isDirectory()) continue
175 if (!/^[a-z0-9-]{3,40}$/.test(entry.name)) continue
176 if (fs.existsSync(path.join(rootPath, entry.name, 'style.json'))) {
177 names.push(entry.name)
178 }
179 }
180 return names.sort()
181 }
182
183 export async function atomicCopyDirectory(sourceDir: string, destinationDir: string): Promise<void> {
184 const parent = path.dirname(destinationDir)
185 const baseName = path.basename(destinationDir)
186 const token = crypto.randomUUID()
187 const tmpDir = path.join(parent, `.${baseName}-${token}.tmp`)
188 const backupDir = path.join(parent, `.${baseName}-${token}.bak`)
189 await rm(tmpDir, { recursive: true, force: true })
190 await rm(backupDir, { recursive: true, force: true })
191 await cp(sourceDir, tmpDir, { recursive: true })
192 let backupCreated = false
193 try {
194 if (fs.existsSync(destinationDir)) {
195 await rename(destinationDir, backupDir)
196 backupCreated = true
197 }
198 await rename(tmpDir, destinationDir)
199 } catch (error) {
200 await rm(destinationDir, { recursive: true, force: true }).catch(() => undefined)
201 if (backupCreated && fs.existsSync(backupDir) && !fs.existsSync(destinationDir)) {
202 await rename(backupDir, destinationDir).catch(() => undefined)
203 }
204 await rm(tmpDir, { recursive: true, force: true }).catch(() => undefined)
205 throw error
206 }
207 if (backupCreated) {
208 await rm(backupDir, { recursive: true, force: true }).catch(() => undefined)
209 }
210 }
211
212 function validateStylePackageJson(json: StylePackageJson, filePath: string): void {
213 if (!/^[a-z0-9-]{3,40}$/.test(json.style)) throw new Error('Invalid style key at ' + filePath)
214 if (!json.name || typeof json.name !== 'object') throw new Error('name is required at ' + filePath)
215 if (!json.name.zh.trim()) throw new Error('name.zh is required at ' + filePath)
216 if (!json.name.en.trim()) throw new Error('name.en is required at ' + filePath)
217 if (!Array.isArray(json.aliases)) throw new Error('aliases must be an array at ' + filePath)
218 if (!/^\d+\.\d+\.\d+$/.test(json.version)) throw new Error('Invalid style version at ' + filePath)
219 if (!['builtin', 'custom', 'override'].includes(json.source)) {
220 throw new Error('Invalid style source at ' + filePath)
221 }
222 if (json.imageGeneration && !json.imageGeneration.prompt.trim()) {
223 throw new Error('imageGeneration.prompt is required at ' + filePath)
224 }
225 }
226
227 function readImageGenerationPrompt(value: unknown): string {
228 if (typeof value === 'string') return value.trim().slice(0, MAX_IMAGE_GENERATION_PROMPT_LENGTH)
229 if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
230 const prompt = (value as Record<string, unknown>).prompt
231 return typeof prompt === 'string' ? prompt.trim().slice(0, MAX_IMAGE_GENERATION_PROMPT_LENGTH) : ''
232 }
233
234 function normalizeImageGeneration(value: unknown): StyleImageGeneration | undefined {
235 const prompt = readImageGenerationPrompt(value)
236 return prompt ? { prompt } : undefined
237 }
238
239 function validateStyleSkillMarkdown(markdown: string, filePath: string): void {
240 if (!markdown.trim()) throw new Error('SKILL.md is required at ' + filePath)
241 }
242
243 function validatePreviewHtml(html: string, filePath: string): void {
244 if (Buffer.byteLength(html, 'utf8') > MAX_PREVIEW_HTML_BYTES) {
245 throw new Error('preview.html must not exceed 1MB at ' + filePath)
246 }
247 if (!/<!doctype html>|<html[\s>]/i.test(html)) {
248 throw new Error('preview.html must be complete HTML at ' + filePath)
249 }
250
251 const $ = cheerio.load(html)
252 if ($('script').length > 0) {
253 throw new Error('Inline or external script is forbidden at ' + filePath)
254 }
255
256 $('*').each((_, element) => {
257 if (!('attribs' in element)) return
258 const attributes = element.attribs as Record<string, string>
259 for (const [rawName, value] of Object.entries(attributes)) {
260 const name = rawName.toLowerCase()
261 if (name.startsWith('on')) {
262 throw new Error('Inline event handlers are forbidden at ' + filePath)
263 }
264 if (name === 'srcdoc') {
265 throw new Error('iframe srcdoc is forbidden at ' + filePath)
266 }
267 if (PREVIEW_RESOURCE_ATTRIBUTES.has(name)) {
268 validatePreviewReference(value, filePath)
269 } else if (name === 'srcset') {
270 for (const candidate of value.split(',')) {
271 validatePreviewReference(candidate.trim().split(/\s+/)[0] || '', filePath)
272 }
273 }
274 if (name === 'style' || /url\s*\(/i.test(value)) {
275 validatePreviewCss(value, filePath)
276 }
277 }
278 })
279
280 $('style').each((_, element) => validatePreviewCss($(element).html() || '', filePath))
281
282 $('meta[http-equiv]').each((_, element) => {
283 if (($(element).attr('http-equiv') || '').trim().toLowerCase() !== 'refresh') return
284 const content = $(element).attr('content') || ''
285 const refreshUrl = content.match(/(?:^|;)\s*url\s*=\s*(['"]?)(.*?)\1\s*$/i)?.[2]
286 if (refreshUrl) validatePreviewReference(refreshUrl, filePath)
287 })
288 }
289
290 function validatePreviewReference(reference: string, filePath: string): void {
291 const normalized = decodePreviewReference(reference).trim().replace(/\\/g, '/')
292 if (!normalized || normalized.startsWith('#')) return
293 const compactProtocol = normalized.replace(/[\u0000-\u0020\u007f]+/g, '')
294 if (/^[a-z][a-z0-9+.-]*:/i.test(compactProtocol) || compactProtocol.startsWith('//')) {
295 throw new Error('Forbidden preview reference ' + reference + ' at ' + filePath)
296 }
297 if (normalized.startsWith('/')) {
298 throw new Error('Absolute preview asset path is forbidden at ' + filePath)
299 }
300 const pathname = normalized.split(/[?#]/, 1)[0]
301 if (pathname.split('/').includes('..')) {
302 throw new Error('Forbidden preview reference ' + reference + ' at ' + filePath)
303 }
304 }
305
306 function decodePreviewReference(reference: string): string {
307 let decoded = reference
308 for (let index = 0; index < 5; index += 1) {
309 try {
310 const next = decodeURIComponent(decoded)
311 if (next === decoded) break
312 decoded = next
313 } catch {
314 break
315 }
316 }
317 return decoded
318 }
319
320 function validatePreviewCss(css: string, filePath: string): void {
321 const normalized = decodeCssEscapes(css).replace(/\/\*[\s\S]*?\*\//g, '')
322 if (/@import\b/i.test(normalized)) {
323 throw new Error('CSS @import is forbidden at ' + filePath)
324 }
325
326 const cssUrlPattern = /url\(\s*(['"]?)(.*?)\1\s*\)/gi
327 for (const match of normalized.matchAll(cssUrlPattern)) {
328 validatePreviewReference(match[2] || '', filePath)
329 }
330
331 const imageSetPattern = /(?:-webkit-)?image-set\(([\s\S]*?)\)/gi
332 for (const imageSet of normalized.matchAll(imageSetPattern)) {
333 for (const quoted of (imageSet[1] || '').matchAll(/(['"])(.*?)\1/g)) {
334 validatePreviewReference(quoted[2] || '', filePath)
335 }
336 }
337 }
338
339 function decodeCssEscapes(css: string): string {
340 return css.replace(
341 /\\(?:([0-9a-f]{1,6})(?:\r\n|[\t\n\f\r ])?|([^\r\n\f]))/gi,
342 (_match, hex: string | undefined, escaped: string | undefined) => {
343 if (!hex) return escaped || ''
344 const codePoint = Number.parseInt(hex, 16)
345 return codePoint === 0 || codePoint > 0x10ffff ? '\uFFFD' : String.fromCodePoint(codePoint)
346 }
347 )
348 }
349
350 function normalizeStyleKey(value: string): string {
351 const normalized = value.trim().toLowerCase()
352 if (!/^[a-z0-9-]{3,40}$/.test(normalized)) throw new Error('Invalid style key: ' + value)
353 return normalized
354 }
355
356 function normalizeSource(value: unknown): StyleSource {
357 return value === 'builtin' || value === 'override' ? value : 'custom'
358 }
359
360 function normalizeStyleName(input: {
361 style: string
362 styleName: string
363 styleNameZh?: string
364 styleNameEn?: string
365 }): StylePackageJson['name'] {
366 const zh = String(input.styleNameZh || input.styleName || input.style || '').trim()
367 const en = String(input.styleNameEn || '').trim() || titleCaseStyleKey(input.style)
368 return { zh, en }
369 }
370
371 function readLocalizedName(parsed: Record<string, unknown>, locale: 'zh' | 'en'): string {
372 const name = parsed.name
373 if (name && typeof name === 'object' && !Array.isArray(name)) {
374 return String((name as Record<string, unknown>)[locale] || '').trim()
375 }
376 return ''
377 }
378
379 function readLegacyStyleName(parsed: Record<string, unknown>): string {
380 return String(parsed.styleName || parsed.label || parsed.style || '').trim()
381 }
382
383 function titleCaseStyleKey(value: string): string {
384 return String(value || '')
385 .split(/[-_\s]+/)
386 .filter(Boolean)
387 .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
388 .join(' ')
389 }
390
391 function parseAliases(value: string): string[] {
392 try {
393 const parsed = JSON.parse(value) as unknown
394 return Array.isArray(parsed) ? parsed.map(String) : []
395 } catch {
396 return []
397 }
398 }
399
399 lines TYPESCRIPT