@openfairygui/functions 0.2.0-alpha.11 → 0.2.0-alpha.13

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,11 +1,11 @@
1
1
  import type { Document } from '@openfairygui/core';
2
- import { createJiti } from 'jiti';
3
- import type { Plugin, PluginManifest, PluginModule } from './types.js';
4
-
5
- export interface LoadedPlugin {
6
- name: string;
7
- plugin: Plugin;
8
- }
2
+ import {
3
+ formatPluginError,
4
+ type LoadedPlugin,
5
+ type Plugin,
6
+ type PluginManifest,
7
+ type PluginModule,
8
+ } from '../../plugins/types.js';
9
9
 
10
10
  interface PluginPackageJson extends Partial<PluginManifest> {
11
11
  name?: string;
@@ -46,10 +46,6 @@ export async function loadPlugins(doc: Document, pluginsDir: string): Promise<Lo
46
46
  return plugins;
47
47
  }
48
48
 
49
- export function formatPluginError(error: unknown): string {
50
- return error instanceof Error ? error.message : String(error);
51
- }
52
-
53
49
  async function readPluginManifest(
54
50
  fs: typeof import('node:fs/promises'),
55
51
  path: typeof import('node:path'),
@@ -73,6 +69,7 @@ function resolvePluginMain(path: typeof import('node:path'), pluginDir: string,
73
69
  }
74
70
 
75
71
  async function loadPlugin(mainPath: string): Promise<Plugin> {
72
+ const { createJiti } = await importNative<typeof import('jiti')>('jiti');
76
73
  const jiti = createJiti(import.meta.url);
77
74
  const mod = await jiti.import<PluginModule>(mainPath);
78
75
  const defaultExport = mod.default;
@@ -0,0 +1,129 @@
1
+ import type { Document } from '@openfairygui/core';
2
+ import { resolveProjectBasePath } from '../../codegen.js';
3
+ import { publish, type PublishOptions } from '../../publish.js';
4
+ import type { AtlasRasterBackend, PublishFileSystem } from '../../publish/contracts.js';
5
+ import type { LoadedPlugin } from '../../plugins/types.js';
6
+ import { loadPlugins } from './plugins.js';
7
+
8
+ const importNative = new Function('id', 'return import(id)') as <T>(id: string) => Promise<T>;
9
+
10
+ interface NodePublishFileSystem extends PublishFileSystem {
11
+ readFileRaw(path: string): Promise<Uint8Array>;
12
+ }
13
+
14
+ export interface PublishNodeOptions extends Omit<PublishOptions, 'atlas' | 'basePath' | 'encoder' | 'fs' | 'plugins'> {
15
+ document: Document;
16
+ /**
17
+ * Assets directory. Defaults to `<document project dir>/assets` when available.
18
+ */
19
+ assetsPath?: string;
20
+ /**
21
+ * Override the standard Sharp raster backend.
22
+ */
23
+ encoder?: AtlasRasterBackend;
24
+ /**
25
+ * Supply already-loaded hooks. Pass an empty array to skip project plugin discovery.
26
+ */
27
+ plugins?: LoadedPlugin[];
28
+ atlas?: Omit<NonNullable<PublishOptions['atlas']>, 'readFileRaw'>;
29
+ }
30
+
31
+ async function createNodePublishFileSystem(): Promise<NodePublishFileSystem> {
32
+ const [fs, path] = await Promise.all([
33
+ importNative<typeof import('node:fs/promises')>('node:fs/promises'),
34
+ importNative<typeof import('node:path')>('node:path'),
35
+ ]);
36
+
37
+ return {
38
+ async readFileRaw(filePath: string): Promise<Uint8Array> {
39
+ const data = await fs.readFile(filePath);
40
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
41
+ },
42
+ async writeFileRaw(filePath: string, data: Uint8Array): Promise<void> {
43
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
44
+ await fs.writeFile(filePath, data);
45
+ },
46
+ async mkdir(dirPath: string): Promise<void> {
47
+ await fs.mkdir(dirPath, { recursive: true });
48
+ },
49
+ async readdir(dirPath: string): Promise<string[]> {
50
+ return fs.readdir(dirPath);
51
+ },
52
+ async deleteFile(filePath: string): Promise<void> {
53
+ await fs.rm(filePath, { force: true });
54
+ },
55
+ join(...paths: string[]): string {
56
+ return path.join(...paths);
57
+ },
58
+ };
59
+ }
60
+
61
+ async function resolveNodeAssetsPath(document: Document, assetsPath: string | undefined): Promise<string | undefined> {
62
+ if (assetsPath) return assetsPath;
63
+ const projectDir = document.getProjectDir?.() ?? '';
64
+ if (!projectDir) return undefined;
65
+ const path = await importNative<typeof import('node:path')>('node:path');
66
+ return path.join(projectDir, 'assets');
67
+ }
68
+
69
+ async function loadSharpBackend(): Promise<AtlasRasterBackend | undefined> {
70
+ try {
71
+ const sharp = await importNative<typeof import('sharp')>('sharp');
72
+ return (sharp.default ?? sharp) as unknown as AtlasRasterBackend;
73
+ } catch {
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ async function loadNodePublishPlugins(document: Document, assetsPath: string | undefined): Promise<LoadedPlugin[]> {
79
+ const projectDir = document.getProjectDir?.() || (assetsPath ? resolveProjectBasePath(assetsPath) : '');
80
+ if (!projectDir) return [];
81
+ const path = await importNative<typeof import('node:path')>('node:path');
82
+ return loadPlugins(document, path.join(projectDir, 'plugins'));
83
+ }
84
+
85
+ /**
86
+ * Publish a FairyGUI project through the standard Node host adapter.
87
+ *
88
+ * The adapter owns Node filesystem, Sharp, and project plugin discovery.
89
+ * For custom environments, use the lower-level `publish()` core with explicit
90
+ * capabilities instead.
91
+ */
92
+ export async function publishNode(options: PublishNodeOptions): Promise<void> {
93
+ const {
94
+ document,
95
+ assetsPath: configuredAssetsPath,
96
+ atlas,
97
+ encoder: configuredEncoder,
98
+ plugins: configuredPlugins,
99
+ ...publishOptions
100
+ } = options;
101
+ const [fileSystem, assetsPath] = await Promise.all([
102
+ createNodePublishFileSystem(),
103
+ resolveNodeAssetsPath(document, configuredAssetsPath),
104
+ ]);
105
+ const [encoder, plugins] = await Promise.all([
106
+ configuredEncoder === undefined ? loadSharpBackend() : Promise.resolve(configuredEncoder),
107
+ configuredPlugins === undefined
108
+ ? loadNodePublishPlugins(document, assetsPath)
109
+ : Promise.resolve(configuredPlugins),
110
+ ]);
111
+
112
+ if (!encoder) {
113
+ document.getLogger().warn('publish: Sharp is unavailable; atlas layout will be generated without PNG output.');
114
+ }
115
+
116
+ await document.transform(
117
+ publish({
118
+ ...publishOptions,
119
+ basePath: assetsPath,
120
+ encoder,
121
+ atlas: {
122
+ ...atlas,
123
+ readFileRaw: fileSystem.readFileRaw,
124
+ },
125
+ fs: fileSystem,
126
+ plugins,
127
+ }),
128
+ );
129
+ }
@@ -0,0 +1,404 @@
1
+ import { type Document, type ILogger, ProjectType } from '@openfairygui/core';
2
+ import type { AtlasOptions } from '../../atlas.js';
3
+ import { publish } from '../../publish.js';
4
+ import type {
5
+ AtlasRasterBackend,
6
+ AtlasRasterInput,
7
+ AtlasRasterPipeline,
8
+ AtlasRasterResolvedBuffer,
9
+ PublishFileSystem,
10
+ PublishOutputFileSystem,
11
+ PublishSourceFileSystem,
12
+ } from '../../publish/contracts.js';
13
+
14
+ export type BrowserPublishProjectType = 'layabox';
15
+
16
+ export type BrowserPublishAtlasOptions = Pick<
17
+ AtlasOptions,
18
+ | 'maxSize'
19
+ | 'fast'
20
+ | 'allowRotation'
21
+ | 'padding'
22
+ | 'powerOfTwo'
23
+ | 'square'
24
+ | 'multiPage'
25
+ | 'trimImage'
26
+ | 'extractAlpha'
27
+ >;
28
+
29
+ export type BrowserPublishSourceFileSystem = PublishSourceFileSystem;
30
+
31
+ export type BrowserPublishOutputFileSystem = PublishOutputFileSystem;
32
+
33
+ export interface BrowserPublishOptions {
34
+ document: Document;
35
+ sourceFileSystem: BrowserPublishSourceFileSystem;
36
+ outputFileSystem: BrowserPublishOutputFileSystem;
37
+ projectType: BrowserPublishProjectType;
38
+ output: string;
39
+ compressed?: boolean;
40
+ packages?: string[];
41
+ branch?: string;
42
+ atlas?: BrowserPublishAtlasOptions;
43
+ }
44
+
45
+ export interface BrowserPublishDiagnostic {
46
+ level: 'debug' | 'info' | 'warning' | 'error';
47
+ message: string;
48
+ }
49
+
50
+ export interface BrowserPublishedFile {
51
+ path: string;
52
+ size: number;
53
+ }
54
+
55
+ export interface BrowserPublishResult {
56
+ success: boolean;
57
+ files: BrowserPublishedFile[];
58
+ diagnostics: BrowserPublishDiagnostic[];
59
+ }
60
+
61
+ type BrowserCanvas = OffscreenCanvas | HTMLCanvasElement;
62
+
63
+ interface BrowserContext {
64
+ clearRect(x: number, y: number, width: number, height: number): void;
65
+ drawImage(image: CanvasImageSource, dx: number, dy: number): void;
66
+ drawImage(
67
+ image: CanvasImageSource,
68
+ sx: number,
69
+ sy: number,
70
+ sw: number,
71
+ sh: number,
72
+ dx: number,
73
+ dy: number,
74
+ dw: number,
75
+ dh: number,
76
+ ): void;
77
+ fillRect(x: number, y: number, width: number, height: number): void;
78
+ getImageData(sx: number, sy: number, sw: number, sh: number): ImageData;
79
+ rotate(angle: number): void;
80
+ restore(): void;
81
+ save(): void;
82
+ translate(x: number, y: number): void;
83
+ fillStyle: string | CanvasGradient | CanvasPattern;
84
+ }
85
+
86
+ interface BrowserRaster {
87
+ canvas: BrowserCanvas;
88
+ width: number;
89
+ height: number;
90
+ }
91
+
92
+ function getBrowserContext(canvas: BrowserCanvas): BrowserContext {
93
+ const context = canvas.getContext('2d');
94
+ if (!context) throw new Error('publishBrowser: a 2D canvas context is unavailable.');
95
+ return context as unknown as BrowserContext;
96
+ }
97
+
98
+ function createBrowserCanvas(width: number, height: number): BrowserCanvas {
99
+ if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(width, height);
100
+ if (typeof globalThis.document === 'undefined') {
101
+ throw new Error('publishBrowser: OffscreenCanvas or a DOM canvas is required for atlas PNG generation.');
102
+ }
103
+ const canvas = globalThis.document.createElement('canvas');
104
+ canvas.width = width;
105
+ canvas.height = height;
106
+ return canvas;
107
+ }
108
+
109
+ function assertBrowserImageSupport(): void {
110
+ if (typeof createImageBitmap !== 'function') {
111
+ throw new Error('publishBrowser: createImageBitmap is required for atlas PNG generation.');
112
+ }
113
+ if (typeof OffscreenCanvas === 'undefined' && typeof globalThis.document === 'undefined') {
114
+ throw new Error('publishBrowser: OffscreenCanvas or a DOM canvas is required for atlas PNG generation.');
115
+ }
116
+ }
117
+
118
+ function createRaster(
119
+ width: number,
120
+ height: number,
121
+ background?: { r: number; g: number; b: number; alpha: number },
122
+ ): BrowserRaster {
123
+ const canvas = createBrowserCanvas(width, height);
124
+ const context = getBrowserContext(canvas);
125
+ context.clearRect(0, 0, width, height);
126
+ if (background && background.alpha > 0) {
127
+ context.fillStyle = `rgba(${background.r}, ${background.g}, ${background.b}, ${background.alpha})`;
128
+ context.fillRect(0, 0, width, height);
129
+ }
130
+ return { canvas, width, height };
131
+ }
132
+
133
+ function imageMimeType(path: string): string {
134
+ if (/\.svg$/iu.test(path)) return 'image/svg+xml';
135
+ if (/\.jpe?g$/iu.test(path)) return 'image/jpeg';
136
+ if (/\.webp$/iu.test(path)) return 'image/webp';
137
+ if (/\.gif$/iu.test(path)) return 'image/gif';
138
+ return 'image/png';
139
+ }
140
+
141
+ function imageMimeTypeFromBytes(bytes: Uint8Array): string {
142
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) return 'image/jpeg';
143
+ if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif';
144
+ if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) return 'image/webp';
145
+ return 'image/png';
146
+ }
147
+
148
+ async function canvasToPng(canvas: BrowserCanvas): Promise<Uint8Array> {
149
+ let blob: Blob;
150
+ if ('convertToBlob' in canvas && typeof canvas.convertToBlob === 'function') {
151
+ blob = await canvas.convertToBlob({ type: 'image/png' });
152
+ } else {
153
+ blob = await new Promise<Blob>((resolve, reject) => {
154
+ (canvas as HTMLCanvasElement).toBlob((value) => {
155
+ if (value) resolve(value);
156
+ else reject(new Error('publishBrowser: canvas PNG encoding failed.'));
157
+ }, 'image/png');
158
+ });
159
+ }
160
+ return new Uint8Array(await blob.arrayBuffer());
161
+ }
162
+
163
+ async function decodeRaster(bytes: Uint8Array, mimeType: string): Promise<BrowserRaster> {
164
+ if (typeof createImageBitmap !== 'function') {
165
+ throw new Error('publishBrowser: createImageBitmap is required for atlas PNG generation.');
166
+ }
167
+ const copy = bytes.slice();
168
+ const bitmap = await createImageBitmap(new Blob([copy.buffer as ArrayBuffer], { type: mimeType }));
169
+ try {
170
+ const raster = createRaster(bitmap.width, bitmap.height);
171
+ getBrowserContext(raster.canvas).drawImage(bitmap, 0, 0);
172
+ return raster;
173
+ } finally {
174
+ bitmap.close();
175
+ }
176
+ }
177
+
178
+ class BrowserImagePipeline implements AtlasRasterPipeline {
179
+ private rawOutput = false;
180
+
181
+ constructor(
182
+ private raster: Promise<BrowserRaster>,
183
+ private readonly decode: (bytes: Uint8Array) => Promise<BrowserRaster>,
184
+ private readonly write: (path: string, data: Uint8Array) => Promise<void>,
185
+ ) {}
186
+
187
+ ensureAlpha(): this {
188
+ return this;
189
+ }
190
+
191
+ resize(options: { width: number; height: number; fit?: 'fill' }): this {
192
+ this.raster = this.raster.then((source) => {
193
+ const target = createRaster(options.width, options.height);
194
+ getBrowserContext(target.canvas).drawImage(source.canvas, 0, 0, options.width, options.height);
195
+ return target;
196
+ });
197
+ return this;
198
+ }
199
+
200
+ raw(): this {
201
+ this.rawOutput = true;
202
+ return this;
203
+ }
204
+
205
+ extract(options: { left: number; top: number; width: number; height: number }): this {
206
+ this.raster = this.raster.then((source) => {
207
+ const target = createRaster(options.width, options.height);
208
+ getBrowserContext(target.canvas).drawImage(
209
+ source.canvas,
210
+ options.left,
211
+ options.top,
212
+ options.width,
213
+ options.height,
214
+ 0,
215
+ 0,
216
+ options.width,
217
+ options.height,
218
+ );
219
+ return target;
220
+ });
221
+ return this;
222
+ }
223
+
224
+ png(): this {
225
+ this.rawOutput = false;
226
+ return this;
227
+ }
228
+
229
+ rotate(angle: number): this {
230
+ this.raster = this.raster.then((source) => {
231
+ if (angle % 180 === 0) return source;
232
+ const target = createRaster(source.height, source.width);
233
+ const context = getBrowserContext(target.canvas);
234
+ context.save();
235
+ if (angle === 270 || angle === -90) {
236
+ context.translate(0, source.width);
237
+ context.rotate(-Math.PI / 2);
238
+ } else {
239
+ context.translate(source.height, 0);
240
+ context.rotate(Math.PI / 2);
241
+ }
242
+ context.drawImage(source.canvas, 0, 0);
243
+ context.restore();
244
+ return target;
245
+ });
246
+ return this;
247
+ }
248
+
249
+ composite(inputs: Array<{ input: Uint8Array; left: number; top: number }>): this {
250
+ this.raster = this.raster.then(async (target) => {
251
+ const context = getBrowserContext(target.canvas);
252
+ for (const input of inputs) {
253
+ const source = await this.decode(input.input);
254
+ context.drawImage(source.canvas, input.left, input.top);
255
+ }
256
+ return target;
257
+ });
258
+ return this;
259
+ }
260
+
261
+ async metadata(): Promise<{ width: number; height: number; channels: number; hasAlpha: boolean }> {
262
+ const raster = await this.raster;
263
+ return { width: raster.width, height: raster.height, channels: 4, hasAlpha: true };
264
+ }
265
+
266
+ async toBuffer(options: { resolveWithObject: true }): Promise<AtlasRasterResolvedBuffer>;
267
+ async toBuffer(options?: { resolveWithObject?: false }): Promise<Uint8Array>;
268
+ async toBuffer(options?: { resolveWithObject?: boolean }): Promise<Uint8Array | AtlasRasterResolvedBuffer> {
269
+ const raster = await this.raster;
270
+ if (options?.resolveWithObject) {
271
+ const data = getBrowserContext(raster.canvas).getImageData(0, 0, raster.width, raster.height).data;
272
+ return { data: new Uint8Array(data), info: { width: raster.width, height: raster.height, channels: 4 } };
273
+ }
274
+ if (this.rawOutput)
275
+ return new Uint8Array(
276
+ getBrowserContext(raster.canvas).getImageData(0, 0, raster.width, raster.height).data,
277
+ );
278
+ return canvasToPng(raster.canvas);
279
+ }
280
+
281
+ async toFile(path: string): Promise<void> {
282
+ const raster = await this.raster;
283
+ await this.write(path, await canvasToPng(raster.canvas));
284
+ }
285
+ }
286
+
287
+ function createBrowserImageEncoder(
288
+ sourceFileSystem: BrowserPublishSourceFileSystem,
289
+ outputFileSystem: PublishFileSystem,
290
+ ): AtlasRasterBackend {
291
+ const decode = (bytes: Uint8Array) => decodeRaster(bytes, imageMimeTypeFromBytes(bytes));
292
+ return (input: AtlasRasterInput): BrowserImagePipeline => {
293
+ const raster =
294
+ typeof input === 'string'
295
+ ? sourceFileSystem.readFileRaw(input).then((bytes) => decodeRaster(bytes, imageMimeType(input)))
296
+ : input instanceof Uint8Array
297
+ ? decode(input)
298
+ : Promise.resolve(createRaster(input.create.width, input.create.height, input.create.background));
299
+ return new BrowserImagePipeline(raster, decode, outputFileSystem.writeFileRaw);
300
+ };
301
+ }
302
+
303
+ function createTrackingFileSystem(
304
+ fileSystem: BrowserPublishOutputFileSystem,
305
+ files: Map<string, number>,
306
+ ): PublishFileSystem {
307
+ const tracked: PublishFileSystem = {
308
+ join: (...paths) => fileSystem.join(...paths),
309
+ mkdir: (path) => fileSystem.mkdir(path),
310
+ writeFileRaw: async (path, data) => {
311
+ await fileSystem.writeFileRaw(path, data);
312
+ files.set(path, data.byteLength);
313
+ },
314
+ };
315
+ return tracked;
316
+ }
317
+
318
+ function createDiagnosticLogger(logger: ILogger, diagnostics: BrowserPublishDiagnostic[]): ILogger {
319
+ return {
320
+ debug(message) {
321
+ diagnostics.push({ level: 'debug', message });
322
+ logger.debug(message);
323
+ },
324
+ info(message) {
325
+ diagnostics.push({ level: 'info', message });
326
+ logger.info(message);
327
+ },
328
+ warn(message) {
329
+ diagnostics.push({ level: 'warning', message });
330
+ logger.warn(message);
331
+ },
332
+ error(message) {
333
+ diagnostics.push({ level: 'error', message });
334
+ logger.error(message);
335
+ },
336
+ };
337
+ }
338
+
339
+ function toResult(
340
+ success: boolean,
341
+ files: Map<string, number>,
342
+ diagnostics: BrowserPublishDiagnostic[],
343
+ ): BrowserPublishResult {
344
+ return {
345
+ success,
346
+ files: [...files].map(([path, size]) => ({ path, size })),
347
+ diagnostics,
348
+ };
349
+ }
350
+
351
+ /**
352
+ * Publish a loaded FairyGUI project to browser-provided storage.
353
+ *
354
+ * The adapter uses browser Canvas APIs for atlas composition, writes only through
355
+ * the supplied output filesystem, and intentionally skips Node publish plugins.
356
+ */
357
+ export async function publishBrowser(options: BrowserPublishOptions): Promise<BrowserPublishResult> {
358
+ const files = new Map<string, number>();
359
+ const diagnostics: BrowserPublishDiagnostic[] = [];
360
+ const root = options.document.getRoot();
361
+ const previousProjectType = root.getProjectType();
362
+ const previousLogger = options.document.getLogger();
363
+ options.document.setLogger(createDiagnosticLogger(previousLogger, diagnostics));
364
+
365
+ try {
366
+ if (options.projectType !== 'layabox') {
367
+ throw new Error(`publishBrowser: unsupported project type "${String(options.projectType)}".`);
368
+ }
369
+ assertBrowserImageSupport();
370
+ root.setProjectType(ProjectType.LayaBox);
371
+ const outputFileSystem = createTrackingFileSystem(options.outputFileSystem, files);
372
+ const sourceAssetsPath = options.sourceFileSystem.join(options.document.getProjectDir(), 'assets');
373
+
374
+ await options.document.transform(
375
+ publish({
376
+ output: options.output,
377
+ compressed: options.compressed,
378
+ fileExtension: 'fui',
379
+ packages: options.packages,
380
+ branch: options.branch,
381
+ basePath: sourceAssetsPath,
382
+ encoder: createBrowserImageEncoder(options.sourceFileSystem, outputFileSystem),
383
+ atlas: {
384
+ ...options.atlas,
385
+ readFileRaw: (path) => options.sourceFileSystem.readFileRaw(path),
386
+ },
387
+ fs: outputFileSystem,
388
+ plugins: [],
389
+ codeGeneration: false,
390
+ }),
391
+ );
392
+
393
+ return toResult(true, files, diagnostics);
394
+ } catch (error) {
395
+ diagnostics.push({
396
+ level: 'error',
397
+ message: error instanceof Error ? error.message : String(error),
398
+ });
399
+ return toResult(false, files, diagnostics);
400
+ } finally {
401
+ root.setProjectType(previousProjectType);
402
+ options.document.setLogger(previousLogger);
403
+ }
404
+ }