@openfairygui/functions 0.3.0-alpha.4 → 0.3.1

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.
Files changed (37) hide show
  1. package/dist/{atlas-C6tbl7nn.d.ts → atlas-BtWa1DwO.d.ts} +6 -0
  2. package/dist/{atlas-CHsu2Y8i.d.cts → atlas-CSqHsG0X.d.cts} +6 -0
  3. package/dist/index.cjs +2 -2
  4. package/dist/index.d.cts +2 -2
  5. package/dist/index.d.ts +2 -2
  6. package/dist/index.js +2 -2
  7. package/dist/node.cjs +128 -30
  8. package/dist/node.d.cts +2 -2
  9. package/dist/node.d.ts +2 -2
  10. package/dist/node.js +128 -30
  11. package/dist/{publish-CyBj2o7n.js → publish-Cy09yjfn.js} +122 -54
  12. package/dist/{publish-C7qHwEiP.cjs → publish-DhMv_lEP.cjs} +122 -54
  13. package/dist/{restore-BeWaJNjR.d.cts → restore-CbscKkNw.d.ts} +9 -4
  14. package/dist/{restore-DVo1hXpN.js → restore-DB_L_xq4.js} +1 -1
  15. package/dist/{restore-Dh0-Nvms.d.ts → restore-k3oX26Fs.d.cts} +9 -4
  16. package/dist/{restore-LXbDQqmT.cjs → restore-n61oZ4nw.cjs} +1 -1
  17. package/dist/web.cjs +47 -64
  18. package/dist/web.d.cts +2 -2
  19. package/dist/web.d.ts +2 -2
  20. package/dist/web.js +49 -66
  21. package/package.json +5 -2
  22. package/src/adapters/node/plugins.ts +29 -13
  23. package/src/adapters/node/publish.ts +74 -4
  24. package/src/adapters/node/validate.ts +1 -0
  25. package/src/adapters/web/publish.ts +2 -0
  26. package/src/adapters/web/raster.ts +55 -72
  27. package/src/atlas/packing.ts +22 -13
  28. package/src/atlas.ts +22 -3
  29. package/src/codegen.ts +24 -11
  30. package/src/plugins/types.ts +7 -0
  31. package/src/publish/contracts.ts +3 -0
  32. package/src/publish/external-resources.ts +12 -6
  33. package/src/publish/options.ts +21 -7
  34. package/src/publish/package-context.ts +35 -11
  35. package/src/publish/resource-references.ts +21 -6
  36. package/src/publish.ts +26 -4
  37. package/src/utils.ts +2 -2
package/dist/web.cjs CHANGED
@@ -1,32 +1,10 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_publish = require("./publish-C7qHwEiP.cjs");
2
+ const require_publish = require("./publish-DhMv_lEP.cjs");
3
3
  let _openfairygui_core = require("@openfairygui/core");
4
4
  let fast_xml_parser = require("fast-xml-parser");
5
5
  //#region src/adapters/web/raster.ts
6
- const MAX_SVG_SOURCE_BYTES = 8 * 1024 * 1024;
7
6
  const MAX_SVG_DIMENSION = 16384;
8
7
  const MAX_SVG_PIXELS = 64 * 1024 * 1024;
9
- const MAX_SVG_NODES = 5e4;
10
- const UNSAFE_SVG_ELEMENTS = new Set([
11
- "a",
12
- "animate",
13
- "animatecolor",
14
- "animatemotion",
15
- "animatetransform",
16
- "audio",
17
- "canvas",
18
- "discard",
19
- "embed",
20
- "feimage",
21
- "foreignobject",
22
- "iframe",
23
- "image",
24
- "object",
25
- "script",
26
- "set",
27
- "style",
28
- "video"
29
- ]);
30
8
  function unsafeSvg(message) {
31
9
  throw new Error(`publishBrowser: unsafe SVG input (${message}).`);
32
10
  }
