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