@playcraft/devkit 1.0.17 → 1.0.19

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.
@@ -8,6 +8,7 @@ const path = require('path');
8
8
  const { runBuild } = require('../../core/webpack.build.js');
9
9
  const { runThemeValidation, restoreSchema } = require('../../core/utils/validateThemeData');
10
10
  const { runPreBuildChecks } = require('../../core/validators/pre-build-checker.js');
11
+ const { FontInjectionWebpackPlugin } = require('../../core/plugins/FontInjectionWebpackPlugin.js');
11
12
 
12
13
  /**
13
14
  * 从主题入口文件中检测当前主题名称。
@@ -118,8 +119,15 @@ function continueWithBuild() {
118
119
  }
119
120
  }
120
121
 
122
+ // 注入字体插件(build 模式 base64 内联)
123
+ const fontPlugin = themeName
124
+ ? new FontInjectionWebpackPlugin({ projectRoot, themeName, themeConfig })
125
+ : null;
126
+
121
127
  // 执行构建,无论成功或失败都恢复 schema
122
- runBuild()
128
+ runBuild(undefined, undefined, undefined, {
129
+ plugins: fontPlugin ? [fontPlugin] : [],
130
+ })
123
131
  .then(() => {
124
132
  if (schemaPathToRestore) restoreSchema(schemaPathToRestore);
125
133
  })
package/core/index.d.ts CHANGED
@@ -183,3 +183,12 @@ export declare function runViteDev(
183
183
  customDefines?: Record<string, any>,
184
184
  viteCustomConfig?: Partial<ViteUserConfig>
185
185
  ): Promise<void>;
186
+
187
+ /** Vite plugin for .json5 file imports */
188
+ export declare function json5Plugin(): import('vite').Plugin;
189
+
190
+ /** Vite plugin for asset inlining (.atlas, .fnt, .xml) */
191
+ export declare function assetInlinePlugin(): import('vite').Plugin;
192
+
193
+ /** Vite plugin for font injection (base64 @font-face in <head>) */
194
+ export declare function fontFamilyPlugin(): import('vite').Plugin;
package/core/index.js CHANGED
@@ -7,7 +7,12 @@ const { DebuggerInjectionPlugin } = require('./plugins/DebuggerInjectionPlugin.j
7
7
  const { ExitAPIInjectorPlugin } = require('./plugins/ExitAPIInjectorPlugin.js');
8
8
  const { runBuild, makeWebpackBuildConfig } = require('./webpack.build.js');
9
9
  const { runDev, makeWebpackDevConfig } = require('./webpack.dev.js');
10
- const { runViteDev, makeViteDevConfig } = require('./vite.dev.js');
10
+ const { runViteDev, makeViteDevConfig, json5Plugin, assetInlinePlugin, fontFamilyPlugin } = require('./vite.dev.js');
11
+
12
+ // 字体注入
13
+ const { FontInjectionWebpackPlugin } = require('./plugins/FontInjectionWebpackPlugin.js');
14
+ const { FONT_REGISTRY, getRegisteredFontKeys, isBuiltin: isBuiltinFont, getFontEntry } = require('./utils/fontRegistry.js');
15
+ const { getOrGenerateFontCSS } = require('./utils/fontResolver.js');
11
16
 
12
17
  // 验证器
13
18
  const {
@@ -44,6 +49,11 @@ exports.DebuggerInjectionPlugin = DebuggerInjectionPlugin;
44
49
  exports.makeViteDevConfig = makeViteDevConfig;
45
50
  exports.runViteDev = runViteDev;
46
51
 
52
+ // 公共 Vite 插件(供项目 vite.config.ts 直接引用)
53
+ exports.json5Plugin = json5Plugin;
54
+ exports.assetInlinePlugin = assetInlinePlugin;
55
+ exports.fontFamilyPlugin = fontFamilyPlugin;
56
+
47
57
  // 编译前检查器导出
48
58
  exports.runPreBuildChecks = runPreBuildChecks;
49
59
  exports.runTrackingCheck = runTrackingCheck;
@@ -56,3 +66,11 @@ exports.validateTracking = validateTracking;
56
66
  exports.printValidationResult = printValidationResult;
57
67
  exports.TRACKING_METHODS = TRACKING_METHODS;
58
68
  exports.SDK_TO_TRACKING_MAP = SDK_TO_TRACKING_MAP;
69
+
70
+ // 字体注入导出
71
+ exports.FontInjectionWebpackPlugin = FontInjectionWebpackPlugin;
72
+ exports.FONT_REGISTRY = FONT_REGISTRY;
73
+ exports.getRegisteredFontKeys = getRegisteredFontKeys;
74
+ exports.isBuiltinFont = isBuiltinFont;
75
+ exports.getFontEntry = getFontEntry;
76
+ exports.getOrGenerateFontCSS = getOrGenerateFontCSS;
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webpack 插件:在构建产物 HTML 的 </head> 之前注入字体 @font-face CSS。
5
+ *
6
+ * 用于 build 模式(playable-scripts build / builds)。
7
+ * dev 模式走 vite.dev.js 中的 Vite 插件,不走此插件。
8
+ *
9
+ * 两种使用方式:
10
+ * 1. 单构建:在 cli/commands/build.js 中通过 webpackCustomConfig.plugins 注入
11
+ * 2. 批量构建:子进程同样走单构建逻辑(schema 已被覆写为纯数据,fontResolver 自动兼容)
12
+ */
13
+
14
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
15
+ const { getOrGenerateFontCSS } = require('../utils/fontResolver');
16
+
17
+ class FontInjectionWebpackPlugin {
18
+ /**
19
+ * @param {object} opts
20
+ * @param {string} opts.projectRoot - 项目根目录
21
+ * @param {string} opts.themeName - 当前主题名称
22
+ * @param {object} [opts.themeConfig] - builds.config.js 中的 themes 配置
23
+ * @param {object} [opts.mergedData] - 预合并数据(可选,批量构建子进程会走自动检测)
24
+ */
25
+ constructor(opts = {}) {
26
+ this.opts = opts;
27
+ }
28
+
29
+ apply(compiler) {
30
+ compiler.hooks.compilation.tap('FontInjectionWebpackPlugin', (compilation) => {
31
+ HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync(
32
+ 'FontInjectionWebpackPlugin',
33
+ (data, cb) => {
34
+ const injectData = {
35
+ projectRoot: this.opts.projectRoot || process.cwd(),
36
+ themeName: this.opts.themeName,
37
+ themeConfig: this.opts.themeConfig,
38
+ mergedData: this.opts.mergedData,
39
+ };
40
+
41
+ if (!injectData.themeName) {
42
+ // 没有主题名 → 可能是非主题项目,跳过
43
+ cb(null, data);
44
+ return;
45
+ }
46
+
47
+ const css = getOrGenerateFontCSS(injectData);
48
+ if (css) {
49
+ data.html = data.html.replace('</head>', `${css}\n</head>`);
50
+ }
51
+
52
+ cb(null, data);
53
+ }
54
+ );
55
+ });
56
+ }
57
+ }
58
+
59
+ module.exports = { FontInjectionWebpackPlugin };
@@ -0,0 +1,31 @@
1
+ # 平台预置字体资源
2
+
3
+ 本目录存放可玩广告使用的字体内置资源文件(woff2 格式)。
4
+
5
+ ## 字体列表
6
+
7
+ | key | 文件名 | 说明 | 下载来源 |
8
+ |-----|--------|------|----------|
9
+ | `poppins` | `poppins-regular.woff2` | 英文字体 Poppins Regular | [Google Fonts](https://fonts.google.com/specimen/Poppins) |
10
+ | `noto-sans-sc` | `noto-sans-sc-regular.woff2` | 简体中文 Noto Sans SC | [Google Fonts](https://fonts.google.com/noto/specimen/Noto+Sans+SC) |
11
+ | `pangmen` | `pangmen-regular.woff2` | 庞门正道标题体 | 字体网站下载后 subset |
12
+ | `maoken` | `maoken-regular.woff2` | 猫啃珠圆体 | 字体网站下载后 subset |
13
+
14
+ ## 下载字体
15
+
16
+ ```bash
17
+ # 在 playable-scripts 目录下运行
18
+ node scripts/download-fonts.js
19
+ ```
20
+
21
+ 该脚本会自动下载 Google Fonts 中的开源字体(poppins、noto-sans-sc),
22
+ 并转换为 woff2 格式存入本目录。
23
+
24
+ 对于 pangmen / maoken 等非 Google Fonts 字体,需手动下载后放入本目录。
25
+
26
+ ## 字体文件要求
27
+
28
+ - 格式:woff2
29
+ - 文件名:与上表中的 key 对应
30
+ - 中文字体需做 subset(仅保留常用字符),建议控制在 100KB 以内
31
+ - 会被内联为 base64 写入 HTML 产物,请控制体积
@@ -0,0 +1 @@
1
+ 5434fbcb2072e4a7760465dd0f37cffa
@@ -0,0 +1 @@
1
+ 5434fbcb2072e4a7760465dd0f37cffa
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 平台预置字体字典。
5
+ *
6
+ * 所有字体文件内置于 playable-scripts/core/resources/fonts/ 目录下。
7
+ * 首次使用前需运行 `node scripts/download-fonts.js` 下载字体文件。
8
+ *
9
+ * 分类:
10
+ * - builtin=true: 系统自带字体,无需注入文件,运行时走 CSS fallback 链
11
+ * - builtin=false: 需要从本地字体文件读取并 base64 内联到产物中
12
+ *
13
+ * key 命名规范:与 theme.schema.json5 中 fontFamily 的 enum 值一一对应。
14
+ */
15
+
16
+ const path = require('path');
17
+
18
+ /** 字体资源目录(随 npm 包发布) */
19
+ const FONTS_DIR = path.join(__dirname, '..', 'resources', 'fonts');
20
+
21
+ const FONT_REGISTRY = {
22
+ // ===== 系统自带字体(无需注入) =====
23
+ system: {
24
+ builtin: true,
25
+ fallback: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
26
+ },
27
+ Arial: {
28
+ builtin: true,
29
+ fallback: 'Arial, Helvetica, sans-serif',
30
+ },
31
+ monaco: {
32
+ builtin: true,
33
+ fallback: 'Monaco, "Courier New", monospace',
34
+ },
35
+
36
+ // ===== 平台内置字体(base64 内联注入) =====
37
+ // key 必须与 theme.schema.json5 fontFamily.enum 值完全一致
38
+
39
+ // Google Fonts 字体:单文件 woff2
40
+ Poppins: {
41
+ builtin: false,
42
+ fontFileName: 'poppins-regular.woff2',
43
+ cssFamily: 'Poppins, sans-serif',
44
+ },
45
+ 'Noto Sans SC': {
46
+ builtin: false,
47
+ fontFileName: 'noto-sans-sc-regular.woff2',
48
+ cssFamily: '"Noto Sans SC", sans-serif',
49
+ },
50
+
51
+ // 本地 ttf 种子字体(单文件 woff2,由 download-fonts.js 用 pyftsubset 生成)
52
+ 'Pangmen Zhengdao': {
53
+ builtin: false,
54
+ fontFileName: 'pangmen-regular.woff2', // 由 pangmen-biaoti.ttf subset 生成
55
+ cssFamily: '"Pangmen Zhengdao", sans-serif',
56
+ },
57
+
58
+ // ZeoSeven 字体:unicode-range 子集(build 时按需筛选)
59
+ 'Maoken Zhuyuan Ti': {
60
+ builtin: false,
61
+ subsetsDir: 'maoken',
62
+ fontFileName: 'maoken-regular.woff2',
63
+ cssFamily: '"Maoken Zhuyuan Ti", sans-serif',
64
+ },
65
+ };
66
+
67
+ /**
68
+ * @returns {string[]} 所有已注册的字体 key 列表
69
+ */
70
+ function getRegisteredFontKeys() {
71
+ return Object.keys(FONT_REGISTRY);
72
+ }
73
+
74
+ /**
75
+ * @param {string} key - 字体 key
76
+ * @returns {boolean} 是否为系统自带字体
77
+ */
78
+ function isBuiltin(key) {
79
+ return !!(FONT_REGISTRY[key] && FONT_REGISTRY[key].builtin);
80
+ }
81
+
82
+ /**
83
+ * @param {string} key - 字体 key
84
+ * @returns {object|null} 字体配置对象,未注册返回 null
85
+ */
86
+ function getFontEntry(key) {
87
+ return FONT_REGISTRY[key] || null;
88
+ }
89
+
90
+ /**
91
+ * @returns {string} 字体资源目录绝对路径
92
+ */
93
+ function getFontsDir() {
94
+ return FONTS_DIR;
95
+ }
96
+
97
+ module.exports = { FONT_REGISTRY, getRegisteredFontKeys, isBuiltin, getFontEntry, getFontsDir };
@@ -0,0 +1,398 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 字体解析工具。
5
+ *
6
+ * 负责:
7
+ * 1. 扫描 theme.schema.json5,收集所有 fontFamily 字段(含 enum 白名单)
8
+ * 2. 合并 schema 默认值与 theme.data.json5
9
+ * 3. 从合并数据中提取实际使用的 fontFamily 值(去重)
10
+ * 4. 对照 fontRegistry 生成注入计划
11
+ * 5. 读取字体内置文件,生成 base64 @font-face CSS
12
+ *
13
+ * dev / build 共用此模块。
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const JSON5 = require('json5');
19
+ const { FONT_REGISTRY, isBuiltin, getFontsDir } = require('./fontRegistry');
20
+
21
+ // ── 内存缓存(dev 模式多次 transformIndexHtml 时复用) ──
22
+ let _cacheKey = null; // 'themeName::schemaMtime::dataMtime'
23
+ let _cachedCSS = null; // 生成的 CSS 字符串 '' 表示无字体需要注入
24
+
25
+ /**
26
+ * 递归扫描 JSON Schema,收集所有名为 `fontFamily` 且有 `enum` 的字段节点。
27
+ *
28
+ * @param {object} schema - JSON Schema 对象
29
+ * @param {Array<{trail: string[], enum: string[], default: string}>} nodes - 收集结果
30
+ * @param {string[]} trail - 当前属性路径
31
+ * @returns {Array}
32
+ */
33
+ function collectFontFamilySchemaNodes(schema, nodes = [], trail = []) {
34
+ if (!schema || typeof schema !== 'object') return nodes;
35
+ if (schema.type === 'object' && schema.properties) {
36
+ for (const [key, sub] of Object.entries(schema.properties)) {
37
+ if (key === 'fontFamily' && sub && Array.isArray(sub.enum)) {
38
+ nodes.push({
39
+ trail: [...trail, key],
40
+ enum: sub.enum,
41
+ default: sub.default,
42
+ });
43
+ } else {
44
+ collectFontFamilySchemaNodes(sub, nodes, [...trail, key]);
45
+ }
46
+ }
47
+ }
48
+ return nodes;
49
+ }
50
+
51
+ /**
52
+ * 深度合并两个对象(同 validateThemeData.js 的 deepMerge 语义)。
53
+ * override 中的值优先,数组整体替换不拼接。
54
+ */
55
+ function deepMerge(base, override) {
56
+ const result = { ...base };
57
+ for (const key of Object.keys(override)) {
58
+ const bv = base[key];
59
+ const ov = override[key];
60
+ if (
61
+ ov !== null && typeof ov === 'object' && !Array.isArray(ov) &&
62
+ bv !== null && typeof bv === 'object' && !Array.isArray(bv)
63
+ ) {
64
+ result[key] = deepMerge(bv, ov);
65
+ } else if (ov !== undefined) {
66
+ result[key] = ov;
67
+ }
68
+ }
69
+ return result;
70
+ }
71
+
72
+ /**
73
+ * 从 JSON Schema 递归提取 default 值树(同 validateThemeData.js)。
74
+ */
75
+ function extractDefaults(node) {
76
+ if (!node || typeof node !== 'object') return undefined;
77
+ if (node.type === 'object' && node.properties) {
78
+ const r = {};
79
+ for (const [k, v] of Object.entries(node.properties)) {
80
+ const val = extractDefaults(v);
81
+ if (val !== undefined) r[k] = val;
82
+ }
83
+ return Object.keys(r).length ? r : undefined;
84
+ }
85
+ if ('default' in node) return node.default;
86
+ return undefined;
87
+ }
88
+
89
+ /**
90
+ * 合并 schema 的 default 值与 theme.data.json5。
91
+ * 如果 schemaContent 已经是纯数据(非 Schema 格式),直接作为数据使用。
92
+ *
93
+ * @param {object} schemaContent - 已解析的 schema 文件内容
94
+ * @param {object} themeData - 已解析的 theme.data.json5 内容
95
+ * @param {string} themeName - 当前主题名(仅用于日志)
96
+ * @returns {object}
97
+ */
98
+ function mergeThemeData(schemaContent, themeData, themeName) {
99
+ // schema 已经是纯数据(如批量构建子进程中 schema 已被覆写)
100
+ if (!isJsonSchema(schemaContent)) {
101
+ // 直接使用 schema 作为 mergedData
102
+ return schemaContent;
103
+ }
104
+ // schema 是 JSON Schema 格式 → 提取 default + 合并 theme.data
105
+ return deepMerge(extractDefaults(schemaContent) || {}, themeData || {});
106
+ }
107
+
108
+ /**
109
+ * 判断对象是否为 JSON Schema 格式(有 type='object' 和 properties)。
110
+ */
111
+ function isJsonSchema(obj) {
112
+ return obj && typeof obj === 'object' && obj.type === 'object' && typeof obj.properties === 'object';
113
+ }
114
+
115
+ /**
116
+ * 递归遍历 mergedData,提取所有 key 为 `fontFamily` 的字符串值。
117
+ *
118
+ * @param {object} mergedData
119
+ * @param {string[]} values
120
+ * @returns {string[]}
121
+ */
122
+ function collectUsedFontFamilies(mergedData, values = []) {
123
+ if (!mergedData || typeof mergedData !== 'object') return values;
124
+ for (const [k, v] of Object.entries(mergedData)) {
125
+ if (k === 'fontFamily' && typeof v === 'string' && v) {
126
+ values.push(v);
127
+ } else if (v && typeof v === 'object' && !Array.isArray(v)) {
128
+ collectUsedFontFamilies(v, values);
129
+ }
130
+ }
131
+ return values;
132
+ }
133
+
134
+ /**
135
+ * 读取字体文件并返回 base64 编码内容。
136
+ * 支持缓存,同进程内同一文件只读一次。
137
+ */
138
+ const _fontDataCache = new Map();
139
+
140
+ function readFontBase64(fileName) {
141
+ if (_fontDataCache.has(fileName)) return _fontDataCache.get(fileName);
142
+ const fontPath = path.join(getFontsDir(), fileName);
143
+ if (!fs.existsSync(fontPath)) {
144
+ console.warn(`[font] Font file not found: ${fontPath}, skipping`);
145
+ _fontDataCache.set(fileName, null);
146
+ return null;
147
+ }
148
+ const data = fs.readFileSync(fontPath);
149
+ const base64 = data.toString('base64');
150
+ _fontDataCache.set(fileName, base64);
151
+ return base64;
152
+ }
153
+
154
+ /**
155
+ * 主入口:根据项目根目录 + 主题名称,计算字体注入计划。
156
+ *
157
+ * @param {object} opts
158
+ * @param {string} opts.projectRoot - 项目根目录绝对路径
159
+ * @param {string} opts.themeName - 主题目录名称
160
+ * @param {object} [opts.themeConfig] - builds.config.js 中的 themes 配置
161
+ * @param {object} [opts.mergedData] - 预合并数据(build 模式直接传,跳过 IO)
162
+ * @returns {{
163
+ * usedFonts: string[],
164
+ * builtinFonts: string[],
165
+ * injectFonts: Array<{key: string, entry: object}>,
166
+ * warnings: string[]
167
+ * }}
168
+ */
169
+ function resolveFontPlans(opts) {
170
+ const { projectRoot, themeName, themeConfig, mergedData: preMerged } = opts;
171
+ const sourceDir = (themeConfig && themeConfig.sourceDir) || 'src/theme';
172
+ const themeBaseDir = path.join(projectRoot, sourceDir);
173
+ const schemaPath = path.join(themeBaseDir, 'theme.schema.json5');
174
+ const dataPath = path.join(themeBaseDir, themeName, 'theme.data.json5');
175
+
176
+ if (!fs.existsSync(schemaPath)) {
177
+ return { usedFonts: [], builtinFonts: [], injectFonts: [], mergedData: {}, warnings: [] };
178
+ }
179
+
180
+ const schemaContent = JSON5.parse(fs.readFileSync(schemaPath, 'utf8'));
181
+
182
+ // 收集 schema 中所有 fontFamily 字段的 enum 合集作为白名单
183
+ const nodes = collectFontFamilySchemaNodes(schemaContent);
184
+ const schemaEnumSet = new Set();
185
+ nodes.forEach(n => n.enum.forEach(e => schemaEnumSet.add(e)));
186
+
187
+ // 合并数据:优先使用传入的预合并数据,其次 IO 自算
188
+ let mergedData;
189
+ if (preMerged) {
190
+ mergedData = preMerged;
191
+ } else {
192
+ const themeData = fs.existsSync(dataPath) ? JSON5.parse(fs.readFileSync(dataPath, 'utf8')) : {};
193
+ mergedData = mergeThemeData(schemaContent, themeData, themeName);
194
+ }
195
+
196
+ // 收集使用过的 fontFamily(去重)
197
+ const rawUsed = collectUsedFontFamilies(mergedData);
198
+ const usedFonts = Array.from(new Set(rawUsed));
199
+
200
+ // 分类 + 校验
201
+ const builtinFonts = [];
202
+ const injectFonts = [];
203
+ const warnings = [];
204
+
205
+ for (const key of usedFonts) {
206
+ if (!FONT_REGISTRY[key]) {
207
+ warnings.push(`[font] "${key}" not found in FONT_REGISTRY, will use as raw CSS font-family`);
208
+ continue;
209
+ }
210
+ if (schemaEnumSet.size > 0 && !schemaEnumSet.has(key)) {
211
+ warnings.push(`[font] "${key}" used but not declared in any schema fontFamily.enum`);
212
+ }
213
+ if (isBuiltin(key)) {
214
+ builtinFonts.push(key);
215
+ } else {
216
+ injectFonts.push({ key, entry: FONT_REGISTRY[key] });
217
+ }
218
+ }
219
+
220
+ return { usedFonts, builtinFonts, injectFonts, mergedData, warnings };
221
+ }
222
+
223
+ /**
224
+ * 递归遍历配置对象,收集所有字符串值(用于提取文案字符集)。
225
+ */
226
+ function collectAllStrings(obj, strs = []) {
227
+ if (!obj || typeof obj !== 'object') return strs;
228
+ for (const [k, v] of Object.entries(obj)) {
229
+ if (typeof v === 'string' && v) {
230
+ strs.push(v);
231
+ } else if (v && typeof v === 'object' && !Array.isArray(v)) {
232
+ collectAllStrings(v, strs);
233
+ }
234
+ }
235
+ return strs;
236
+ }
237
+
238
+ /**
239
+ * 解析单个 unicode-range 值(如 "U+4E00-9FFF, U+3400-4DBF"),判断是否覆盖 codePoint。
240
+ */
241
+ function rangeCoversCodePoint(rangeStr, cp) {
242
+ const parts = rangeStr.split(/\s*,\s*/);
243
+ for (const part of parts) {
244
+ const m = part.match(/^U\+([0-9A-Fa-f]+)(?:-U\+([0-9A-Fa-f]+))?/);
245
+ if (!m) continue;
246
+ const lo = parseInt(m[1], 16);
247
+ const hi = m[2] ? parseInt(m[2], 16) : lo;
248
+ if (cp >= lo && cp <= hi) return true;
249
+ }
250
+ return false;
251
+ }
252
+
253
+ /**
254
+ * 从 ZeoSeven 子集目录中,筛选出 covering 目标字符集的子集 woff2 文件。
255
+ * @returns {{ css: string, usedFiles: string[] }} 或 null
256
+ */
257
+ function buildSubsetCSS(key, entry, targetChars) {
258
+ const subsDir = path.join(getFontsDir(), entry.subsetsDir);
259
+ const indexPath = path.join(subsDir, 'index.json');
260
+
261
+ if (!fs.existsSync(indexPath)) return null;
262
+
263
+ const index = JSON.parse(fs.readFileSync(indexPath, 'utf8')); // { "hash.woff2": "U+..." }
264
+
265
+ // 找出覆盖目标字符的子集文件名(去重)
266
+ const neededFiles = new Set();
267
+ const matchedRanges = {}; // debug
268
+ for (const ch of targetChars) {
269
+ const cp = ch.codePointAt(0);
270
+ for (const [file, rangeStr] of Object.entries(index)) {
271
+ if (rangeCoversCodePoint(rangeStr, cp)) {
272
+ neededFiles.add(file);
273
+ matchedRanges[ch] = rangeStr; // debug
274
+ break;
275
+ }
276
+ }
277
+ }
278
+
279
+ if (targetChars.length <= 20) {
280
+ console.log(`[font] ${key} subset matching:`, matchedRanges);
281
+ }
282
+ console.log(`[font] ${key}: ${neededFiles.size} subsets for ${targetChars.length} unique chars`);
283
+
284
+ if (neededFiles.size === 0) return null;
285
+
286
+ // 读取每个需要的子集文件并生成 @font-face 声明
287
+ // 使用 cssFamily 中的主字体名(如 Poppins),而非内部 key(如 poppins)
288
+ const familyName = entry.cssFamily ? entry.cssFamily.split(',')[0].trim().replace(/^["']|["']$/g, '') : key;
289
+ const blocks = [];
290
+ for (const file of neededFiles) {
291
+ const data = readFontBase64(path.join(entry.subsetsDir, file));
292
+ if (!data) continue;
293
+ const range = index[file];
294
+ blocks.push(
295
+ `@font-face{font-family:'${familyName}';src:url(data:font/woff2;base64,${data}) format('woff2');unicode-range:${range};font-display:swap}`
296
+ );
297
+ }
298
+
299
+ return blocks.length > 0 ? { css: blocks.join('\n'), usedFiles: [...neededFiles] } : null;
300
+ }
301
+
302
+ /**
303
+ * 生成要注入到 <head> 的 <style> 片段(base64 @font-face)。
304
+ *
305
+ * @param {Array<{key: string, entry: object}>} injectFonts
306
+ * @param {object} mergedData - 合并后的主题数据,用于提取字符集
307
+ * @returns {string}
308
+ */
309
+ function generateFontFaceStyle(injectFonts, mergedData) {
310
+ if (!injectFonts || injectFonts.length === 0) return '';
311
+
312
+ // 从配置中提取所有文案字符(用于动态 subset)
313
+ const allStrings = collectAllStrings(mergedData || {});
314
+ const targetChars = [...new Set(allStrings.join('').split(''))].join('');
315
+
316
+ const blocks = [];
317
+
318
+ for (const { key, entry } of injectFonts) {
319
+ // ZeoSeven 字体:按需筛选 unicode-range 子集
320
+ if (entry.subsetsDir) {
321
+ const subset = buildSubsetCSS(key, entry, targetChars);
322
+ if (subset) {
323
+ blocks.push(subset.css);
324
+ console.log(`[font] ${key}: ${subset.usedFiles.length} subsets for ${targetChars.length} unique chars`);
325
+ continue;
326
+ }
327
+ // subsetsDir 存在但筛选失败 → 回退到单文件
328
+ }
329
+ // Google Fonts / 单文件回退
330
+ const base64 = readFontBase64(entry.fontFileName);
331
+ if (!base64) continue;
332
+ // 使用 cssFamily 的主字体名,与游戏代码中 fontFamily 值一致
333
+ const familyName = entry.cssFamily ? entry.cssFamily.split(',')[0].trim().replace(/^["']|["']$/g, '') : key;
334
+ blocks.push(
335
+ `@font-face{font-family:'${familyName}';src:url(data:font/woff2;base64,${base64}) format('woff2');font-display:swap}`
336
+ );
337
+ }
338
+
339
+ if (blocks.length === 0) return '';
340
+
341
+ return `<style data-playable-fonts>\n${blocks.join('\n')}\n</style>`;
342
+ }
343
+
344
+ /**
345
+ * dev/build 统一入口:获取字体 CSS。
346
+ * 内建基于 schema/theme.data 修改时间的缓存,dev 模式下避免重复 IO。
347
+ *
348
+ * @param {object} opts - 同 resolveFontPlans
349
+ * @param {boolean} [opts.force] - 强制跳过缓存
350
+ * @returns {string}
351
+ */
352
+ function getOrGenerateFontCSS(opts) {
353
+ const { projectRoot, themeName, themeConfig, force } = opts;
354
+ const sourceDir = (themeConfig && themeConfig.sourceDir) || 'src/theme';
355
+ const themeBaseDir = path.join(projectRoot, sourceDir);
356
+ const schemaPath = path.join(themeBaseDir, 'theme.schema.json5');
357
+ const dataPath = path.join(themeBaseDir, themeName, 'theme.data.json5');
358
+
359
+ if (!fs.existsSync(schemaPath)) return '';
360
+
361
+ // 构建缓存 key:主题名 + 两个文件的修改时间
362
+ const schemaMtime = fs.statSync(schemaPath).mtimeMs;
363
+ const dataMtime = fs.existsSync(dataPath) ? fs.statSync(dataPath).mtimeMs : 0;
364
+ const cacheKey = `${themeName}::${schemaMtime}::${dataMtime}`;
365
+
366
+ if (!force && _cacheKey === cacheKey && _cachedCSS !== null) {
367
+ return _cachedCSS;
368
+ }
369
+
370
+ const plan = resolveFontPlans({ projectRoot, themeName, themeConfig, mergedData: opts.mergedData });
371
+
372
+ // 打印 warn
373
+ plan.warnings.forEach(w => console.warn(w));
374
+
375
+ const css = generateFontFaceStyle(plan.injectFonts, plan.mergedData);
376
+
377
+ if (plan.injectFonts.length > 0) {
378
+ const names = plan.injectFonts.map(f => f.key).join(', ');
379
+ console.log(`[font] Injecting fonts: ${names}`);
380
+ }
381
+ if (plan.builtinFonts.length > 0) {
382
+ console.log(`[font] Using system fonts: ${plan.builtinFonts.join(', ')}`);
383
+ }
384
+
385
+ _cacheKey = cacheKey;
386
+ _cachedCSS = css;
387
+ return css;
388
+ }
389
+
390
+ module.exports = {
391
+ collectFontFamilySchemaNodes,
392
+ mergeThemeData,
393
+ collectUsedFontFamilies,
394
+ resolveFontPlans,
395
+ generateFontFaceStyle,
396
+ getOrGenerateFontCSS,
397
+ isJsonSchema,
398
+ };
package/core/vite.dev.js CHANGED
@@ -6,6 +6,117 @@ const { options } = require('./options.js');
6
6
  const { mergeOptions } = require('./utils/mergeOptions.js');
7
7
  const { buildDefines } = require('./utils/buildDefines.js');
8
8
  const { logOptions } = require('./utils/logOptions.js');
9
+ const { getOrGenerateFontCSS } = require('./utils/fontResolver.js');
10
+
11
+ // ── 公共 Vite 插件(供项目 vite.config.ts 直接引用) ──
12
+
13
+ /** JSON5 文件加载插件 */
14
+ function json5Plugin() {
15
+ return {
16
+ name: 'vite-plugin-json5',
17
+ transform(code, id) {
18
+ if (id.endsWith('.json5')) {
19
+ const JSON5 = require('json5');
20
+ const raw = fs.readFileSync(id, 'utf-8');
21
+ const parsed = JSON5.parse(raw);
22
+ return {
23
+ code: `export default ${JSON.stringify(parsed)};`,
24
+ map: null,
25
+ };
26
+ }
27
+ },
28
+ };
29
+ }
30
+
31
+ /** 资源内联插件(模拟 webpack 的 asset/inline 行为) */
32
+ function assetInlinePlugin() {
33
+ return {
34
+ name: 'vite-plugin-asset-inline',
35
+ enforce: 'pre',
36
+ transform(code, id) {
37
+ // .atlas 文件 -> base64 data URL
38
+ if (id.endsWith('.atlas')) {
39
+ const content = fs.readFileSync(id);
40
+ const base64 = content.toString('base64');
41
+ return {
42
+ code: `export default "data:text/atlas;base64,${base64}";`,
43
+ map: null,
44
+ };
45
+ }
46
+ // .fnt 文件 -> base64 data URL
47
+ if (id.endsWith('.fnt')) {
48
+ const content = fs.readFileSync(id);
49
+ const base64 = content.toString('base64');
50
+ return {
51
+ code: `export default "data:text/plain;base64,${base64}";`,
52
+ map: null,
53
+ };
54
+ }
55
+ // .xml 文件 -> 原始文本
56
+ if (id.endsWith('.xml')) {
57
+ const raw = fs.readFileSync(id, 'utf-8');
58
+ return {
59
+ code: `export default ${JSON.stringify(raw)};`,
60
+ map: null,
61
+ };
62
+ }
63
+ },
64
+ };
65
+ }
66
+
67
+ /** 字体注入插件(dev 模式:读取字体文件 base64 内联到 index.html) */
68
+ function fontFamilyPlugin() {
69
+ let _themeName = null;
70
+ let _themeConfig = null;
71
+
72
+ // 检测当前主题名(与 build.js 中 detectCurrentTheme 逻辑一致)
73
+ function detectTheme() {
74
+ const root = process.cwd();
75
+ const buildsConfigPath = path.join(root, 'builds.config.js');
76
+ let config = { sourceDir: 'src/theme', entryFile: 'src/theme/index.ts' };
77
+ if (fs.existsSync(buildsConfigPath)) {
78
+ try {
79
+ delete require.cache[require.resolve(buildsConfigPath)];
80
+ const buildsConfig = require(buildsConfigPath);
81
+ if (buildsConfig.themes) {
82
+ config = { ...config, ...buildsConfig.themes };
83
+ }
84
+ } catch (_) { /* ignore */ }
85
+ }
86
+ let name = null;
87
+ const entryPath = path.join(root, config.entryFile);
88
+ if (fs.existsSync(entryPath)) {
89
+ const content = fs.readFileSync(entryPath, 'utf8');
90
+ const match = content.match(/from\s+['"]\.\/([^'"]+)['"]/);
91
+ if (match) name = match[1];
92
+ }
93
+ return { themeName: name, themeConfig: config };
94
+ }
95
+
96
+ return {
97
+ name: 'vite-plugin-font-family',
98
+ apply: 'serve',
99
+ transformIndexHtml: {
100
+ order: 'pre',
101
+ handler() {
102
+ if (_themeName === null) {
103
+ const detected = detectTheme();
104
+ _themeName = detected.themeName;
105
+ _themeConfig = detected.themeConfig;
106
+ }
107
+ if (!_themeName) return '';
108
+ const css = getOrGenerateFontCSS({
109
+ projectRoot: process.cwd(),
110
+ themeName: _themeName,
111
+ themeConfig: _themeConfig,
112
+ });
113
+ return css ? [{ tag: 'style', attrs: { 'data-playable-fonts': '' }, children: css.replace(/<\/?style[^>]*>/g, ''), injectTo: 'head' }] : [];
114
+ },
115
+ },
116
+ };
117
+ }
118
+
119
+ // ── makeViteDevConfig / runViteDev ──
9
120
 
10
121
  /**
11
122
  * 创建 Vite 开发配置
@@ -67,67 +178,13 @@ function makeViteDevConfig(customOptions, customDefines, viteCustomConfig) {
67
178
  ...aliasConfig,
68
179
  };
69
180
 
70
- // JSON5 插件
71
- function json5Plugin() {
72
- return {
73
- name: 'vite-plugin-json5',
74
- transform(code, id) {
75
- if (id.endsWith('.json5')) {
76
- const JSON5 = require('json5');
77
- const raw = fs.readFileSync(id, 'utf-8');
78
- const parsed = JSON5.parse(raw);
79
- return {
80
- code: `export default ${JSON.stringify(parsed)};`,
81
- map: null,
82
- };
83
- }
84
- },
85
- };
86
- }
87
-
88
- // 资源内联插件(模拟 webpack 的 asset/inline 行为)
89
- function assetInlinePlugin() {
90
- return {
91
- name: 'vite-plugin-asset-inline',
92
- enforce: 'pre',
93
- transform(code, id) {
94
- // .atlas 文件 -> base64 data URL
95
- if (id.endsWith('.atlas')) {
96
- const content = fs.readFileSync(id);
97
- const base64 = content.toString('base64');
98
- return {
99
- code: `export default "data:text/atlas;base64,${base64}";`,
100
- map: null,
101
- };
102
- }
103
- // .fnt 文件 -> base64 data URL
104
- if (id.endsWith('.fnt')) {
105
- const content = fs.readFileSync(id);
106
- const base64 = content.toString('base64');
107
- return {
108
- code: `export default "data:text/plain;base64,${base64}";`,
109
- map: null,
110
- };
111
- }
112
- // .xml 文件 -> 原始文本
113
- if (id.endsWith('.xml')) {
114
- const raw = fs.readFileSync(id, 'utf-8');
115
- return {
116
- code: `export default ${JSON.stringify(raw)};`,
117
- map: null,
118
- };
119
- }
120
- },
121
- };
122
- }
123
-
124
181
  /** @type {import('vite').UserConfig} */
125
182
  const viteConfig = {
126
183
  root: '.',
127
184
  resolve: {
128
185
  alias: aliasConfig,
129
186
  },
130
- plugins: [json5Plugin(), assetInlinePlugin()],
187
+ plugins: [json5Plugin(), assetInlinePlugin(), fontFamilyPlugin()],
131
188
  define: defines,
132
189
  server: {
133
190
  port: devOptions['port'] || 3000,
@@ -141,7 +198,6 @@ function makeViteDevConfig(customOptions, customDefines, viteCustomConfig) {
141
198
 
142
199
  // 合并自定义配置
143
200
  if (viteCustomConfig) {
144
- // 简单合并:自定义配置中的字段会覆盖默认配置
145
201
  const { plugins: customPlugins, define: customDefine, resolve: customResolve, server: customServer, build: customBuild, ...rest } = viteCustomConfig;
146
202
 
147
203
  if (customPlugins) {
@@ -170,22 +226,111 @@ function makeViteDevConfig(customOptions, customDefines, viteCustomConfig) {
170
226
  }
171
227
 
172
228
  /**
173
- * 启动 Vite 开发服务器
174
- * @param {import('vite').UserConfig} [viteConfig] - Vite 配置,不传则自动创建
175
- * @param {Partial<import('./index').CLIOptions>} [customOptions] - 自定义选项
176
- * @param {Record<string, any>} [customDefines] - 额外的 define 常量
177
- * @param {import('vite').UserConfig} [viteCustomConfig] - 自定义 Vite 配置
229
+ * 深度合并两个 Vite 配置对象。override 覆盖 base。
230
+ * 数组会拼接(plugins),其余字段浅覆盖。
231
+ */
232
+ function deepMergeViteConfig(base, override) {
233
+ if (!override || typeof override !== 'object') return base;
234
+ const result = { ...base };
235
+ for (const key of Object.keys(override)) {
236
+ const bv = base[key];
237
+ const ov = override[key];
238
+ if (key === 'plugins' && Array.isArray(bv) && Array.isArray(ov)) {
239
+ result[key] = [...bv, ...ov];
240
+ } else if (
241
+ ov !== null && typeof ov === 'object' && !Array.isArray(ov) &&
242
+ bv !== null && typeof bv === 'object' && !Array.isArray(bv)
243
+ ) {
244
+ result[key] = { ...bv, ...ov };
245
+ } else {
246
+ result[key] = ov;
247
+ }
248
+ }
249
+ return result;
250
+ }
251
+
252
+ /**
253
+ * 尝试加载项目根目录的 vite.config.ts。
254
+ */
255
+ async function loadProjectViteConfig(root) {
256
+ const candidates = ['vite.config.ts', 'vite.config.js', 'vite.config.mjs'];
257
+ let configPath = null;
258
+ for (const c of candidates) {
259
+ const p = path.join(root, c);
260
+ if (fs.existsSync(p)) { configPath = p; break; }
261
+ }
262
+ if (!configPath) return null;
263
+
264
+ const { createRequire } = require('module');
265
+ let projRequire;
266
+ try {
267
+ projRequire = createRequire(path.join(root, 'package.json'));
268
+ } catch (_) {
269
+ projRequire = createRequire(path.join(root, 'noop.js'));
270
+ }
271
+
272
+ let loadConfigFromFile;
273
+ try {
274
+ loadConfigFromFile = projRequire('vite').loadConfigFromFile;
275
+ } catch (_) {
276
+ try {
277
+ loadConfigFromFile = projRequire('vite/dist/node/config').loadConfigFromFile;
278
+ } catch (_2) {
279
+ console.log('\x1b[33m⚠️ [vite] Cannot load project config (loadConfigFromFile unavailable), using scripts default\x1b[0m');
280
+ return null;
281
+ }
282
+ }
283
+
284
+ if (!loadConfigFromFile) return null;
285
+
286
+ try {
287
+ const result = await loadConfigFromFile(
288
+ { command: 'serve', mode: process.env.NODE_ENV || 'development' },
289
+ configPath,
290
+ root
291
+ );
292
+ if (result && result.config) {
293
+ console.log(`[vite] Loaded project config: ${path.relative(root, configPath)}`);
294
+ return result.config;
295
+ }
296
+ return null;
297
+ } catch (err) {
298
+ console.log(`\x1b[33m⚠️ [vite] Failed to load ${path.relative(root, configPath)}: ${err.message}, using scripts default\x1b[0m`);
299
+ return null;
300
+ }
301
+ }
302
+
303
+ /**
304
+ * 启动 Vite 开发服务器。
305
+ *
306
+ * 配置优先级(从低到高):
307
+ * 1. makeViteDevConfig 构建的默认配置(fontFamilyPlugin、json5Plugin 等)
308
+ * 2. 项目根目录的 vite.config.ts(如果存在,覆盖/扩充默认配置)
309
+ * 3. 传入的 viteConfig / customOptions / customDefines / viteCustomConfig
178
310
  */
179
311
  async function runViteDev(viteConfig, customOptions, customDefines, viteCustomConfig) {
180
312
  if (!viteConfig) {
181
313
  viteConfig = makeViteDevConfig(customOptions, customDefines, viteCustomConfig);
182
314
  }
183
315
 
184
- const { createServer } = require('vite');
185
- const server = await createServer(viteConfig);
316
+ const projectRoot = process.cwd();
317
+ const projectConfig = await loadProjectViteConfig(projectRoot);
318
+ if (projectConfig) {
319
+ viteConfig = deepMergeViteConfig(viteConfig, projectConfig);
320
+ }
321
+
322
+ const { createRequire } = require('module');
323
+ const projRequire = createRequire(path.join(projectRoot, 'package.json'));
324
+ const { createServer } = projRequire('vite');
325
+ const server = await createServer({ ...viteConfig, configFile: false });
186
326
  await server.listen();
187
327
  server.printUrls();
188
328
  }
189
329
 
330
+ // ── 导出 ──
331
+
332
+ exports.json5Plugin = json5Plugin;
333
+ exports.assetInlinePlugin = assetInlinePlugin;
334
+ exports.fontFamilyPlugin = fontFamilyPlugin;
190
335
  exports.makeViteDevConfig = makeViteDevConfig;
191
336
  exports.runViteDev = runViteDev;
@@ -21,6 +21,53 @@ const { injectSDKPlugins } = require('./utils/injectSDKPlugins.js');
21
21
  const { resolveChannelFold2Zip } = require('./utils/resolveChannelFold2Zip.js');
22
22
 
23
23
 
24
+ const META = {
25
+ COMMON_CHARSET: 'utf-8',
26
+ COMMON_VIEWPORT: 'width=device-width,initial-scale=1.0,viewport-fit=cover,maximum-scale=1.0,user-scalable=no',
27
+ MINTEGRAL_VIEWPORT: 'width=device-width,user-scalable=no,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0',
28
+ };
29
+
30
+ const FontFamily = {
31
+ LIFTOFF: '<style>body{font-family:Roboto,"Helvetica Neue",-apple-system,BlinkMacSystemFont,sans-serif}</style>',
32
+ };
33
+
34
+ /**
35
+ * HtmlWebpackPlugin beforeEmit 钩子。
36
+ * 在最终 HTML 输出前统一修正 meta 标签:
37
+ * - charset → 统一小写 utf-8
38
+ * - viewport → 统一替换为渠道通道规范值
39
+ * 项目模板无需关心 meta 标签,全部由 devkit 构建时自动处理。
40
+ */
41
+ class MetaTagOverridePlugin {
42
+ constructor(adNetwork) {
43
+ this.adNetwork = adNetwork;
44
+ }
45
+
46
+ apply(compiler) {
47
+ compiler.hooks.compilation.tap('MetaTagOverridePlugin', (compilation) => {
48
+ HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tap('MetaTagOverridePlugin', (data) => {
49
+ // charset: 统一小写
50
+ data.html = data.html.replace(/<meta\s+charset=["'][^"']*["']\s*\/?>/i, '<meta charset="utf-8" />');
51
+ // viewport: Mintegral 需要 minimum-scale,其他渠道通用
52
+ const viewportContent = this.adNetwork === 'mintegral'
53
+ ? META.MINTEGRAL_VIEWPORT
54
+ : META.COMMON_VIEWPORT;
55
+ data.html = data.html.replace(
56
+ /<meta\s+name=["']viewport["'][^>]*\/?>/i,
57
+ `<meta name="viewport" content="${viewportContent}" />`
58
+ );
59
+ // font-family: Liftoff 渠道需要指定系统字体
60
+ if (this.adNetwork === 'liftoff') {
61
+ const fontStyle = FontFamily.LIFTOFF;
62
+ data.html = data.html.replace('</head>', fontStyle + '</head>');
63
+ }
64
+
65
+ return data;
66
+ });
67
+ });
68
+ }
69
+ }
70
+
24
71
  /** @type {AD_NETWORK[]} */
25
72
  /** @type {AD_NETWORK[]} */
26
73
  const zipOutputNetworks = [
@@ -83,11 +130,11 @@ function makeWebpackBuildConfig(customOptions, customDefines, webpackCustomConfi
83
130
  else htmlFileName = `${getFileName()}.html`;
84
131
 
85
132
  const metaTags = {
86
- viewport: 'width=device-width,initial-scale=1.0,viewport-fit=cover,maximum-scale=1.0,user-scalable=no'
133
+ viewport: META.COMMON_VIEWPORT,
87
134
  };
88
135
 
89
136
  if (adNetwork === 'mintegral') {
90
- metaTags['viewport'] = 'width=device-width,user-scalable=no,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0';
137
+ metaTags['viewport'] = META.MINTEGRAL_VIEWPORT;
91
138
  }
92
139
 
93
140
  if (!buildOptions.skipRecommendedMeta) {
@@ -176,6 +223,7 @@ function makeWebpackBuildConfig(customOptions, customDefines, webpackCustomConfi
176
223
  },
177
224
  plugins: [
178
225
  new HtmlWebpackPlugin(htmlWebpackPluginConfig),
226
+ new MetaTagOverridePlugin(adNetwork),
179
227
 
180
228
  new webpack.DefinePlugin({
181
229
  ...buildDefines(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/devkit",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "HTML5 Playable Ads 构建工具,支持多广告渠道打包",
5
5
  "main": "core/index.js",
6
6
  "module": "core/index.js",