@playcraft/devkit 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- "use strict";process.env.BABEL_ENV="production";process.env.NODE_ENV="production";const fs=require("fs");const path=require("path");const{runBuild}=require("../../core/webpack.build.js");const{validateThemeData}=require("../../core/utils/validateThemeData");/**
1
+ "use strict";process.env.BABEL_ENV="production";process.env.NODE_ENV="production";const fs=require("fs");const path=require("path");const{runBuild}=require("../../core/webpack.build.js");const{runThemeValidation,restoreSchema}=require("../../core/utils/validateThemeData");/**
2
2
  * Detect the current theme name from the theme entry file.
3
3
  * Supports patterns like:
4
4
  * export { default } from './themeName';
@@ -8,4 +8,6 @@
8
8
  const buildsConfigPath=path.join(projectRoot,"builds.config.js");let themeConfig={sourceDir:"src/theme",entryFile:"src/theme/index.ts"};if(fs.existsSync(buildsConfigPath)){try{delete require.cache[buildsConfigPath];const config=require(buildsConfigPath);if(config.themes){themeConfig={...themeConfig,...config.themes}}}catch(e){// ignore
9
9
  }}const entryPath=path.join(projectRoot,themeConfig.entryFile);if(!fs.existsSync(entryPath)){return{themeName:null,themeConfig}}const content=fs.readFileSync(entryPath,"utf8");// Match: export { default } from './themeName' or import ... from './themeName'
10
10
  const match=content.match(/from\s+['"]\.\/([^'"]+)['"]/);if(match){return{themeName:match[1],themeConfig}}return{themeName:null,themeConfig}}// --- Main ---
11
- const projectRoot=process.cwd();const{themeName,themeConfig}=detectCurrentTheme(projectRoot);if(themeName){const themeBaseDir=path.join(projectRoot,themeConfig.sourceDir||"src/theme");const schemaPath=path.join(themeBaseDir,"theme.schema.json5");if(fs.existsSync(schemaPath)){console.log(`\x1b[36m[build] 校验主题数据: ${themeName}\x1b[0m`);const{merged,errors}=validateThemeData(themeName,projectRoot,themeConfig);if(errors){console.error(`\x1b[31m[build] 主题 "${themeName}" 数据校验失败:\x1b[0m`);errors.forEach(e=>console.error(`\x1b[31m - ${e}\x1b[0m`));process.exit(1)}console.log(`\x1b[32m[build] 主题 "${themeName}" 数据校验通过`)}}runBuild();
11
+ const projectRoot=process.cwd();const{themeName,themeConfig}=detectCurrentTheme(projectRoot);let schemaPathToRestore=null;if(themeName){const buildLog=(msg,level)=>{const colors={info:"\x1B[36m",success:"\x1B[32m",error:"\x1B[31m"};const reset="\x1B[0m";const color=colors[level]||"";console.log(`${color}[build] ${msg}${reset}`)};const{valid,schemaPath}=runThemeValidation(themeName,projectRoot,themeConfig,{log:buildLog});schemaPathToRestore=schemaPath;if(!valid){// Restore schema on validation failure (backup may have been created)
12
+ if(schemaPathToRestore)restoreSchema(schemaPathToRestore);process.exit(1)}}// Run build, then always restore schema regardless of success/failure
13
+ runBuild().then(()=>{if(schemaPathToRestore)restoreSchema(schemaPathToRestore)}).catch(err=>{if(schemaPathToRestore)restoreSchema(schemaPathToRestore);console.error(err);process.exit(1)});
@@ -19,25 +19,35 @@ return path.basename(projectRoot)}/**
19
19
  * @returns {string[]} Array of normalized ignore patterns
20
20
  */function parseSingleIgnoreFile(filePath){if(!fs.existsSync(filePath)){return[]}const content=fs.readFileSync(filePath,"utf8");const patterns=[];for(const rawLine of content.split(/\r?\n/)){const line=rawLine.trim();// Skip empty lines and comments
21
21
  if(!line||line.startsWith("#"))continue;// Warn about negation patterns (not supported)
22
- if(line.startsWith("!")){console.log(`\x1b[33m⚠️ Negation pattern not supported, skipping: ${line}\x1b[0m`);continue}// Normalize: remove trailing slashes for directory matching
23
- const normalized=line.replace(/\/+$/,"");patterns.push(normalized)}return patterns}/**
22
+ if(line.startsWith("!")){console.log(`\x1b[33m⚠️ Negation pattern not supported, skipping: ${line}\x1b[0m`);continue}// Normalize:
23
+ // - remove trailing slashes for directory matching
24
+ // - remove leading ./ (gitignore treats ./foo same as foo)
25
+ let normalized=line.replace(/\/+$/,"");normalized=normalized.replace(/^\.\//g,"");patterns.push(normalized)}return patterns}/**
24
26
  * Parse .gitignore and .playcraftignore files, merge and deduplicate patterns.
25
27
  * .gitignore provides base ignore rules, .playcraftignore provides additional ones.
26
28
  * @returns {{ patterns: string[], sources: { gitignore: number, playcraftignore: number } }}
27
29
  */function parseIgnoreFiles(){const gitignorePath=path.join(projectRoot,".gitignore");const playcraftignorePath=path.join(projectRoot,".playcraftignore");const gitignorePatterns=parseSingleIgnoreFile(gitignorePath);const playcraftPatterns=parseSingleIgnoreFile(playcraftignorePath);if(gitignorePatterns.length===0&&playcraftPatterns.length===0){console.log("\x1B[33m\u26A0\uFE0F No .gitignore or .playcraftignore file found, packing all files.\x1B[0m");return{patterns:[],sources:{gitignore:0,playcraftignore:0}}}// Merge and deduplicate (preserve order, .gitignore first)
28
30
  const seen=new Set;const merged=[];for(const p of[...gitignorePatterns,...playcraftPatterns]){if(!seen.has(p)){seen.add(p);merged.push(p)}}return{patterns:merged,sources:{gitignore:gitignorePatterns.length,playcraftignore:playcraftPatterns.length}}}/**
29
31
  * Convert a gitignore-like pattern to a RegExp.
32
+ *
33
+ * Gitignore semantics:
34
+ * - Pattern without `/` → matches against the basename anywhere in the tree
35
+ * - Pattern with `/` (e.g. `scripts/foo`) → anchored to project root
36
+ * - Leading `/` means "root only" and is stripped before regex conversion
37
+ * - Leading `./` is treated the same as `/` (already stripped in parse phase)
38
+ *
30
39
  * @param {string} pattern
31
40
  * @returns {RegExp}
32
- */function patternToRegex(pattern){// Escape regex special chars except * and ?
33
- let regexStr=pattern.replace(/[.+^${}()|[\]\\]/g,"\\$&");// Handle ** (match any path segment including nested)
41
+ */function patternToRegex(pattern){let p=pattern;let anchored=false;// Leading / means root-anchored; strip it for regex building
42
+ if(p.startsWith("/")){p=p.slice(1);anchored=true}// If the pattern contains a /, it is also root-anchored
43
+ if(p.includes("/")){anchored=true}// Escape regex special chars except * and ?
44
+ let regexStr=p.replace(/[.+^${}()|[\]\\]/g,"\\$&");// Handle ** (match any path segment including nested)
34
45
  regexStr=regexStr.replace(/\*\*/g,"{{GLOBSTAR}}");// Handle * (match anything except path separator)
35
46
  regexStr=regexStr.replace(/\*/g,"[^/]*");// Handle ?
36
47
  regexStr=regexStr.replace(/\?/g,"[^/]");// Restore globstar
37
- regexStr=regexStr.replace(/\{\{GLOBSTAR\}\}/g,".*");// If pattern doesn't contain /, match against basename or full path
38
- if(!pattern.includes("/")){// Match as a path segment anywhere: either at start or after /
39
- return new RegExp(`(^|/)${regexStr}(/|$)`)}// Pattern with / — match from the start of the relative path
40
- return new RegExp(`^${regexStr}(/|$)`)}/**
48
+ regexStr=regexStr.replace(/\{\{GLOBSTAR\}\}/g,".*");if(anchored){// Anchored: match from the start of the relative path
49
+ return new RegExp(`^${regexStr}(/|$)`)}// Unanchored: match as a path segment anywhere in the tree
50
+ return new RegExp(`(^|/)${regexStr}(/|$)`)}/**
41
51
  * Check if a relative path should be ignored.
42
52
  * @param {string} relativePath - Forward-slash normalized relative path
43
53
  * @param {RegExp[]} regexPatterns
@@ -7,20 +7,21 @@
7
7
  * 2. 每个构建任务使用独立的临时目录(dist-temp-{taskId})
8
8
  * 3. 构建完成后自动移动到目标目录并清理临时目录
9
9
  * 4. 支持灵活的配置系统
10
- */const{execSync,exec}=require("child_process");const fs=require("fs");const path=require("path");const os=require("os");const{promisify}=require("util");const{createCOSClient,uploadDirectoryToCOS,uploadFileToCOS,purgeCDNCache}=require("./cos-uploader");const{validateThemeData}=require("./utils/validateThemeData");const execAsync=promisify(exec);/**
10
+ */const{execSync,exec}=require("child_process");const fs=require("fs");const path=require("path");const os=require("os");const{promisify}=require("util");const{fetchTemporaryCredentials,createCOSClient,uploadDirectoryToCOS,uploadFileToCOS,purgeCDNCache}=require("./cos-uploader");const{runThemeValidation,restoreSchema}=require("./utils/validateThemeData");const execAsync=promisify(exec);/**
11
11
  * 更新 preview.json 文件
12
- */async function updatePreviewJson(cosConfig,cosPrefix,currentTheme,previewLinks){const previewJsonKey=cosPrefix?`${cosPrefix}/preview.json`:"preview.json";// 读取现有的 preview.json 文件
13
- let existingPreviewData={};try{const cos=createCOSClient(cosConfig);const getObjectResult=await new Promise((resolve,reject)=>{cos.getObject({Bucket:cosConfig.Bucket,Region:cosConfig.Region,Key:previewJsonKey},(err,data)=>{if(err){if(err.statusCode===404){// 文件不存在,使用空对象
12
+ */async function updatePreviewJson(cosConfig,cosPrefix,currentTheme,previewLinks,credentials=null){const previewJsonKey=cosPrefix?`${cosPrefix}/preview.json`:"preview.json";// 如果没有传入凭证,尝试获取一次
13
+ if(!credentials){try{credentials=await fetchTemporaryCredentials()}catch(error){log(`获取临时凭证失败,跳过 preview.json 更新: ${error.message}`,"warn");return}}// 读取现有的 preview.json 文件
14
+ let existingPreviewData={};try{const cos=await createCOSClient(null,credentials);const getObjectResult=await new Promise((resolve,reject)=>{cos.getObject({Bucket:credentials.bucket,Region:credentials.region,Key:previewJsonKey},(err,data)=>{if(err){if(err.statusCode===404){// 文件不存在,使用空对象
14
15
  resolve(null)}else{reject(err)}}else{resolve(data)}})});if(getObjectResult&&getObjectResult.Body){existingPreviewData=JSON.parse(getObjectResult.Body.toString())}}catch(error){// 如果读取失败,使用空对象继续
15
- console.log(`[${new Date().toLocaleTimeString()}] 🔍 读取现有 preview.json 失败,将创建新文件: ${error.message}`)}// 更新预览数据
16
+ log(`读取现有 preview.json 失败,将创建新文件: ${error.message}`,"debug")}// 更新预览数据
16
17
  const updatedPreviewData={...existingPreviewData};// 获取当前时间
17
18
  const currentTime=new Date().toISOString();// 更新当前主题的预览链接
18
19
  const currentThemeLinks=previewLinks.filter(link=>link.theme===currentTheme);currentThemeLinks.forEach(link=>{const fileName=link.file.replace(/\.[^.]+$/,"");// 去掉文件扩展名
19
20
  updatedPreviewData[fileName]={url:link.url,update_time:currentTime}});// 将更新后的数据写入临时文件
20
21
  const tempDir=path.join(os.tmpdir(),"playable-preview");if(!fs.existsSync(tempDir)){fs.mkdirSync(tempDir,{recursive:true})}const tempPreviewPath=path.join(tempDir,"preview.json");fs.writeFileSync(tempPreviewPath,JSON.stringify(updatedPreviewData,null,2),"utf8");// 上传更新后的 preview.json 文件
21
- await uploadFileToCOS(cosConfig,tempPreviewPath,previewJsonKey);// 刷新 CDN 缓存
22
- const cdnDomain=cosConfig.CdnDomain;if(cdnDomain){const previewJsonUrl=`https://${cdnDomain}/${previewJsonKey}`;try{await purgeCDNCache(cosConfig,[previewJsonUrl]);console.log(`[${new Date().toLocaleTimeString()}] ✅ CDN 缓存已刷新: ${previewJsonUrl}`)}catch(error){console.log(`[${new Date().toLocaleTimeString()}] ⚠️ CDN 缓存刷新失败: ${error.message}`)}}// 清理临时文件
23
- fs.unlinkSync(tempPreviewPath);console.log(`[${new Date().toLocaleTimeString()}] ✅ preview.json 文件已更新并上传`)}// ==================== 默认配置 ====================
22
+ await uploadFileToCOS(null,tempPreviewPath,previewJsonKey,credentials);// 刷新 CDN 缓存
23
+ const cdnDomain="static.aix.intlgame.com";const previewJsonUrl=`https://${cdnDomain}/${previewJsonKey}`;try{await purgeCDNCache(null,[previewJsonUrl],credentials);log(`✓ CDN 缓存已刷新: ${previewJsonUrl}`,"success")}catch(error){log(`⚠️ CDN 缓存刷新失败: ${error.message}`,"warn")}// 清理临时文件
24
+ fs.unlinkSync(tempPreviewPath);log(`✓ preview.json 文件已更新并上传`,"success")}// ==================== 默认配置 ====================
24
25
  /**
25
26
  * 计算默认并发数
26
27
  * 策略: CPU 核心数 - 1,最小为 2,最大为 8
@@ -32,9 +33,7 @@ fs.unlinkSync(tempPreviewPath);console.log(`[${new Date().toLocaleTimeString()}]
32
33
  const parallel=Math.min(8,calculated);// 最多 8 个并发
33
34
  return parallel}const DEFAULT_CONFIG={title:"Phaser Game",appName:"",// 用于从远程仓库拉取配置
34
35
  channels:["google","facebook","tiktok","applovin","ironsource","unity","moloco","liftoff","bigoads"],themes:{enabled:true,sourceDir:"src/theme",entryFile:"src/theme/index.ts",whitelist:null},naming:{language:"EN",format:"\u65E0",supplier:"AIX",follower:"ZQL"},build:{outputDir:"dist-all",tempDirPrefix:"dist-temp",continueOnError:true,cleanTemp:true,parallel:getDefaultParallel()// 根据 CPU 核心数自动设置
35
- },// COS 配置(可选)
36
- cosConfig:null// 如果配置,则自动为每个主题生成 preview 渠道并上传
37
- };// ==================== 工具函数 ====================
36
+ }};// ==================== 工具函数 ====================
38
37
  let isExiting=false;let backupContent=null;let backupPath=null;const activeTempDirs=new Set;// 跟踪所有活动的临时目录
39
38
  function log(message,level="info"){const timestamp=new Date().toLocaleTimeString();const colors={info:"\x1B[36m",success:"\x1B[32m",error:"\x1B[31m",warn:"\x1B[33m",debug:"\x1B[90m"};const reset="\x1B[0m";const prefix={info:"\uD83D\uDCE6",success:"\u2705",error:"\u274C",warn:"\u26A0\uFE0F",debug:"\uD83D\uDD0D"}[level]||"\u2139\uFE0F";console.log(`${colors[level]}[${timestamp}] ${prefix} ${message}${reset}`)}function getCurrentMonth(){const month=new Date().getMonth()+1;return`${month}月交付`}function getCurrentDate(){const date=new Date;const year=date.getFullYear();const month=String(date.getMonth()+1).padStart(2,"0");const day=String(date.getDate()).padStart(2,"0");return`${year}${month}${day}`}function defaultFileNameGenerator(config,themeName,channel){const parts=[themeName,config.naming.language,config.naming.format,getCurrentMonth(),getCurrentDate(),config.naming.supplier,config.naming.follower,channel.toLowerCase()].filter(item=>!!item);return parts.join("-")}function getAllThemes(config,projectRoot){const themesDir=path.join(projectRoot,config.themes.sourceDir);if(!fs.existsSync(themesDir)){log(`主题目录不存在: ${themesDir}`,"warn");return[]}const entries=fs.readdirSync(themesDir,{withFileTypes:true});let themes=entries.filter(entry=>entry.isDirectory()).map(entry=>entry.name).sort();if(config.themes.whitelist&&typeof config.themes.whitelist==="function"){themes=themes.filter(config.themes.whitelist);log(`应用主题白名单过滤: ${themes.length} 个主题`,"debug")}return themes}function backupEntryFile(config,projectRoot){const entryPath=path.join(projectRoot,config.themes.entryFile);if(!fs.existsSync(entryPath)){throw new Error(`入口文件不存在: ${entryPath}`)}backupPath=entryPath;backupContent=fs.readFileSync(entryPath,"utf8");log(`已备份入口文件: ${config.themes.entryFile}`,"debug")}function restoreEntryFile(){if(backupPath&&backupContent){fs.writeFileSync(backupPath,backupContent,"utf8");log(`已恢复入口文件`,"debug");backupPath=null;backupContent=null}}function switchTheme(config,themeName,projectRoot){const entryPath=path.join(projectRoot,config.themes.entryFile);// 使用配置文件中的 generateEntryFile 函数(如果提供)
40
39
  let content;if(config.themes.switching&&typeof config.themes.switching.generateEntryFile==="function"){content=config.themes.switching.generateEntryFile(themeName)}else{// 兼容旧格式:简化的 export 语句
@@ -84,7 +83,7 @@ else if(currentArg.startsWith("--channels=")){result.channels=currentArg.substri
84
83
  else if(currentArg.startsWith("--parallel=")){result.parallel=parseInt(currentArg.substring(11),10)}// 支持 --is-channel-fold2zip 参数
85
84
  else if(currentArg==="--is-channel-fold2zip"){result.isChannelFold2Zip=true}// 兼容位置参数:第一个参数作为主题,第二个作为渠道
86
85
  else if(!currentArg.startsWith("-")&&result.themes===null){result.themes=[currentArg]}else if(!currentArg.startsWith("-")&&result.themes!==null&&result.channels===null){result.channels=[currentArg]}}return result}// 远程配置仓库地址
87
- const REMOTE_CONFIG_REPO="";/**
86
+ const REMOTE_CONFIG_REPO="https://git.woa.com/maclerylin/playable-build-config.git";/**
88
87
  * 执行命令并返回结果
89
88
  * @param {string} command - 要执行的命令
90
89
  * @param {object} options - execSync 选项
@@ -95,16 +94,10 @@ const REMOTE_CONFIG_REPO="";/**
95
94
  *
96
95
  * @param {string} appName - 应用名称,对应远程仓库中的文件夹名
97
96
  * @returns {Promise<object|null>} 远程配置对象,失败返回 null
98
- */async function fetchRemoteConfig(appName){if(!appName||!REMOTE_CONFIG_REPO)return null;const os=require("os");// 使用系统临时目录
99
- const tempDir=path.join(os.tmpdir(),`playable-config-${Date.now()}`);log(`尝试从远程仓库拉取配置: ${appName}`,"info");log(`仓库地址: ${REMOTE_CONFIG_REPO}`,"debug");try{// 创建临时目录
100
- fs.mkdirSync(tempDir,{recursive:true});// 克隆远程仓库到临时目录
101
- log("\u514B\u9686\u8FDC\u7A0B\u914D\u7F6E\u4ED3\u5E93...","info");const cloneResult=execCommand(`git clone --depth 1 --quiet "${REMOTE_CONFIG_REPO}" "${tempDir}"`,{timeout:30000});if(cloneResult===null){log("git clone \u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u7F51\u7EDC\u8FDE\u63A5\u548C\u4ED3\u5E93\u6743\u9650","error");return null}log("\u2713 \u8FDC\u7A0B\u914D\u7F6E\u4ED3\u5E93\u514B\u9686\u6210\u529F","success");// 检查 appName 对应的文件夹是否存在
102
- const appConfigDir=path.join(tempDir,appName);if(!fs.existsSync(appConfigDir)){log(`远程仓库中未找到 "${appName}" 文件夹`,"warn");// 列出可用的配置文件夹供参考
103
- const availableDirs=fs.readdirSync(tempDir).filter(name=>{const itemPath=path.join(tempDir,name);return fs.statSync(itemPath).isDirectory()&&!name.startsWith(".")});if(availableDirs.length>0){log(`可用的 appName: ${availableDirs.join(", ")}`,"info")}return null}// 检查配置文件是否存在
104
- const configFilePath=path.join(appConfigDir,"build.config.js");if(!fs.existsSync(configFilePath)){log(`"${appName}" 文件夹中未找到 build.config.js`,"warn");return null}// 读取配置文件内容并解析
105
- // 使用 vm 模块执行,避免 require 缓存问题
106
- const vm=require("vm");const configContent=fs.readFileSync(configFilePath,"utf-8");const sandbox={module:{exports:{}},exports:{},require:require};sandbox.exports=sandbox.module.exports;try{vm.runInNewContext(configContent,sandbox);log(`✓ 成功加载远程配置: ${appName}`,"success");return sandbox.module.exports}catch(parseError){log(`解析远程配置文件失败: ${parseError.message}`,"error");return null}}catch(error){log(`获取远程配置失败: ${error.message}`,"warn");return null}finally{// 无论成功与否,都删除临时目录
107
- if(fs.existsSync(tempDir)){try{fs.rmSync(tempDir,{recursive:true,force:true});log("\u5DF2\u6E05\u7406\u4E34\u65F6\u76EE\u5F55","debug")}catch(cleanupError){log(`清理临时目录失败: ${cleanupError.message}`,"debug")}}}}/**
97
+ */async function fetchRemoteConfig(appName){if(!appName)return null;const https=require("https");const http=require("http");const url=require("url");// 构建远程配置文件 URL
98
+ const configUrl=`https://wd-act.woa.com/aix/playable-build-config/${encodeURIComponent(appName)}/build.config.js`;log(`尝试从远程 URL 获取配置: ${appName}`,"info");log(`URL: ${configUrl}`,"debug");try{// 发起 HTTP 请求获取配置文件
99
+ const configContent=await new Promise((resolve,reject)=>{const parsedUrl=url.parse(configUrl);const protocol=parsedUrl.protocol==="https:"?https:http;const request=protocol.get(configUrl,response=>{if(response.statusCode!==200){reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));return}let data="";response.on("data",chunk=>data+=chunk);response.on("end",()=>resolve(data))});request.on("error",reject);request.setTimeout(30000,()=>{request.destroy();reject(new Error("Request timeout"))})});log("\u2713 \u8FDC\u7A0B\u914D\u7F6E\u6587\u4EF6\u4E0B\u8F7D\u6210\u529F","success");// 使用 vm 模块执行配置文件,避免 require 缓存问题
100
+ const vm=require("vm");const sandbox={module:{exports:{}},exports:{},require:require};sandbox.exports=sandbox.module.exports;try{vm.runInNewContext(configContent,sandbox);log(`✓ 成功加载远程配置: ${appName}`,"success");return sandbox.module.exports}catch(parseError){log(`解析远程配置文件失败: ${parseError.message}`,"error");log(`配置内容预览: ${configContent.substring(0,200)}...`,"debug");return null}}catch(error){log(`获取远程配置失败: ${error.message}`,"warn");return null}}/**
108
101
  * 加载配置文件
109
102
  *
110
103
  * 优先级: 本地 builds.config.js > 远程配置 > build.json > 默认值
@@ -146,23 +139,27 @@ const args=parseArgs();// 应用命令行参数
146
139
  if(args.parallel!==null){config.build.parallel=args.parallel}// 是否启用渠道折叠为 zip 模式
147
140
  const isChannelFold2Zip=args.isChannelFold2Zip;if(isChannelFold2Zip){log("\u5DF2\u542F\u7528 --is-channel-fold2zip \u6A21\u5F0F\uFF1A\u6240\u6709\u6E20\u9053\u8F93\u51FA\u5C06\u538B\u7F29\u4E3A zip \u6587\u4EF6","info")}// 确定主题列表
148
141
  let themes;if(!config.themes.enabled){themes=[config.title.replace(/\s+/g,"-")]}else if(args.themes){themes=args.themes;log(`使用命令行指定的主题: ${themes.join(", ")}`,"info")}else{themes=getAllThemes(config,projectRoot);if(themes.length===0){throw new Error("\u672A\u627E\u5230\u4EFB\u4F55\u4E3B\u9898")}}// 确定渠道列表
149
- let channels=args.channels||config.channels;// 检查是否需要生成预览链接
150
- const needPreview=config.cosConfig&&config.cosConfig.enabled!==false&&config.cosConfig.SecretId&&config.cosConfig.SecretKey&&config.cosConfig.Bucket&&config.cosConfig.Region&&config.cosConfig.CdnDomain;// 如果配置了 COS,添加 preview 渠道
151
- if(needPreview){if(!channels.includes("preview")){channels=[...channels,"preview"];log("\u68C0\u6D4B\u5230 COS \u914D\u7F6E\uFF0C\u5DF2\u6DFB\u52A0 preview \u6E20\u9053","info")}}// 打印摘要
142
+ let channels=args.channels||config.channels;// 始终添加 preview 渠道,用于收集上传文件
143
+ if(!channels.includes("preview")){channels=[...channels,"preview"];log("\u5DF2\u6DFB\u52A0 preview \u6E20\u9053","info")}// 打印摘要
152
144
  printSummary(config,themes,channels);// 备份入口文件(如果启用主题切换)
153
145
  if(config.themes.enabled){backupEntryFile(config,projectRoot)}// 批量构建
154
146
  const outputDir=path.join(projectRoot,config.build.outputDir);const allResults=[];const previewLinks=[];// 存储预览链接
155
147
  // 批量构建前,清理一下目录
156
- cleanTempDir(outputDir);for(const theme of themes){// 切换主题
157
- if(config.themes.enabled){switchTheme(config,theme,projectRoot)}// Validate and merge theme data (schema + default + theme override)
158
- const themeBaseDir=path.join(projectRoot,config.themes.sourceDir||"src/theme");const schemaPath=path.join(themeBaseDir,"theme.schema.json5");if(fs.existsSync(schemaPath)){log(`校验主题数据: ${theme}`,"info");const{merged,errors}=validateThemeData(theme,projectRoot,config.themes);if(errors){log(`主题 "${theme}" 数据校验失败:`,"error");errors.forEach(e=>log(` - ${e}`,"error"));if(!config.build.continueOnError){throw new Error(`Theme data validation failed for "${theme}"`)}// Record failures for all channels of this theme and skip building
159
- channels.forEach(channel=>{allResults.push({success:false,theme,channel,error:"Theme data validation failed"})});continue}log(`主题 "${theme}" 数据校验通过`,"success")}// 并发构建该主题的所有渠道
160
- const themeResults=await buildChannelsInParallel(config,theme,channels,outputDir,projectRoot,isChannelFold2Zip);allResults.push(...themeResults);// 如果需要预览链接,上传 preview 渠道产物到 COS
161
- if(needPreview){const previewResult=themeResults.find(r=>r.channel==="preview"&&r.success);if(previewResult){try{const previewDir=path.join(outputDir,theme,"preview");// 去掉 theme 这一级目录,直接使用 PathPrefix
162
- const cosPrefix=config.cosConfig.PathPrefix||"";log(`开始上传 ${theme} 预览文件到 COS...`,"info");const uploadResults=await uploadDirectoryToCOS(config.cosConfig,previewDir,cosPrefix);// 保存预览链接
163
- uploadResults.forEach(result=>{previewLinks.push({theme,file:result.file,url:result.url})});log(`✓ ${theme} 预览文件上传成功`,"success");// 更新 preview.json 文件
148
+ cleanTempDir(outputDir);// 获取临时凭证,用于所有主题的上传操作
149
+ let credentials=null;try{credentials=await fetchTemporaryCredentials();log("\u2713 \u83B7\u53D6\u4E34\u65F6\u51ED\u8BC1\u6210\u529F","success")}catch(error){log(`获取临时凭证失败: ${error.message},将跳过所有上传操作`,"warn")}for(const theme of themes){// 切换主题
150
+ if(config.themes.enabled){switchTheme(config,theme,projectRoot)}// Validate theme data and prepare schema (backup + overwrite with merged data)
151
+ const{valid,errors,schemaPath}=runThemeValidation(theme,projectRoot,config.themes,{log});if(!valid){// Restore schema if backup was created
152
+ if(schemaPath)restoreSchema(schemaPath);if(!config.build.continueOnError){throw new Error(`Theme data validation failed for "${theme}"`)}// Record failures for all channels of this theme and skip building
153
+ channels.forEach(channel=>{allResults.push({success:false,theme,channel,error:"Theme data validation failed"})});continue}// 并发构建该主题的所有渠道
154
+ let themeResults;try{themeResults=await buildChannelsInParallel(config,theme,channels,outputDir,projectRoot,isChannelFold2Zip)}finally{// Always restore schema after building this theme
155
+ if(schemaPath)restoreSchema(schemaPath)}allResults.push(...themeResults);// 上传 preview 渠道产物到 COS
156
+ const previewResult=themeResults.find(r=>r.channel==="preview"&&r.success);if(previewResult&&credentials){try{const previewDir=path.join(outputDir,theme,"preview");// 去掉 theme 这一级目录,直接使用 PathPrefix
157
+ const cosPrefix=config.PathPrefix||"";log(`开始上传 ${theme} 预览文件到 COS...`,"info");const uploadResults=await uploadDirectoryToCOS(null,// 不传递 cosConfig,使用传入的凭证
158
+ previewDir,cosPrefix,credentials// 传入凭证
159
+ );// 保存预览链接
160
+ uploadResults.forEach(result=>{previewLinks.push({theme,file:result.file,url:result.url})});log(`✓ ${theme} 预览文件上传成功`,"success");// 更新 preview.json 文件(使用同一个凭证)
164
161
  try{// 只传递当前主题的预览链接
165
- const currentThemeLinks=previewLinks.filter(link=>link.theme===theme);await updatePreviewJson(config.cosConfig,cosPrefix,theme,currentThemeLinks)}catch(error){log(`更新 preview.json 失败: ${error.message}`,"warn")}}catch(error){log(`上传 ${theme} 预览文件失败: ${error.message}`,"error")}}}// 打印进度
162
+ const currentThemeLinks=previewLinks.filter(link=>link.theme===theme);await updatePreviewJson(null,cosPrefix,theme,currentThemeLinks,credentials)}catch(error){log(`更新 preview.json 失败: ${error.message}`,"warn")}}catch(error){log(`上传 ${theme} 预览文件失败: ${error.message}`,"error")}}// 打印进度
166
163
  const completedBuilds=allResults.length;const totalBuilds=themes.length*channels.length;const progress=Math.round(completedBuilds/totalBuilds*100);log(`进度: ${completedBuilds}/${totalBuilds} (${progress}%)`,"success")}// 恢复入口文件
167
164
  if(config.themes.enabled){restoreEntryFile()}// 确保清理所有临时目录
168
165
  if(config.build.cleanTemp){cleanAllTempDirs()}// 统计结果
@@ -4,21 +4,29 @@
4
4
  *
5
5
  * 功能:上传文件到腾讯云 COS,并刷新 CDN 缓存
6
6
  */const COS=require("cos-nodejs-sdk-v5");const fs=require("fs");const path=require("path");/**
7
- * 创建 COS 客户端
8
- */function createCOSClient(cosConfig){if(!cosConfig||!cosConfig.SecretId||!cosConfig.SecretKey){throw new Error("COS \u914D\u7F6E\u7F3A\u5931\uFF1A\u9700\u8981 SecretId \u548C SecretKey")}return new COS({SecretId:cosConfig.SecretId,SecretKey:cosConfig.SecretKey})}/**
7
+ * 从远程 API 获取临时凭证
8
+ */async function fetchTemporaryCredentials(){const https=require("https");const credentialsUrl="https://wd-act.woa.com/aix/playable/api/cos/credentials";return new Promise((resolve,reject)=>{https.get(credentialsUrl,response=>{if(response.statusCode!==200){reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`));return}let data="";response.on("data",chunk=>data+=chunk);response.on("end",()=>{try{const result=JSON.parse(data);if(result.code==="SUCCESS"){resolve(result.data)}else{reject(new Error(result.message||"Failed to get credentials"))}}catch(error){reject(new Error(`Failed to parse response: ${error.message}`))}})}).on("error",reject).setTimeout(30000,function(){this.destroy();reject(new Error("Request timeout"))})})}/**
9
+ * 创建 COS 客户端(使用临时凭证)
10
+ */async function createCOSClient(cosConfig){// 如果配置中提供了 SecretId 和 SecretKey,使用传统方式
11
+ if(cosConfig&&cosConfig.SecretId&&cosConfig.SecretKey){return new COS({SecretId:cosConfig.SecretId,SecretKey:cosConfig.SecretKey})}// 否则使用临时凭证 API
12
+ const credentials=await fetchTemporaryCredentials();return new COS({getAuthorization:(options,callback)=>{callback({TmpSecretId:credentials.credentials.tmpSecretId,TmpSecretKey:credentials.credentials.tmpSecretKey,SecurityToken:credentials.credentials.sessionToken,StartTime:credentials.startTime,ExpiredTime:credentials.expiredTime})}})}/**
9
13
  * 刷新 CDN 缓存
10
14
  *
11
15
  * @param {Object} cosConfig - COS 配置
12
16
  * @param {Array<string>} urls - 需要刷新的 URL 列表
17
+ * @param {Object} credentials - 可选的凭证对象
13
18
  * @returns {Promise<void>}
14
- */async function purgeCDNCache(cosConfig,urls){if(!urls||urls.length===0){return}// 如果没有配置 CDN 刷新,跳过
15
- if(!cosConfig.CdnDomain){console.log("\u26A0\uFE0F \u672A\u914D\u7F6E CdnDomain\uFF0C\u8DF3\u8FC7 CDN \u5237\u65B0");return}try{// 动态加载 CDN SDK
16
- const tencentcloud=require("tencentcloud-sdk-nodejs-cdn");const CdnClient=tencentcloud.cdn.v20180606.Client;const clientConfig={credential:{secretId:cosConfig.SecretId,secretKey:cosConfig.SecretKey},region:"",// CDN 是全球服务,不需要指定 region
19
+ */async function purgeCDNCache(cosConfig,urls,credentials=null){if(!urls||urls.length===0){return}// 如果没有配置 CDN 刷新,跳过
20
+ const cdnDomain=(cosConfig===null||cosConfig===void 0?void 0:cosConfig.CdnDomain)||"static.aix.intlgame.com";if(!cdnDomain){console.log("\u26A0\uFE0F \u672A\u914D\u7F6E CdnDomain\uFF0C\u8DF3\u8FC7 CDN \u5237\u65B0");return}try{// 动态加载 CDN SDK
21
+ const tencentcloud=require("tencentcloud-sdk-nodejs-cdn");const CdnClient=tencentcloud.cdn.v20180606.Client;// 获取凭证
22
+ let secretId,secretKey;if(cosConfig&&cosConfig.SecretId&&cosConfig.SecretKey){// 使用配置中的凭证
23
+ secretId=cosConfig.SecretId;secretKey=cosConfig.SecretKey}else{// 使用临时凭证(优先使用传入的凭证,否则重新获取)
24
+ if(!credentials){credentials=await fetchTemporaryCredentials()}secretId=credentials.credentials.tmpSecretId;secretKey=credentials.credentials.tmpSecretKey}const clientConfig={credential:{secretId,secretKey},region:"",// CDN 是全球服务,不需要指定 region
17
25
  profile:{httpProfile:{endpoint:"cdn.tencentcloudapi.com"}}};const client=new CdnClient(clientConfig);// CDN 刷新区域(从配置中读取,默认为 overseas)
18
26
  // mainland: 中国境内
19
27
  // overseas: 中国境外
20
28
  // global: 全球
21
- const area=cosConfig.CdnArea||"overseas";// 每次最多刷新 1000 个 URL(腾讯云限制)
29
+ const area=(cosConfig===null||cosConfig===void 0?void 0:cosConfig.CdnArea)||"overseas";// 每次最多刷新 1000 个 URL(腾讯云限制)
22
30
  const batchSize=1000;for(let i=0;i<urls.length;i+=batchSize){const batch=urls.slice(i,i+batchSize);const params={Urls:batch,Area:area};await client.PurgeUrlsCache(params);console.log(`✓ 已刷新 ${batch.length} 个 URL 的 CDN 缓存 (区域: ${area})`)}}catch(error){// 如果没有安装 CDN SDK,给出提示
23
31
  if(error.code==="MODULE_NOT_FOUND"){console.warn("\u26A0\uFE0F \u672A\u5B89\u88C5 tencentcloud-sdk-nodejs-cdn\uFF0C\u8DF3\u8FC7 CDN \u5237\u65B0");console.warn(" \u5B89\u88C5\u547D\u4EE4: npm install tencentcloud-sdk-nodejs-cdn --save-dev")}else{console.error(`CDN 刷新失败: ${error.message}`);// 不抛出错误,避免影响上传流程
24
32
  }}}/**
@@ -27,16 +35,23 @@ if(error.code==="MODULE_NOT_FOUND"){console.warn("\u26A0\uFE0F \u672A\u5B89\u88
27
35
  * @param {Object} cosConfig - COS 配置
28
36
  * @param {string} filePath - 本地文件路径
29
37
  * @param {string} cosKey - COS 对象键(远程路径)
38
+ * @param {Object} credentials - 可选的凭证对象
30
39
  * @returns {Promise<string>} - 上传成功返回访问链接
31
- */async function uploadFileToCOS(cosConfig,filePath,cosKey){if(!fs.existsSync(filePath)){throw new Error(`文件不存在: ${filePath}`)}const cos=createCOSClient(cosConfig);return new Promise((resolve,reject)=>{cos.putObject({Bucket:cosConfig.Bucket,Region:cosConfig.Region,Key:cosKey,Body:fs.createReadStream(filePath)},(err,data)=>{if(err){reject(new Error(`上传失败: ${err.message}`))}else{// 生成访问链接
32
- const cdnDomain=cosConfig.CdnDomain;// 对路径的每个部分分别进行 URL 编码
40
+ */async function uploadFileToCOS(cosConfig,filePath,cosKey,credentials=null){if(!fs.existsSync(filePath)){throw new Error(`文件不存在: ${filePath}`)}// 获取临时凭证和配置信息
41
+ let bucket,region,cdnDomain;if(cosConfig&&cosConfig.Bucket&&cosConfig.Region){// 使用配置文件中的信息
42
+ bucket=cosConfig.Bucket;region=cosConfig.Region;cdnDomain=cosConfig.CdnDomain||"static.aix.intlgame.com"}else{// 从临时凭证 API 获取配置(优先使用传入的凭证,否则重新获取)
43
+ if(!credentials){credentials=await fetchTemporaryCredentials()}bucket=credentials.bucket;region=credentials.region;cdnDomain="static.aix.intlgame.com"}const cos=await createCOSClient(cosConfig||null,credentials);return new Promise((resolve,reject)=>{cos.putObject({Bucket:bucket,Region:region,Key:cosKey,Body:fs.createReadStream(filePath)},(err,data)=>{if(err){reject(new Error(`上传失败: ${err.message}`))}else{// 生成访问链接
44
+ // 对路径的每个部分分别进行 URL 编码
33
45
  const pathParts=cosKey.split("/");const encodedPath=pathParts.map(part=>encodeURIComponent(part)).join("/");const url=`https://${cdnDomain}/${encodedPath}`;resolve(url)}})})}/**
34
46
  * 上传目录下的所有文件到 COS
35
47
  *
36
48
  * @param {Object} cosConfig - COS 配置
37
49
  * @param {string} localDir - 本地目录路径
38
50
  * @param {string} cosPrefix - COS 路径前缀
51
+ * @param {Object} credentials - 可选的凭证对象
39
52
  * @returns {Promise<Array>} - 上传成功返回文件列表
40
- */async function uploadDirectoryToCOS(cosConfig,localDir,cosPrefix){if(!fs.existsSync(localDir)){throw new Error(`目录不存在: ${localDir}`)}const files=fs.readdirSync(localDir);const results=[];const uploadedUrls=[];for(const file of files){const filePath=path.join(localDir,file);const stat=fs.statSync(filePath);if(stat.isFile()){// 去掉文件名中的 -preview 或 _preview 后缀
41
- let cleanFileName=file;cleanFileName=cleanFileName.replace(/-preview\./i,".");cleanFileName=cleanFileName.replace(/_preview\./i,".");const cosKey=cosPrefix?`${cosPrefix}/${cleanFileName}`:cleanFileName;const url=await uploadFileToCOS(cosConfig,filePath,cosKey);results.push({file:cleanFileName,url});uploadedUrls.push(url)}}// 上传完成后刷新 CDN 缓存
42
- if(uploadedUrls.length>0){await purgeCDNCache(cosConfig,uploadedUrls)}return results}module.exports={createCOSClient,uploadFileToCOS,uploadDirectoryToCOS,purgeCDNCache};
53
+ */async function uploadDirectoryToCOS(cosConfig,localDir,cosPrefix,credentials=null){if(!fs.existsSync(localDir)){throw new Error(`目录不存在: ${localDir}`)}// 如果没有传入凭证且没有配置,获取一次凭证用于所有文件上传
54
+ if(!credentials&&(!cosConfig||!cosConfig.Bucket||!cosConfig.Region)){try{credentials=await fetchTemporaryCredentials();console.log("\u2713 \u83B7\u53D6\u4E34\u65F6\u51ED\u8BC1\u6210\u529F")}catch(error){throw new Error(`获取临时凭证失败: ${error.message}`)}}const files=fs.readdirSync(localDir);const results=[];const uploadedUrls=[];for(const file of files){const filePath=path.join(localDir,file);const stat=fs.statSync(filePath);if(stat.isFile()){// 去掉文件名中的 -preview 或 _preview 后缀
55
+ let cleanFileName=file;cleanFileName=cleanFileName.replace(/-preview\./i,".");cleanFileName=cleanFileName.replace(/_preview\./i,".");const cosKey=cosPrefix?`${cosPrefix}/${cleanFileName}`:cleanFileName;// 传入凭证,避免重复获取
56
+ const url=await uploadFileToCOS(cosConfig,filePath,cosKey,credentials);results.push({file:cleanFileName,url});uploadedUrls.push(url)}}// 上传完成后刷新 CDN 缓存,传入凭证
57
+ if(uploadedUrls.length>0){await purgeCDNCache(cosConfig,uploadedUrls,credentials)}return results}module.exports={fetchTemporaryCredentials,createCOSClient,uploadFileToCOS,uploadDirectoryToCOS,purgeCDNCache};
@@ -1,5 +1,5 @@
1
1
  "use strict";const HtmlWebpackPlugin=require("html-webpack-plugin");const mintegralSrc="function gameStart() {window.mintGameStart && window.mintGameStart()}; function gameClose() {window.mintGameClose && window.mintGameClose()}";/**
2
2
  * Webpack plugin that injects Mintegral required script into the HTML head.
3
- * Designed to use with @tencent/playable-sdk package
3
+ * Designed to use with @playcraft/adsdk package
4
4
  * @implements {import('webpack').WebpackPluginInstance}
5
5
  */class MintegralInjectorPlugin{apply(compiler){compiler.hooks.compilation.tap("MintegralInjectorPlugin",compilation=>{HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync("MintegralInjectorPlugin",(data,callback)=>{data.headTags.splice(0,0,{tagName:"script",voidTag:false,meta:{plugin:"html-inline-script-webpack-plugin"},innerHTML:mintegralSrc});callback(null,data)})})}}exports.MintegralInjectorPlugin=MintegralInjectorPlugin;
@@ -1,28 +1,78 @@
1
- "use strict";const fs=require("fs");const path=require("path");const JSON5=require("json5");const Ajv=require("ajv");/**
2
- * Validate and merge theme data for a given theme.
1
+ "use strict";const fs=require("fs");const path=require("path");const JSON5=require("json5");const Ajv=require("ajv");const SCHEMA_BACKUP_SUFFIX=".backup";const SCHEMA_BACKUP_COMMENT="// [AUTO-GENERATED BACKUP] This file was created by the build process.\n"+"// If the build was interrupted, you can restore it by removing the \".backup\" suffix\n"+"// and replacing the original \"theme.schema.json5\" with this file.\n";/**
2
+ * Recursively extract `default` values from a JSON Schema definition.
3
+ *
4
+ * - For "object" types: recurse into `properties` and build a nested object.
5
+ * - For primitive types: return the `default` value if present.
6
+ *
7
+ * @param {object} schema - A JSON Schema (or sub-schema) object
8
+ * @returns {*} The extracted default value tree, or undefined if none
9
+ */function extractDefaultsFromSchema(schema){if(!schema||typeof schema!=="object")return undefined;if(schema.type==="object"&&schema.properties){const result={};for(const[key,propSchema]of Object.entries(schema.properties)){const val=extractDefaultsFromSchema(propSchema);if(val!==undefined){result[key]=val}}return Object.keys(result).length>0?result:undefined}// Primitive or array type with a default value
10
+ if("default"in schema){return schema.default}return undefined}/**
11
+ * Validate theme data for a given theme against the schema.
3
12
  *
4
13
  * File layout expected under the project:
5
- * src/theme/theme.schema.json5 – JSON Schema (JSON5 format)
6
- * src/theme/theme.default.json5 Default values
7
- * src/theme/{themeName}/theme.data.json5 – Per-theme overrides
14
+ * src/theme/theme.schema.json5 – JSON Schema (JSON5 format)
15
+ * src/theme/{themeName}/theme.data.json5 Per-theme data
8
16
  *
9
- * Merge strategy: deep merge, theme data takes priority over defaults.
17
+ * Default values are extracted from the schema's `default` fields via extractDefaultsFromSchema,
18
+ * then deep-merged with theme data (theme data takes priority) before validation.
10
19
  *
11
20
  * @param {string} themeName - Name of the theme directory
12
21
  * @param {string} projectRoot - Absolute path to the project root
13
22
  * @param {object} [themeConfig] - Optional theme config (themes section from builds.config.js)
14
- * @returns {{ merged: object, errors: string[] | null }}
15
- * merged – The merged data object (default + theme override)
16
- * errors – null if validation passed, otherwise an array of error messages
23
+ * @returns {{ defaultData: object, errors: string[] | null }}
24
+ * defaultData – The extracted default values from schema
25
+ * errors – null if validation passed, otherwise an array of error messages
17
26
  */function validateThemeData(themeName,projectRoot,themeConfig){const sourceDir=themeConfig&&themeConfig.sourceDir||"src/theme";const themeBaseDir=path.join(projectRoot,sourceDir);// --- Resolve file paths ---
18
- const schemaPath=path.join(themeBaseDir,"theme.schema.json5");const defaultPath=path.join(themeBaseDir,"theme.default.json5");const dataPath=path.join(themeBaseDir,themeName,"theme.data.json5");// --- Read & parse files ---
19
- if(!fs.existsSync(schemaPath)){return{merged:null,errors:[`Schema file not found: ${schemaPath}`]}}const schema=JSON5.parse(fs.readFileSync(schemaPath,"utf8"));let defaultData={};if(fs.existsSync(defaultPath)){defaultData=JSON5.parse(fs.readFileSync(defaultPath,"utf8"))}let themeData={};if(fs.existsSync(dataPath)){themeData=JSON5.parse(fs.readFileSync(dataPath,"utf8"))}// --- Deep merge (theme overrides default) ---
20
- const merged=deepMerge(defaultData,themeData);// --- Validate against schema ---
21
- const ajv=new Ajv({allErrors:true,strict:false});const validate=ajv.compile(schema);const valid=validate(merged);if(!valid){const errors=validate.errors.map(err=>{const field=err.instancePath||"(root)";return`${field} ${err.message}`});return{merged,errors}}return{merged,errors:null}}/**
27
+ const schemaPath=path.join(themeBaseDir,"theme.schema.json5");const dataPath=path.join(themeBaseDir,themeName,"theme.data.json5");// --- Read & parse files ---
28
+ if(!fs.existsSync(schemaPath)){return{defaultData:null,errors:[`Schema file not found: ${schemaPath}`]}}const schema=JSON5.parse(fs.readFileSync(schemaPath,"utf8"));// Extract default values from schema
29
+ const defaultData=extractDefaultsFromSchema(schema)||{};let themeData={};if(fs.existsSync(dataPath)){themeData=JSON5.parse(fs.readFileSync(dataPath,"utf8"))}// Deep merge (theme data overrides schema defaults), then validate
30
+ const merged=deepMerge(defaultData,themeData);// --- Validate merged data against schema ---
31
+ const ajv=new Ajv({allErrors:true,strict:false});const validate=ajv.compile(schema);const valid=validate(merged);if(!valid){const errors=validate.errors.map(err=>{const field=err.instancePath||"(root)";return`${field} ${err.message}`});return{defaultData,errors}}return{defaultData,errors:null}}/**
22
32
  * Deep merge two plain objects. Values in `override` take priority.
23
33
  * Arrays are replaced entirely (not concatenated).
24
34
  *
25
35
  * @param {object} base
26
36
  * @param {object} override
27
37
  * @returns {object}
28
- */function deepMerge(base,override){const result={...base};for(const key of Object.keys(override)){const baseVal=base[key];const overVal=override[key];if(overVal!==null&&typeof overVal==="object"&&!Array.isArray(overVal)&&baseVal!==null&&typeof baseVal==="object"&&!Array.isArray(baseVal)){result[key]=deepMerge(baseVal,overVal)}else{result[key]=overVal}}return result}module.exports={validateThemeData,deepMerge};
38
+ */function deepMerge(base,override){const result={...base};for(const key of Object.keys(override)){const baseVal=base[key];const overVal=override[key];if(overVal!==null&&typeof overVal==="object"&&!Array.isArray(overVal)&&baseVal!==null&&typeof baseVal==="object"&&!Array.isArray(baseVal)){result[key]=deepMerge(baseVal,overVal)}else{result[key]=overVal}}return result}/**
39
+ * Backup the original theme.schema.json5 file.
40
+ * The backup file includes a comment header explaining how to restore it manually.
41
+ *
42
+ * @param {string} schemaPath - Absolute path to theme.schema.json5
43
+ * @returns {string} The backup file path
44
+ */function backupSchema(schemaPath){const backupPath=schemaPath+SCHEMA_BACKUP_SUFFIX;const originalContent=fs.readFileSync(schemaPath,"utf8");fs.writeFileSync(backupPath,SCHEMA_BACKUP_COMMENT+originalContent,"utf8");return backupPath}/**
45
+ * Restore theme.schema.json5 from its backup file and remove the backup.
46
+ *
47
+ * @param {string} schemaPath - Absolute path to theme.schema.json5
48
+ */function restoreSchema(schemaPath){const backupPath=schemaPath+SCHEMA_BACKUP_SUFFIX;if(!fs.existsSync(backupPath))return;const backupContent=fs.readFileSync(backupPath,"utf8");// Strip the auto-generated comment header (lines starting with "//")
49
+ const lines=backupContent.split("\n");const contentStart=lines.findIndex(line=>!line.startsWith("//"));const originalContent=lines.slice(contentStart>=0?contentStart:0).join("\n");fs.writeFileSync(schemaPath,originalContent,"utf8");fs.unlinkSync(backupPath)}/**
50
+ * Overwrite theme.schema.json5 with the merged data (schema defaults + theme overrides)
51
+ * so that the bundler can consume it directly during the build.
52
+ *
53
+ * @param {string} schemaPath - Absolute path to theme.schema.json5
54
+ * @param {object} mergedData - The merged theme data to write
55
+ */function overwriteSchemaWithMerged(schemaPath,mergedData){const content=JSON.stringify(mergedData,null,2);fs.writeFileSync(schemaPath,content,"utf8")}/**
56
+ * Run theme validation with standard logging.
57
+ * Shared by both single build (build.js) and batch build (batch-build.js).
58
+ *
59
+ * Flow:
60
+ * 1. Validate theme.data.json5 against theme.schema.json5
61
+ * 2. Backup theme.schema.json5 (with restore instructions)
62
+ * 3. Extract defaults from schema via extractDefaultsFromSchema
63
+ * 4. Overwrite theme.schema.json5 with default data for the build
64
+ *
65
+ * After the build (success or failure), call restoreSchema() to restore the original.
66
+ *
67
+ * @param {string} themeName - Name of the theme directory
68
+ * @param {string} projectRoot - Absolute path to the project root
69
+ * @param {object} [themeConfig] - Optional theme config (themes section from builds.config.js)
70
+ * @param {object} [options]
71
+ * @param {function} [options.log] - Custom log function(message, level). Defaults to console.log.
72
+ * @returns {{ valid: boolean, errors: string[]|null, schemaPath: string|null }}
73
+ * valid – true if schema file doesn't exist (skip) or validation passed
74
+ * errors – null if validation passed, otherwise an array of error messages
75
+ * schemaPath – Absolute path to theme.schema.json5 (for restoreSchema calls), or null
76
+ */function runThemeValidation(themeName,projectRoot,themeConfig,options){const sourceDir=themeConfig&&themeConfig.sourceDir||"src/theme";const themeBaseDir=path.join(projectRoot,sourceDir);const schemaPath=path.join(themeBaseDir,"theme.schema.json5");// No schema file → skip validation entirely
77
+ if(!fs.existsSync(schemaPath)){return{valid:true,errors:null,schemaPath:null}}const logFn=options&&options.log||function defaultLog(msg,level){const colors={info:"\x1B[36m",success:"\x1B[32m",error:"\x1B[31m"};const reset="\x1B[0m";const color=colors[level]||"";console.log(`${color}${msg}${reset}`)};logFn(`校验主题数据: ${themeName}`,"info");const{defaultData,errors}=validateThemeData(themeName,projectRoot,themeConfig);if(errors){logFn(`主题 "${themeName}" 数据校验失败:`,"error");errors.forEach(e=>logFn(` - ${e}`,"error"));return{valid:false,errors,schemaPath}}logFn(`主题 "${themeName}" 数据校验通过`,"success");// Backup original schema, then overwrite with default data for the build
78
+ backupSchema(schemaPath);overwriteSchemaWithMerged(schemaPath,defaultData);logFn(`已备份 schema 并写入默认数据`,"info");return{valid:true,errors:null,schemaPath}}module.exports={validateThemeData,deepMerge,extractDefaultsFromSchema,runThemeValidation,backupSchema,restoreSchema,overwriteSchemaWithMerged};
package/package.json CHANGED
@@ -1,82 +1,83 @@
1
- {
2
- "name": "@playcraft/devkit",
3
- "version": "1.0.5",
4
- "description": "HTML5 Playable Ads 构建工具,支持多广告渠道打包",
5
- "main": "core/index.js",
6
- "module": "core/index.js",
7
- "types": "core/index.d.ts",
8
- "bin": {
9
- "playable-scripts": "cli/bin/playable-scripts.js"
10
- },
11
- "files": [
12
- "cli",
13
- "core",
14
- "LICENSE",
15
- "defines.d.ts"
16
- ],
17
- "scripts": {
18
- "deploy": "npm publish --registry https://www.npmjs.com/registry"
19
- },
20
- "author": "Tencent ADS Team",
21
- "license": "MIT",
22
- "dependencies": {
23
- "@babel/core": "^7.26.0",
24
- "@babel/preset-env": "^7.26.0",
25
- "@vue/compiler-sfc": "^3.4.0",
26
- "ajv": "^8.18.0",
27
- "babel-loader": "^9.2.1",
28
- "copy-webpack-plugin": "^13.0.0",
29
- "cos-nodejs-sdk-v5": "^2.14.4",
30
- "cross-spawn": "^7.0.3",
31
- "css-loader": "^7.1.2",
32
- "esbuild-loader": "^4.2.2",
33
- "fflate": "^0.8.2",
34
- "html-inline-script-webpack-plugin": "^3.2.1",
35
- "html-webpack-plugin": "^5.6.0",
36
- "json5": "^2.2.3",
37
- "json5-loader": "^4.0.0",
38
- "mini-css-extract-plugin": "^2.7.6",
39
- "prettyjson": "^1.2.5",
40
- "style-loader": "^4.0.0",
41
- "tencentcloud-sdk-nodejs-cdn": "^4.1.161",
42
- "terser-webpack-plugin": "^5.3.10",
43
- "tsconfig-paths-webpack-plugin": "^4.2.0",
44
- "vue": "^3.4.0",
45
- "vue-loader": "^17.4.2",
46
- "webpack": "^5.91.0",
47
- "webpack-dev-server": "^5.1.0",
48
- "webpack-merge": "^5.10.0",
49
- "webpack-obfuscator": "^3.6.0",
50
- "yazl": "^2.5.1"
51
- },
52
- "engines": {
53
- "node": ">=14.0.0"
54
- },
55
- "keywords": [
56
- "playable",
57
- "scripts",
58
- "builder",
59
- "playable-ads",
60
- "html5-ads",
61
- "ad-network",
62
- "ad-builder",
63
- "compression",
64
- "fflate",
65
- "optimization",
66
- "tencent",
67
- "bigoads",
68
- "mraid",
69
- "vungle",
70
- "applovin",
71
- "google-ads",
72
- "meta-ads",
73
- "unity-ads",
74
- "ironsource",
75
- "webpack",
76
- "cli-tool",
77
- "build-tool"
78
- ],
79
- "publishConfig": {
80
- "registry": "https://www.npmjs.com/registry"
81
- }
82
- }
1
+ {
2
+ "name": "@playcraft/devkit",
3
+ "version": "1.0.7",
4
+ "description": "HTML5 Playable Ads 构建工具,支持多广告渠道打包",
5
+ "main": "core/index.js",
6
+ "module": "core/index.js",
7
+ "types": "core/index.d.ts",
8
+ "bin": {
9
+ "playable-scripts": "cli/bin/playable-scripts.js"
10
+ },
11
+ "files": [
12
+ "cli",
13
+ "core",
14
+ "LICENSE",
15
+ "defines.d.ts"
16
+ ],
17
+ "scripts": {
18
+ "deploy": "npm run build && cd dist && npm publish --registry https://registry.npmjs.org/ --access public"
19
+ },
20
+ "author": "Tencent ADS Team",
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@babel/cli": "^7.28.6",
24
+ "@babel/core": "^7.26.0",
25
+ "@babel/preset-env": "^7.26.0",
26
+ "@vue/compiler-sfc": "^3.4.0",
27
+ "ajv": "^8.18.0",
28
+ "babel-loader": "^9.2.1",
29
+ "copy-webpack-plugin": "^13.0.0",
30
+ "cos-nodejs-sdk-v5": "^2.14.4",
31
+ "cross-spawn": "^7.0.3",
32
+ "css-loader": "^7.1.2",
33
+ "esbuild-loader": "^4.2.2",
34
+ "fflate": "^0.8.2",
35
+ "html-inline-script-webpack-plugin": "^3.2.1",
36
+ "html-webpack-plugin": "^5.6.0",
37
+ "json5": "^2.2.3",
38
+ "json5-loader": "^4.0.0",
39
+ "mini-css-extract-plugin": "^2.7.6",
40
+ "prettyjson": "^1.2.5",
41
+ "style-loader": "^4.0.0",
42
+ "tencentcloud-sdk-nodejs-cdn": "^4.1.161",
43
+ "terser-webpack-plugin": "^5.3.10",
44
+ "tsconfig-paths-webpack-plugin": "^4.2.0",
45
+ "vue": "^3.4.0",
46
+ "vue-loader": "^17.4.2",
47
+ "webpack": "^5.91.0",
48
+ "webpack-dev-server": "^5.1.0",
49
+ "webpack-merge": "^5.10.0",
50
+ "webpack-obfuscator": "^3.6.0",
51
+ "yazl": "^2.5.1"
52
+ },
53
+ "engines": {
54
+ "node": ">=14.0.0"
55
+ },
56
+ "keywords": [
57
+ "playable",
58
+ "scripts",
59
+ "builder",
60
+ "playable-ads",
61
+ "html5-ads",
62
+ "ad-network",
63
+ "ad-builder",
64
+ "compression",
65
+ "fflate",
66
+ "optimization",
67
+ "tencent",
68
+ "bigoads",
69
+ "mraid",
70
+ "vungle",
71
+ "applovin",
72
+ "google-ads",
73
+ "meta-ads",
74
+ "unity-ads",
75
+ "ironsource",
76
+ "webpack",
77
+ "cli-tool",
78
+ "build-tool"
79
+ ],
80
+ "publishConfig": {
81
+ "registry": "https://www.npmjs.com/registry"
82
+ }
83
+ }