@@ -41,47 +19,14 @@ function parseSvgLength(value, name) {
41
19
  if (!Number.isFinite(parsed) || parsed <= 0 || parsed > MAX_SVG_DIMENSION) unsafeSvg(`${name} exceeds the supported dimensions`);
42
20
  return parsed;
43
21
  }
44
- function validateSvgAttribute(name, value) {
45
- const normalizedName = name.toLowerCase();
46
- if (normalizedName === "xmlns" || normalizedName.startsWith("xmlns:")) return;
47
- const localName = svgLocalName(name);
48
- const text = String(value);
49
- if (localName.startsWith("on")) unsafeSvg(`event attribute "${name}" is not allowed`);
50
- if (localName === "style" || localName === "src") unsafeSvg(`attribute "${name}" is not allowed`);
51
- if (localName === "href" && !/^#[A-Za-z_][\w:.-]*$/u.test(text)) unsafeSvg(`external reference in "${name}" is not allowed`);
52
- if (/(?:^|[\s("'=])(?:https?:|file:|javascript:|data:|\/\/)/iu.test(text)) unsafeSvg(`external URL in "${name}" is not allowed`);
53
- for (const match of text.matchAll(/url\s*\(([^)]*)\)/giu)) {
54
- const reference = (match[1] ?? "").trim().replace(/^(['"])(.*)\1$/u, "$2");
55
- if (!/^#[A-Za-z_][\w:.-]*$/u.test(reference)) unsafeSvg(`external url() in "${name}" is not allowed`);
56
- }
57
- }
58
- function visitSvgEntry(entry) {
59
- const pending = [entry];
60
- let nodeCount = 0;
61
- while (pending.length > 0) {
62
- const current = pending.pop();
63
- for (const [name, value] of Object.entries(current)) {
64
- if (name === ":@" || name.startsWith("#") || name.startsWith("?")) continue;
65
- if (++nodeCount > MAX_SVG_NODES) unsafeSvg("node count exceeds the supported limit");
66
- const localName = svgLocalName(name);
67
- if (UNSAFE_SVG_ELEMENTS.has(localName)) unsafeSvg(`element <${name}> is not allowed`);
68
- for (const [attributeName, attributeValue] of Object.entries(current[":@"] ?? {})) validateSvgAttribute(attributeName, attributeValue);
69
- if (Array.isArray(value)) {
70
- for (const child of value) if (child && typeof child === "object" && !Array.isArray(child)) pending.push(child);
71
- }
72
- }
73
- }
74
- }
75
22
  function validateSvg(bytes) {
76
- if (bytes.byteLength === 0 || bytes.byteLength > MAX_SVG_SOURCE_BYTES) unsafeSvg("source size is unsupported");
77
- let source;
78
23
  try {
79
- source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
80
- } catch {
81
- unsafeSvg("source is not valid UTF-8");
24
+ validateSvgDimensions((0, _openfairygui_core.validateSafeSvgSource)(bytes));
25
+ } catch (error) {
26
+ unsafeSvg(error instanceof Error ? error.message : String(error));
82
27
  }
83
- if (/<!\s*(?:doctype|entity)\b|<\?xml-stylesheet\b/iu.test(source)) unsafeSvg("DTD, entities, and stylesheets are not allowed");
84
- if (fast_xml_parser.XMLValidator.validate(source, { allowBooleanAttributes: false }) !== true) unsafeSvg("source is not well-formed XML");
28
+ }
29
+ function validateSvgDimensions(source) {
85
30
  const roots = new fast_xml_parser.XMLParser({
86
31
  preserveOrder: true,
87
32
  ignoreAttributes: false,
@@ -95,9 +40,7 @@ function validateSvg(bytes) {
95
40
  name
96
41
  })));
97
42
  if (roots.length !== 1 || svgLocalName(roots[0].name) !== "svg") unsafeSvg("a single <svg> root is required");
98
- const root = roots[0].entry;
99
- visitSvgEntry(root);
100
- const attributes = root[":@"] ?? {};
43
+ const attributes = roots[0].entry[":@"] ?? {};
101
44
  const width = parseSvgLength(attributes.width, "width");
102
45
  const height = parseSvgLength(attributes.height, "height");
103
46
  let viewBoxWidth;
@@ -219,6 +162,46 @@ var BrowserImagePipeline = class {
219
162
  ensureAlpha() {
220
163
  return this;
221
164
  }
165
+ removeAlpha() {
166
+ this.raster = this.raster.then((source) => {
167
+ const context = getBrowserContext(source.canvas);
168
+ const image = context.getImageData(0, 0, source.width, source.height);
169
+ for (let index = 3; index < image.data.length; index += 4) image.data[index] = 255;
170
+ context.putImageData(image, 0, 0);
171
+ return source;
172
+ });
173
+ return this;
174
+ }
175
+ extractChannel(channel) {
176
+ if (channel !== "alpha") throw new Error(`publishBrowser: Unsupported channel "${channel}".`);
177
+ this.raster = this.raster.then((source) => {
178
+ const context = getBrowserContext(source.canvas);
179
+ const image = context.getImageData(0, 0, source.width, source.height);
180
+ for (let index = 0; index < image.data.length; index += 4) {
181
+ const alpha = image.data[index + 3] ?? 0;
182
+ image.data[index] = alpha;
183
+ image.data[index + 1] = alpha;
184
+ image.data[index + 2] = alpha;
185
+ image.data[index + 3] = 255;
186
+ }
187
+ context.putImageData(image, 0, 0);
188
+ return source;
189
+ });
190
+ return this;
191
+ }
192
+ joinChannel(images) {
193
+ this.raster = Promise.all([this.raster, ...images.map((image) => this.decode(image))]).then(([source, ...channels]) => {
194
+ const context = getBrowserContext(source.canvas);
195
+ const image = context.getImageData(0, 0, source.width, source.height);
196
+ for (const [channelIndex, channel] of channels.slice(0, 2).entries()) {
197
+ const channelData = getBrowserContext(channel.canvas).getImageData(0, 0, channel.width, channel.height).data;
198
+ for (let index = 0; index < image.data.length; index += 4) image.data[index + channelIndex + 1] = channelData[index] ?? 0;
199
+ }
200
+ context.putImageData(image, 0, 0);
201
+ return source;
202
+ });
203
+ return this;
204
+ }
222
205
  resize(options) {
223
206
  this.raster = this.raster.then((source) => {
224
207
  const target = createRaster(options.width, options.height);
package/dist/web.d.cts CHANGED
@@ -1,9 +1,9 @@
1
- import { d as PublishSourceFileSystem, t as AtlasOptions, u as PublishOutputFileSystem } from "./atlas-CHsu2Y8i.cjs";
1
+ import { d as PublishSourceFileSystem, t as AtlasOptions, u as PublishOutputFileSystem } from "./atlas-CSqHsG0X.cjs";
2
2
  import { Document, ProjectValidationReport, UamProject } from "@openfairygui/core";
3
3
 
4
4
  //#region src/adapters/web/publish.d.ts
5
5
  type BrowserPublishProjectType = 'layabox';
6
- type BrowserPublishAtlasOptions = Pick<AtlasOptions, 'maxSize' | 'fast' | 'allowRotation' | 'padding' | 'powerOfTwo' | 'square' | 'multiPage' | 'trimImage' | 'extractAlpha'>;
6
+ type BrowserPublishAtlasOptions = Pick<AtlasOptions, 'maxSize' | 'fast' | 'allowRotation' | 'padding' | 'powerOfTwo' | 'maxAtlasIndex' | 'multipleOfFour' | 'square' | 'multiPage' | 'trimImage' | 'extractAlpha'>;
7
7
  type BrowserPublishSourceFileSystem = PublishSourceFileSystem;
8
8
  type BrowserPublishOutputFileSystem = PublishOutputFileSystem;
9
9
  interface BrowserPublishOptions {
package/dist/web.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- import { d as PublishSourceFileSystem, t as AtlasOptions, u as PublishOutputFileSystem } from "./atlas-C6tbl7nn.js";
1
+ import { d as PublishSourceFileSystem, t as AtlasOptions, u as PublishOutputFileSystem } from "./atlas-BtWa1DwO.js";
2
2
  import { Document, ProjectValidationReport, UamProject } from "@openfairygui/core";
3
3
 
4
4
  //#region src/adapters/web/publish.d.ts
5
5
  type BrowserPublishProjectType = 'layabox';
6
- type BrowserPublishAtlasOptions = Pick<AtlasOptions, 'maxSize' | 'fast' | 'allowRotation' | 'padding' | 'powerOfTwo' | 'square' | 'multiPage' | 'trimImage' | 'extractAlpha'>;
6
+ type BrowserPublishAtlasOptions = Pick<AtlasOptions, 'maxSize' | 'fast' | 'allowRotation' | 'padding' | 'powerOfTwo' | 'maxAtlasIndex' | 'multipleOfFour' | 'square' | 'multiPage' | 'trimImage' | 'extractAlpha'>;
7
7
  type BrowserPublishSourceFileSystem = PublishSourceFileSystem;
8
8
  type BrowserPublishOutputFileSystem = PublishOutputFileSystem;
9
9
  interface BrowserPublishOptions {
package/dist/web.js CHANGED
@@ -1,31 +1,9 @@
1
- import { _ as validateProject, c as resolveCodeGenerationSettings, n as resolvePublishOptions, t as publish } from "./publish-CyBj2o7n.js";
2
- import { ProjectType, createProjectValidationReport } from "@openfairygui/core";
3
- import { XMLParser, XMLValidator } from "fast-xml-parser";
1
+ import { _ as validateProject, c as resolveCodeGenerationSettings, n as resolvePublishOptions, t as publish } from "./publish-Cy09yjfn.js";
2
+ import { ProjectType, createProjectValidationReport, validateSafeSvgSource } from "@openfairygui/core";
3
+ import { XMLParser } from "fast-xml-parser";
4
4
  //#region src/adapters/web/raster.ts
5
- const MAX_SVG_SOURCE_BYTES = 8 * 1024 * 1024;
6
5
  const MAX_SVG_DIMENSION = 16384;
7
6
  const MAX_SVG_PIXELS = 64 * 1024 * 1024;
8
- const MAX_SVG_NODES = 5e4;
9
- const UNSAFE_SVG_ELEMENTS = new Set([
10
- "a",
11
- "animate",
12
- "animatecolor",
13
- "animatemotion",
14
- "animatetransform",
15
- "audio",
16
- "canvas",
17
- "discard",
18
- "embed",
19
- "feimage",
20
- "foreignobject",
21
- "iframe",
22
- "image",
23
- "object",
24
- "script",
25
- "set",
26
- "style",
27
- "video"
28
- ]);
29
7
  function unsafeSvg(message) {
30
8
  throw new Error(`publishBrowser: unsafe SVG input (${message}).`);
31
9
  }
@@ -40,47 +18,14 @@ function parseSvgLength(value, name) {
40
18
  if (!Number.isFinite(parsed) || parsed <= 0 || parsed > MAX_SVG_DIMENSION) unsafeSvg(`${name} exceeds the supported dimensions`);
41
19
  return parsed;
42
20
  }
43
- function validateSvgAttribute(name, value) {
44
- const normalizedName = name.toLowerCase();
45
- if (normalizedName === "xmlns" || normalizedName.startsWith("xmlns:")) return;
46
- const localName = svgLocalName(name);
47
- const text = String(value);
48
- if (localName.startsWith("on")) unsafeSvg(`event attribute "${name}" is not allowed`);
49
- if (localName === "style" || localName === "src") unsafeSvg(`attribute "${name}" is not allowed`);
50
- if (localName === "href" && !/^#[A-Za-z_][\w:.-]*$/u.test(text)) unsafeSvg(`external reference in "${name}" is not allowed`);
51
- if (/(?:^|[\s("'=])(?:https?:|file:|javascript:|data:|\/\/)/iu.test(text)) unsafeSvg(`external URL in "${name}" is not allowed`);
52
- for (const match of text.matchAll(/url\s*\(([^)]*)\)/giu)) {
53
- const reference = (match[1] ?? "").trim().replace(/^(['"])(.*)\1$/u, "$2");
54
- if (!/^#[A-Za-z_][\w:.-]*$/u.test(reference)) unsafeSvg(`external url() in "${name}" is not allowed`);
55
- }
56
- }
57
- function visitSvgEntry(entry) {
58
- const pending = [entry];
59
- let nodeCount = 0;
60
- while (pending.length > 0) {
61
- const current = pending.pop();
62
- for (const [name, value] of Object.entries(current)) {
63
- if (name === ":@" || name.startsWith("#") || name.startsWith("?")) continue;
64
- if (++nodeCount > MAX_SVG_NODES) unsafeSvg("node count exceeds the supported limit");
65
- const localName = svgLocalName(name);
66
- if (UNSAFE_SVG_ELEMENTS.has(localName)) unsafeSvg(`element <${name}> is not allowed`);
67
- for (const [attributeName, attributeValue] of Object.entries(current[":@"] ?? {})) validateSvgAttribute(attributeName, attributeValue);
68
- if (Array.isArray(value)) {
69
- for (const child of value) if (child && typeof child === "object" && !Array.isArray(child)) pending.push(child);
70
- }
71
- }
72
- }
73
- }
74
21
  function validateSvg(bytes) {
75
- if (bytes.byteLength === 0 || bytes.byteLength > MAX_SVG_SOURCE_BYTES) unsafeSvg("source size is unsupported");
76
- let source;
77
22
  try {
78
- source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
79
- } catch {
80
- unsafeSvg("source is not valid UTF-8");
23
+ validateSvgDimensions(validateSafeSvgSource(bytes));
24
+ } catch (error) {
25
+ unsafeSvg(error instanceof Error ? error.message : String(error));
81
26
  }
82
- if (/<!\s*(?:doctype|entity)\b|<\?xml-stylesheet\b/iu.test(source)) unsafeSvg("DTD, entities, and stylesheets are not allowed");
83
- if (XMLValidator.validate(source, { allowBooleanAttributes: false }) !== true) unsafeSvg("source is not well-formed XML");
27
+ }
28
+ function validateSvgDimensions(source) {
84
29
  const roots = new XMLParser({
85
30
  preserveOrder: true,
86
31
  ignoreAttributes: false,
@@ -94,9 +39,7 @@ function validateSvg(bytes) {
94
39
  name
95
40
  })));
96
41
  if (roots.length !== 1 || svgLocalName(roots[0].name) !== "svg") unsafeSvg("a single <svg> root is required");
97
- const root = roots[0].entry;
98
- visitSvgEntry(root);
99
- const attributes = root[":@"] ?? {};
42
+ const attributes = roots[0].entry[":@"] ?? {};
100
43
  const width = parseSvgLength(attributes.width, "width");
101
44
  const height = parseSvgLength(attributes.height, "height");
102
45
  let viewBoxWidth;
@@ -218,6 +161,46 @@ var BrowserImagePipeline = class {
218
161
  ensureAlpha() {
219
162
  return this;
220
163
  }
164
+ removeAlpha() {
165
+ this.raster = this.raster.then((source) => {
166
+ const context = getBrowserContext(source.canvas);
167
+ const image = context.getImageData(0, 0, source.width, source.height);
168
+ for (let index = 3; index < image.data.length; index += 4) image.data[index] = 255;
169
+ context.putImageData(image, 0, 0);
170
+ return source;
171
+ });
172
+ return this;
173
+ }
174
+ extractChannel(channel) {
175
+ if (channel !== "alpha") throw new Error(`publishBrowser: Unsupported channel "${channel}".`);
176
+ this.raster = this.raster.then((source) => {
177
+ const context = getBrowserContext(source.canvas);
178
+ const image = context.getImageData(0, 0, source.width, source.height);
179
+ for (let index = 0; index < image.data.length; index += 4) {
180
+ const alpha = image.data[index + 3] ?? 0;
181
+ image.data[index] = alpha;
182
+ image.data[index + 1] = alpha;
183
+ image.data[index + 2] = alpha;
184
+ image.data[index + 3] = 255;
185
+ }
186
+ context.putImageData(image, 0, 0);
187
+ return source;
188
+ });
189
+ return this;
190
+ }
191
+ joinChannel(images) {
192
+ this.raster = Promise.all([this.raster, ...images.map((image) => this.decode(image))]).then(([source, ...channels]) => {
193
+ const context = getBrowserContext(source.canvas);
194
+ const image = context.getImageData(0, 0, source.width, source.height);
195
+ for (const [channelIndex, channel] of channels.slice(0, 2).entries()) {
196
+ const channelData = getBrowserContext(channel.canvas).getImageData(0, 0, channel.width, channel.height).data;
197
+ for (let index = 0; index < image.data.length; index += 4) image.data[index + channelIndex + 1] = channelData[index] ?? 0;
198
+ }
199
+ context.putImageData(image, 0, 0);
200
+ return source;
201
+ });
202
+ return this;
203
+ }
221
204
  resize(options) {
222
205
  this.raster = this.raster.then((source) => {
223
206
  const target = createRaster(options.width, options.height);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfairygui/functions",
3
- "version": "0.3.0-alpha.4",
3
+ "version": "0.3.1",
4
4
  "description": "FairyGUI Headless Authoring SDK — composable transform functions.",
5
5
  "author": "OpenFairyGUI Contributors",
6
6
  "license": "MIT",
@@ -13,6 +13,9 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/OpenFairyGUI/OpenFairyGUI/issues"
15
15
  },
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
16
19
  "type": "module",
17
20
  "sideEffects": false,
18
21
  "main": "./dist/index.cjs",
@@ -75,7 +78,7 @@
75
78
  "dependencies": {
76
79
  "fast-xml-parser": "^5.0.0",
77
80
  "jiti": "^2.6.1",
78
- "@openfairygui/core": "0.3.0-alpha.4"
81
+ "@openfairygui/core": "0.3.1"
79
82
  },
80
83
  "optionalDependencies": {
81
84
  "sharp": ">=0.33.0"
@@ -7,10 +7,7 @@ import {
7
7
  type PluginModule,
8
8
  } from '../../plugins/types.js';
9
9
 
10
- interface PluginPackageJson extends Partial<PluginManifest> {
11
- name?: string;
12
- main?: string;
13
- }
10
+ interface PluginPackageJson extends Partial<PluginManifest> {}
14
11
 
15
12
  // Keep Node builtins out of the neutral bundle resolver while still loading plugins in Node.
16
13
  const importNative = new Function('id', 'return import(id)') as <T>(id: string) => Promise<T>;
@@ -31,15 +28,35 @@ export async function loadPlugins(doc: Document, pluginsDir: string): Promise<Lo
31
28
  for (const entry of entries) {
32
29
  if (!entry.isDirectory()) continue;
33
30
  const pluginDir = path.join(pluginsDir, entry.name);
31
+ let manifest: PluginManifest | null;
34
32
  try {
35
- const manifest = await readPluginManifest(fs, path, pluginDir);
36
- if (!manifest) continue;
33
+ manifest = await readPluginManifest(fs, path, pluginDir);
34
+ } catch (error) {
35
+ doc.getLogger().warn(`publish: Plugin "${entry.name}" was skipped: ${formatPluginError(error)}`);
36
+ continue;
37
+ }
38
+ if (!manifest) continue;
39
+ if (!manifest.main) {
40
+ const error = new Error(`Codegen plugin "${manifest.name}" is missing package.json main.`);
41
+ if (manifest.required) throw error;
42
+ doc.getLogger().warn(`publish: Plugin "${manifest.name}" was skipped: ${error.message}`);
43
+ continue;
44
+ }
37
45
 
46
+ try {
38
47
  const mainPath = resolvePluginMain(path, pluginDir, manifest);
39
48
  const plugin = await loadPlugin(mainPath);
40
- plugins.push({ name: manifest.name, plugin });
49
+ plugins.push({
50
+ name: manifest.name,
51
+ plugin,
52
+ failureMode: manifest.required ? 'abort' : manifest.failureMode,
53
+ });
41
54
  } catch (error) {
42
- doc.getLogger().warn(`publish: Plugin "${entry.name}" was skipped: ${formatPluginError(error)}`);
55
+ if (!manifest.required && manifest.failureMode === 'warn') {
56
+ doc.getLogger().warn(`publish: Plugin "${manifest.name}" was skipped: ${formatPluginError(error)}`);
57
+ continue;
58
+ }
59
+ throw new Error(`publish: Failed to load plugin "${manifest.name}": ${formatPluginError(error)}`);
43
60
  }
44
61
  }
45
62
 
@@ -50,17 +67,16 @@ async function readPluginManifest(
50
67
  fs: typeof import('node:fs/promises'),
51
68
  path: typeof import('node:path'),
52
69
  pluginDir: string,
53
- ): Promise<PluginPackageJson | null> {
70
+ ): Promise<PluginManifest | null> {
54
71
  const manifestPath = path.join(pluginDir, 'package.json');
55
72
  const content = await fs.readFile(manifestPath, 'utf-8');
56
73
  const manifest = JSON.parse(content) as PluginPackageJson;
57
74
  if (!manifest.name) throw new Error(`Codegen plugin at ${pluginDir} is missing package.json name.`);
58
- if (!manifest.main) throw new Error(`Codegen plugin "${manifest.name}" is missing package.json main.`);
59
- return manifest;
75
+ return manifest as PluginManifest;
60
76
  }
61
77
 
62
- function resolvePluginMain(path: typeof import('node:path'), pluginDir: string, manifest: PluginPackageJson): string {
63
- const mainPath = path.resolve(pluginDir, manifest.main!);
78
+ function resolvePluginMain(path: typeof import('node:path'), pluginDir: string, manifest: PluginManifest): string {
79
+ const mainPath = path.resolve(pluginDir, manifest.main);
64
80
  const relative = path.relative(pluginDir, mainPath);
65
81
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
66
82
  throw new Error(`Codegen plugin "${manifest.name}" main must resolve inside its plugin directory.`);
@@ -83,6 +83,70 @@ async function loadNodePublishPlugins(document: Document, assetsPath: string | u
83
83
  return loadPlugins(document, path.join(projectDir, 'plugins'));
84
84
  }
85
85
 
86
+ async function publishToStagedOutput(output: string, run: (staging: string) => Promise<void>): Promise<void> {
87
+ const [fs, path, { randomUUID }] = await Promise.all([
88
+ importNative<typeof import('node:fs/promises')>('node:fs/promises'),
89
+ importNative<typeof import('node:path')>('node:path'),
90
+ importNative<typeof import('node:crypto')>('node:crypto'),
91
+ ]);
92
+ const target = path.resolve(output);
93
+ const parent = path.dirname(target);
94
+ const name = path.basename(target);
95
+ const staging = path.join(parent, `.${name}.publish-${randomUUID()}`);
96
+ const backup = path.join(parent, `.${name}.publish-backup-${randomUUID()}`);
97
+ await fs.mkdir(parent, { recursive: true });
98
+ let existed = false;
99
+ try {
100
+ const stat = await fs.lstat(target);
101
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`publishNode: output must be a regular directory: ${target}`);
102
+ await assertNoSymlinks(fs, path, target);
103
+ existed = true;
104
+ } catch (error) {
105
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
106
+ }
107
+ try {
108
+ if (existed) await fs.cp(target, staging, { recursive: true, errorOnExist: true, force: false });
109
+ else await fs.mkdir(staging);
110
+ } catch (error) {
111
+ await fs.rm(staging, { recursive: true, force: true });
112
+ throw error;
113
+ }
114
+
115
+ try {
116
+ await run(staging);
117
+ } catch (error) {
118
+ await fs.rm(staging, { recursive: true, force: true });
119
+ throw error;
120
+ }
121
+ if (!existed) {
122
+ await fs.rename(staging, target);
123
+ return;
124
+ }
125
+
126
+ // ponytail: two-step rename preserves rollback; use directory exchange if zero reader gap becomes required.
127
+ await fs.rename(target, backup);
128
+ try {
129
+ await fs.rename(staging, target);
130
+ } catch (error) {
131
+ await fs.rename(backup, target);
132
+ await fs.rm(staging, { recursive: true, force: true });
133
+ throw error;
134
+ }
135
+ await fs.rm(backup, { recursive: true, force: true }).catch(() => undefined);
136
+ }
137
+
138
+ async function assertNoSymlinks(
139
+ fs: typeof import('node:fs/promises'),
140
+ path: typeof import('node:path'),
141
+ directory: string,
142
+ ): Promise<void> {
143
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
144
+ const entryPath = path.join(directory, entry.name);
145
+ if (entry.isSymbolicLink()) throw new Error(`publishNode: symbolic links are not supported in output directories: ${entryPath}`);
146
+ if (entry.isDirectory()) await assertNoSymlinks(fs, path, entryPath);
147
+ }
148
+ }
149
+
86
150
  /**
87
151
  * Publish a FairyGUI project through the standard Node host adapter.
88
152
  *
@@ -114,9 +178,10 @@ export async function publishNode(options: PublishNodeOptions): Promise<void> {
114
178
  throw new Error('publishNode: Sharp is required for a complete publish. Install sharp or provide an encoder.');
115
179
  }
116
180
 
117
- await document.transform(
118
- publish({
181
+ const run = async (output: string | undefined): Promise<void> => {
182
+ await document.transform(publish({
119
183
  ...publishOptions,
184
+ output,
120
185
  basePath: assetsPath,
121
186
  encoder,
122
187
  atlas: {
@@ -125,6 +190,11 @@ export async function publishNode(options: PublishNodeOptions): Promise<void> {
125
190
  },
126
191
  fs: fileSystem,
127
192
  plugins,
128
- }),
129
- );
193
+ }));
194
+ };
195
+ if (publishOptions.output) {
196
+ await publishToStagedOutput(publishOptions.output, run);
197
+ return;
198
+ }
199
+ await run(undefined);
130
200
  }
@@ -72,6 +72,7 @@ export async function validateProjectNode(projectPath: string): Promise<ProjectV
72
72
  ));
73
73
 
74
74
  for (const { pkg, packageIndex, resource, resourceIndex } of images) {
75
+ if (resource.kind !== 'image') continue;
75
76
  try {
76
77
  await sharp(resource.sourceBytes!).raw().toBuffer();
77
78
  } catch (error) {
@@ -19,6 +19,8 @@ export type BrowserPublishAtlasOptions = Pick<
19
19
  | 'allowRotation'
20
20
  | 'padding'
21
21
  | 'powerOfTwo'
22
+ | 'maxAtlasIndex'
23
+ | 'multipleOfFour'
22
24
  | 'square'
23
25
  | 'multiPage'
24
26
  | 'trimImage'