pixifact-cli 0.6.0 → 0.7.0

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,578 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { watch as watchFiles, type FSWatcher } from 'node:fs';
3
- import { access, cp, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
4
- import path from 'node:path';
5
- import { build, type BuildOptions, type Plugin } from 'esbuild';
6
- import {
7
- parsePixifactProjectConfig,
8
- pixifactProjectConfigFileName,
9
- type PixifactProjectConfig,
10
- type PixifactWechatTargetConfig,
11
- } from 'pixifact';
12
- import {
13
- compilerSceneNodeLocator,
14
- parseSceneTemplate,
15
- type SceneTemplateNode,
16
- } from 'pixifact/compiler';
17
- import { compileScenes, generatedSceneAssetsFileName } from 'pixifact/compiler-node';
18
-
19
- const wechatMainPackageLimit = 4 * 1024 * 1024;
20
- const wechatTotalPackageLimit = 20 * 1024 * 1024;
21
- const supportedWechatTextureExtensions = new Set(['.jpeg', '.jpg', '.png', '.webp']);
22
-
23
- export interface WechatTargetDiagnostic {
24
- asset?: string;
25
- message: string;
26
- node?: string;
27
- scene?: string;
28
- }
29
-
30
- export interface WechatBuildReport {
31
- mainPackageBytes: number;
32
- outputDirectory: string;
33
- subpackages: Record<string, number>;
34
- totalPackageBytes: number;
35
- }
36
-
37
- export type WechatDevEvent = {
38
- type: 'built';
39
- report: WechatBuildReport;
40
- } | {
41
- type: 'error';
42
- error: string;
43
- diagnostics?: WechatTargetDiagnostic[];
44
- };
45
-
46
- export interface WechatDevSession {
47
- close(): void;
48
- initialReport: WechatBuildReport;
49
- }
50
-
51
- export class WechatTargetError extends Error {
52
- constructor(
53
- message: string,
54
- readonly diagnostics: WechatTargetDiagnostic[],
55
- ) {
56
- super(message);
57
- this.name = 'WechatTargetError';
58
- }
59
- }
60
-
61
- interface WechatTargetContext {
62
- config: PixifactProjectConfig;
63
- projectRoot: string;
64
- target: PixifactWechatTargetConfig;
65
- }
66
-
67
- interface SceneAssetLocation {
68
- pack?: string;
69
- src: string;
70
- }
71
-
72
- interface SceneAssetReference {
73
- asset: string;
74
- node: string;
75
- scene: string;
76
- }
77
-
78
- export async function validateWechatTarget(projectRootInput: string) {
79
- const context = await readTargetContext(projectRootInput);
80
- const diagnostics = await collectWechatDiagnostics(context);
81
- return { context, diagnostics };
82
- }
83
-
84
- export async function buildWechatTarget(
85
- projectRootInput: string,
86
- mode: 'development' | 'production',
87
- ): Promise<WechatBuildReport> {
88
- const { context, diagnostics } = await validateWechatTarget(projectRootInput);
89
- if (diagnostics.length > 0) {
90
- throw new WechatTargetError('WeChat target validation failed.', diagnostics);
91
- }
92
- await compileScenes({ projectRoot: context.projectRoot });
93
- const generatedDir = path.join(context.projectRoot, '.pixifact', 'generated');
94
- const assets = JSON.parse(
95
- await readFile(path.join(generatedDir, generatedSceneAssetsFileName), 'utf8'),
96
- ) as string[];
97
- const outputDirectory = path.resolve(context.projectRoot, context.target.outDir);
98
- assertSafeOutputDirectory(context, outputDirectory);
99
- await rm(outputDirectory, { force: true, recursive: true });
100
- await mkdir(outputDirectory, { recursive: true });
101
-
102
- const manifest = await emitTargetAssets(context, assets, outputDirectory);
103
- await copyWechatConfiguration(context, outputDirectory);
104
- await build(wechatBuildOptions(context, outputDirectory, manifest, mode));
105
- const report = await createBuildReport(context, outputDirectory);
106
- const sizeDiagnostics: WechatTargetDiagnostic[] = [];
107
- if (report.mainPackageBytes > wechatMainPackageLimit) {
108
- sizeDiagnostics.push({ message: `微信主包为 ${report.mainPackageBytes} bytes,超过 4 MiB 限制。` });
109
- }
110
- if (report.totalPackageBytes > wechatTotalPackageLimit) {
111
- sizeDiagnostics.push({ message: `微信代码包总计 ${report.totalPackageBytes} bytes,超过 20 MiB 限制。` });
112
- }
113
- if (sizeDiagnostics.length > 0) {
114
- throw new WechatTargetError('WeChat package size validation failed.', sizeDiagnostics);
115
- }
116
- return report;
117
- }
118
-
119
- export async function devWechatTarget(
120
- projectRootInput: string,
121
- onEvent: (event: WechatDevEvent) => void = () => undefined,
122
- ): Promise<WechatDevSession> {
123
- const initialReport = await buildWechatTarget(projectRootInput, 'development');
124
- const { context } = await validateWechatTarget(projectRootInput);
125
- const ignoredPaths = new Set([
126
- '.git',
127
- '.pixifact',
128
- 'node_modules',
129
- context.target.outDir,
130
- ]);
131
- let watcher: FSWatcher;
132
- let debounce: ReturnType<typeof setTimeout> | undefined;
133
- let building = false;
134
- let queued = false;
135
- let closed = false;
136
-
137
- const rebuild = async () => {
138
- if (closed) {
139
- return;
140
- }
141
- if (building) {
142
- queued = true;
143
- return;
144
- }
145
- building = true;
146
- try {
147
- const report = await buildWechatTarget(context.projectRoot, 'development');
148
- ignoredPaths.add(projectPath(context.projectRoot, report.outputDirectory));
149
- onEvent({ type: 'built', report });
150
- } catch (error) {
151
- onEvent(wechatDevErrorEvent(error));
152
- } finally {
153
- building = false;
154
- if (queued) {
155
- queued = false;
156
- void rebuild();
157
- }
158
- }
159
- };
160
- watcher = watchFiles(context.projectRoot, { recursive: true }, (_eventType, filename) => {
161
- const relativePath = filename?.toString().replaceAll(path.sep, '/');
162
- if (!relativePath || [...ignoredPaths].some((ignored) =>
163
- relativePath === ignored || relativePath.startsWith(`${ignored}/`)
164
- )) {
165
- return;
166
- }
167
- clearTimeout(debounce);
168
- debounce = setTimeout(() => void rebuild(), 80);
169
- });
170
-
171
- return {
172
- close() {
173
- if (closed) {
174
- return;
175
- }
176
- closed = true;
177
- clearTimeout(debounce);
178
- watcher.close();
179
- },
180
- initialReport,
181
- };
182
- }
183
-
184
- function wechatDevErrorEvent(error: unknown): WechatDevEvent {
185
- return error instanceof WechatTargetError
186
- ? { type: 'error', error: error.message, diagnostics: error.diagnostics }
187
- : { type: 'error', error: error instanceof Error ? error.message : String(error) };
188
- }
189
-
190
- async function readTargetContext(projectRootInput: string): Promise<WechatTargetContext> {
191
- const projectRoot = path.resolve(projectRootInput);
192
- const config = parsePixifactProjectConfig(JSON.parse(
193
- await readFile(path.join(projectRoot, pixifactProjectConfigFileName), 'utf8'),
194
- ));
195
- const target = config.targets?.wechat;
196
- if (!target) {
197
- throw new Error('pixifact.project.json does not declare targets.wechat.');
198
- }
199
- return { config, projectRoot, target };
200
- }
201
-
202
- function assertSafeOutputDirectory(context: WechatTargetContext, outputDirectory: string) {
203
- if (outputDirectory === context.projectRoot || !outputDirectory.startsWith(`${context.projectRoot}${path.sep}`)) {
204
- throw new Error('targets.wechat.outDir must be a directory inside projectRoot.');
205
- }
206
- const inputs = [
207
- '.pixifact',
208
- context.target.entry,
209
- context.target.configDir,
210
- ...Object.values(context.config.scenes),
211
- ...Object.values(context.config.resourcePacks ?? {}).map((pack) => pack.root),
212
- ];
213
- const outDir = context.target.outDir.replaceAll(path.sep, '/').replace(/\/+$/, '');
214
- const containedInput = inputs.find((input) => input === outDir || input.startsWith(`${outDir}/`));
215
- if (containedInput) {
216
- throw new Error(`targets.wechat.outDir must not contain project input "${containedInput}".`);
217
- }
218
- }
219
-
220
- async function collectWechatDiagnostics(context: WechatTargetContext) {
221
- const diagnostics: WechatTargetDiagnostic[] = [];
222
- const sceneAssets: SceneAssetReference[] = [];
223
- await requireFile(context, context.target.entry, diagnostics, '微信入口不存在。');
224
- await requireFile(
225
- context,
226
- path.posix.join(context.target.configDir, 'game.json'),
227
- diagnostics,
228
- '微信 game.json 不存在。',
229
- );
230
- await requireFile(
231
- context,
232
- path.posix.join(context.target.configDir, 'project.config.json'),
233
- diagnostics,
234
- '微信 project.config.json 不存在。',
235
- );
236
- for (const [name, pack] of Object.entries(context.config.resourcePacks ?? {})) {
237
- if (!context.target.resourcePacks[name]) {
238
- diagnostics.push({ message: `微信 target 未声明资源包 ${name} 的 delivery。` });
239
- }
240
- const root = path.resolve(context.projectRoot, pack.root);
241
- if (!await isDirectory(root)) {
242
- diagnostics.push({ asset: pack.root, message: `资源包 ${name} 的根目录不存在。` });
243
- continue;
244
- }
245
- for (const file of await collectFiles(root)) {
246
- if (path.extname(file).toLowerCase() === '.svg') {
247
- diagnostics.push({
248
- asset: projectPath(context.projectRoot, file),
249
- message: '微信小游戏目标不支持 SVG 资源。',
250
- });
251
- }
252
- }
253
- }
254
- for (const [name, delivery] of Object.entries(context.target.resourcePacks)) {
255
- if (delivery.delivery === 'remote' && !isHttpsUrl(delivery.baseUrl)) {
256
- diagnostics.push({ message: `远程资源包 ${name} 的 baseUrl 必须使用 HTTPS。` });
257
- }
258
- }
259
- for (const scene of await collectFiles(path.join(context.projectRoot, 'src'), '.scene')) {
260
- const scenePath = projectPath(context.projectRoot, scene);
261
- const template = parseSceneTemplate(await readFile(scene, 'utf8'));
262
- collectSceneDiagnostics(template.children, scenePath, '', diagnostics, sceneAssets);
263
- }
264
- for (const reference of sceneAssets) {
265
- if (!await exists(path.join(context.projectRoot, reference.asset))) {
266
- diagnostics.push({
267
- ...reference,
268
- message: '微信小游戏 Scene 纹理文件不存在。',
269
- });
270
- }
271
- }
272
- await validateWechatSubpackages(context, diagnostics);
273
- return diagnostics;
274
- }
275
-
276
- function isHttpsUrl(value: string) {
277
- try {
278
- const url = new URL(value);
279
- return url.protocol === 'https:' && url.hostname !== '';
280
- } catch {
281
- return false;
282
- }
283
- }
284
-
285
- function collectSceneDiagnostics(
286
- nodes: SceneTemplateNode[],
287
- scene: string,
288
- parentPath: string,
289
- diagnostics: WechatTargetDiagnostic[],
290
- assets: SceneAssetReference[],
291
- ) {
292
- for (const [index, node] of nodes.entries()) {
293
- const nodePath = parentPath ? `${parentPath}/${index}` : String(index);
294
- if (node.kind === 'slotOutlet') {
295
- continue;
296
- }
297
- if (node.kind === 'sceneInstance') {
298
- for (const [slotName, children] of Object.entries(node.slots)) {
299
- collectSceneDiagnostics(children, scene, `${nodePath}/slot:${slotName}`, diagnostics, assets);
300
- }
301
- continue;
302
- }
303
- const locator = compilerSceneNodeLocator(node, nodePath);
304
- if (node.type === 'HTMLText' || node.type === 'DOMContainer') {
305
- diagnostics.push({
306
- message: `微信小游戏目标不支持 ${node.type}。`,
307
- node: locator,
308
- scene,
309
- });
310
- }
311
- if (typeof node.props.texture === 'string') {
312
- const texture = node.props.texture;
313
- if (!isProjectAssetPath(texture)) {
314
- diagnostics.push({
315
- asset: texture,
316
- message: '微信小游戏 Scene 纹理必须使用项目根目录内的相对路径。',
317
- node: locator,
318
- scene,
319
- });
320
- } else if (!supportedWechatTextureExtensions.has(path.extname(texture).toLowerCase())) {
321
- const extension = path.extname(texture).toLowerCase();
322
- diagnostics.push({
323
- asset: texture,
324
- message: `微信小游戏 Scene 纹理不支持 ${extension || '无扩展名'} 格式。`,
325
- node: locator,
326
- scene,
327
- });
328
- } else {
329
- assets.push({ asset: texture, node: locator, scene });
330
- }
331
- }
332
- collectSceneDiagnostics(node.children, scene, nodePath, diagnostics, assets);
333
- }
334
- }
335
-
336
- function isProjectAssetPath(value: string) {
337
- return value.trim() !== ''
338
- && !value.startsWith('/')
339
- && !value.startsWith('./')
340
- && !value.includes('\\')
341
- && !value.split('/').includes('..')
342
- && !/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value);
343
- }
344
-
345
- async function validateWechatSubpackages(
346
- context: WechatTargetContext,
347
- diagnostics: WechatTargetDiagnostic[],
348
- ) {
349
- const gameJsonPath = path.join(context.projectRoot, context.target.configDir, 'game.json');
350
- if (!await exists(gameJsonPath)) {
351
- return;
352
- }
353
- const gameJson = JSON.parse(await readFile(gameJsonPath, 'utf8')) as {
354
- subpackages?: Array<{ name?: string; root?: string }>;
355
- };
356
- for (const [name, delivery] of Object.entries(context.target.resourcePacks)) {
357
- if (delivery.delivery !== 'subpackage') {
358
- continue;
359
- }
360
- const declaration = gameJson.subpackages?.find((subpackage) => subpackage.name === name);
361
- if (!declaration || declaration.root !== delivery.root) {
362
- diagnostics.push({
363
- message: `game.json 必须声明分包 ${name},root 为 ${delivery.root}。`,
364
- });
365
- }
366
- }
367
- }
368
-
369
- async function emitTargetAssets(
370
- context: WechatTargetContext,
371
- assets: string[],
372
- outputDirectory: string,
373
- ) {
374
- const manifest: Record<string, SceneAssetLocation> = {};
375
- for (const [name, pack] of Object.entries(context.config.resourcePacks ?? {})) {
376
- const delivery = context.target.resourcePacks[name];
377
- if (delivery.delivery !== 'subpackage') {
378
- continue;
379
- }
380
- const source = path.join(context.projectRoot, pack.root);
381
- const destination = path.join(outputDirectory, delivery.root);
382
- await cp(source, destination, { recursive: true });
383
- const entry = path.join(destination, 'game.js');
384
- if (!await exists(entry)) {
385
- await writeFile(entry, '// Pixifact asset-only subpackage.\n');
386
- }
387
- }
388
- for (const asset of assets) {
389
- const source = path.resolve(context.projectRoot, asset);
390
- const pack = resourcePackFor(context, asset);
391
- if (pack) {
392
- const sourceRoot = context.config.resourcePacks![pack.name].root;
393
- const relative = path.posix.relative(sourceRoot, asset);
394
- const delivery = pack.delivery;
395
- manifest[asset] = delivery.delivery === 'subpackage'
396
- ? { pack: pack.name, src: path.posix.join(delivery.root, relative) }
397
- : { src: `${delivery.baseUrl}/${relative}` };
398
- continue;
399
- }
400
- const content = await readFile(source);
401
- const extension = path.extname(asset);
402
- const basename = path.basename(asset, extension);
403
- const hash = createHash('sha256').update(content).digest('hex').slice(0, 8);
404
- const output = path.posix.join('assets', `${basename}-${hash}${extension}`);
405
- await mkdir(path.dirname(path.join(outputDirectory, output)), { recursive: true });
406
- await writeFile(path.join(outputDirectory, output), content);
407
- manifest[asset] = { src: output };
408
- }
409
- return manifest;
410
- }
411
-
412
- function resourcePackFor(context: WechatTargetContext, asset: string) {
413
- const matches = Object.entries(context.config.resourcePacks ?? {}).filter(([, pack]) =>
414
- asset === pack.root || asset.startsWith(`${pack.root}/`)
415
- );
416
- if (matches.length > 1) {
417
- throw new Error(`Scene asset "${asset}" matches multiple resource packs.`);
418
- }
419
- const match = matches[0];
420
- return match
421
- ? { name: match[0], delivery: context.target.resourcePacks[match[0]] }
422
- : undefined;
423
- }
424
-
425
- async function copyWechatConfiguration(context: WechatTargetContext, outputDirectory: string) {
426
- for (const file of ['game.json', 'project.config.json']) {
427
- await cp(
428
- path.join(context.projectRoot, context.target.configDir, file),
429
- path.join(outputDirectory, file),
430
- );
431
- }
432
- }
433
-
434
- function wechatBuildOptions(
435
- context: WechatTargetContext,
436
- outputDirectory: string,
437
- manifest: Record<string, SceneAssetLocation>,
438
- mode: 'development' | 'production',
439
- ): BuildOptions {
440
- return {
441
- absWorkingDir: context.projectRoot,
442
- assetNames: 'assets/[name]-[hash]',
443
- banner: {
444
- js: [
445
- 'var Intl=globalThis.Intl||{};',
446
- 'var navigator=globalThis.navigator||(globalThis.navigator={gpu:null,userAgent:"WeChatMiniGame"});',
447
- ].join('\n'),
448
- },
449
- bundle: true,
450
- define: {
451
- 'process.env.NODE_ENV': JSON.stringify(mode),
452
- },
453
- entryPoints: [path.resolve(context.projectRoot, context.target.entry)],
454
- format: 'iife',
455
- legalComments: 'none',
456
- loader: {
457
- '.jpeg': 'file',
458
- '.jpg': 'file',
459
- '.mp3': 'file',
460
- '.ogg': 'file',
461
- '.png': 'file',
462
- '.wav': 'file',
463
- '.webp': 'file',
464
- },
465
- logLevel: 'silent',
466
- minify: mode === 'production',
467
- outfile: path.join(outputDirectory, 'game.js'),
468
- platform: 'browser',
469
- plugins: [wechatScenesPlugin(context, manifest)],
470
- sourcemap: mode === 'development',
471
- target: 'es2020',
472
- };
473
- }
474
-
475
- function wechatScenesPlugin(
476
- context: WechatTargetContext,
477
- manifest: Record<string, SceneAssetLocation>,
478
- ): Plugin {
479
- return {
480
- name: 'pixifact-wechat-scenes',
481
- setup(buildContext) {
482
- buildContext.onResolve({ filter: /^pixifact:scenes$/ }, () => ({
483
- namespace: 'pixifact-wechat',
484
- path: 'scenes',
485
- }));
486
- buildContext.onLoad({ filter: /^scenes$/, namespace: 'pixifact-wechat' }, () => ({
487
- contents: [
488
- "import { configureSceneAssets } from 'pixifact/scene';",
489
- "import { loadWechatSubpackage } from 'pixifact/platform/wechat';",
490
- `import ${JSON.stringify(path.join(context.projectRoot, '.pixifact', 'generated', 'scenes.generated.ts'))};`,
491
- `configureSceneAssets(${JSON.stringify(manifest)}, { loadPack: loadWechatSubpackage });`,
492
- ].join('\n'),
493
- loader: 'ts',
494
- resolveDir: context.projectRoot,
495
- }));
496
- },
497
- };
498
- }
499
-
500
- async function createBuildReport(
501
- context: WechatTargetContext,
502
- outputDirectory: string,
503
- ): Promise<WechatBuildReport> {
504
- const subpackageRoots = Object.fromEntries(
505
- Object.entries(context.target.resourcePacks)
506
- .filter(([, delivery]) => delivery.delivery === 'subpackage')
507
- .map(([name, delivery]) => [name, delivery.delivery === 'subpackage' ? delivery.root : '']),
508
- );
509
- const subpackages = Object.fromEntries(Object.keys(subpackageRoots).map((name) => [name, 0]));
510
- let mainPackageBytes = 0;
511
- let totalPackageBytes = 0;
512
- const files = await collectFiles(outputDirectory);
513
- for (const file of files) {
514
- if (file.endsWith('.map') || file.endsWith('project.config.json')) {
515
- continue;
516
- }
517
- const bytes = (await stat(file)).size;
518
- const relative = projectPath(outputDirectory, file);
519
- const subpackage = Object.entries(subpackageRoots).find(([, root]) =>
520
- relative === root || relative.startsWith(`${root}/`)
521
- );
522
- if (subpackage) {
523
- subpackages[subpackage[0]] += bytes;
524
- } else {
525
- mainPackageBytes += bytes;
526
- }
527
- totalPackageBytes += bytes;
528
- }
529
- return { mainPackageBytes, outputDirectory, subpackages, totalPackageBytes };
530
- }
531
-
532
- async function requireFile(
533
- context: WechatTargetContext,
534
- relativePath: string,
535
- diagnostics: WechatTargetDiagnostic[],
536
- message: string,
537
- ) {
538
- if (!await exists(path.join(context.projectRoot, relativePath))) {
539
- diagnostics.push({ asset: relativePath, message });
540
- }
541
- }
542
-
543
- async function collectFiles(root: string, suffix?: string) {
544
- const files: string[] = [];
545
- if (!await isDirectory(root)) {
546
- return files;
547
- }
548
- for (const entry of await readdir(root, { withFileTypes: true })) {
549
- const absolute = path.join(root, entry.name);
550
- if (entry.isDirectory()) {
551
- files.push(...await collectFiles(absolute, suffix));
552
- } else if (entry.isFile() && (!suffix || entry.name.endsWith(suffix))) {
553
- files.push(absolute);
554
- }
555
- }
556
- return files;
557
- }
558
-
559
- async function exists(filePath: string) {
560
- try {
561
- await access(filePath);
562
- return true;
563
- } catch {
564
- return false;
565
- }
566
- }
567
-
568
- async function isDirectory(filePath: string) {
569
- try {
570
- return (await stat(filePath)).isDirectory();
571
- } catch {
572
- return false;
573
- }
574
- }
575
-
576
- function projectPath(root: string, filePath: string) {
577
- return path.relative(root, filePath).replaceAll(path.sep, '/');
578
- }