| 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 |