@openfairygui/functions 0.3.0 → 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.
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_publish = require("./publish-CxKatE3e.cjs");
3
- const require_restore = require("./restore-QScbHJ7Y.cjs");
2
+ const require_publish = require("./publish-DhMv_lEP.cjs");
3
+ const require_restore = require("./restore-n61oZ4nw.cjs");
4
4
  const require_uam_transaction = require("./uam-transaction.cjs");
5
5
  //#region src/inspect.ts
6
6
  function mapResource(resource) {
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as AtlasRasterInput, c as AtlasRasterResolvedBuffer, d as PublishSourceFileSystem, i as AtlasRasterCompositeInput, l as PublishFileSystem, n as atlas, o as AtlasRasterMetadata, r as AtlasRasterBackend, s as AtlasRasterPipeline, t as AtlasOptions, u as PublishOutputFileSystem } from "./atlas-CSqHsG0X.cjs";
2
- import { A as ExtrasMap, B as ResolvedPublishOptions, C as MaybePromise, D as CliAtlasSettings, E as PluginModule, F as RootProjectSettings, I as publish, L as PublishOptions, M as HasOptionalSrc, N as HasOptionalUrl, O as CliCodeGenerationSettings, P as PublishDependency, R as ResolvePublishOptionsOverrides, S as LoadedPlugin, T as PluginManifest, V as resolvePublishOptions, _ as encodeText, a as RestoreImageExtractor, b as CodeWriter, c as restore, d as CodegenMember, f as CodegenReferencedComponent, g as decodeText, h as buildCodegenClasses, i as RestoreImageExtractInput, j as HasOptionalFont, k as CliPublishSettings, l as AUTO_GENERATED_CODE_MARK, m as ResolvedPackageCodegenPlan, n as RestoreImageCropInput, o as RestoreOptions, p as PublishCodeGenerationOptions, r as RestoreImageCropper, s as RestoreResult, t as RestoreFileSystem, u as CodegenClass, v as publishCodeGeneration, w as Plugin, x as ICodeWriterConfig, y as resolvePackageCodegenPlan, z as ResolvedPublishAtlasOptions } from "./restore-D6F1ELdM.cjs";
2
+ import { A as ExtrasMap, B as ResolvedPublishOptions, C as MaybePromise, D as CliAtlasSettings, E as PluginModule, F as RootProjectSettings, I as publish, L as PublishOptions, M as HasOptionalSrc, N as HasOptionalUrl, O as CliCodeGenerationSettings, P as PublishDependency, R as ResolvePublishOptionsOverrides, S as LoadedPlugin, T as PluginManifest, V as resolvePublishOptions, _ as encodeText, a as RestoreImageExtractor, b as CodeWriter, c as restore, d as CodegenMember, f as CodegenReferencedComponent, g as decodeText, h as buildCodegenClasses, i as RestoreImageExtractInput, j as HasOptionalFont, k as CliPublishSettings, l as AUTO_GENERATED_CODE_MARK, m as ResolvedPackageCodegenPlan, n as RestoreImageCropInput, o as RestoreOptions, p as PublishCodeGenerationOptions, r as RestoreImageCropper, s as RestoreResult, t as RestoreFileSystem, u as CodegenClass, v as publishCodeGeneration, w as Plugin, x as ICodeWriterConfig, y as resolvePackageCodegenPlan, z as ResolvedPublishAtlasOptions } from "./restore-k3oX26Fs.cjs";
3
3
  import { ApplyUamTransactionAppDiagnostic, ApplyUamTransactionAppError, ApplyUamTransactionAppInput, ApplyUamTransactionAppResult, applyUamTransactionApp, applyUamTransactionAppAsync } from "./uam-transaction.cjs";
4
4
  import { Document, ProjectDiagnostic, ProjectValidationReport, Transform, UamProject } from "@openfairygui/core";
5
5
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as AtlasRasterInput, c as AtlasRasterResolvedBuffer, d as PublishSourceFileSystem, i as AtlasRasterCompositeInput, l as PublishFileSystem, n as atlas, o as AtlasRasterMetadata, r as AtlasRasterBackend, s as AtlasRasterPipeline, t as AtlasOptions, u as PublishOutputFileSystem } from "./atlas-BtWa1DwO.js";
2
- import { A as ExtrasMap, B as ResolvedPublishOptions, C as MaybePromise, D as CliAtlasSettings, E as PluginModule, F as RootProjectSettings, I as publish, L as PublishOptions, M as HasOptionalSrc, N as HasOptionalUrl, O as CliCodeGenerationSettings, P as PublishDependency, R as ResolvePublishOptionsOverrides, S as LoadedPlugin, T as PluginManifest, V as resolvePublishOptions, _ as encodeText, a as RestoreImageExtractor, b as CodeWriter, c as restore, d as CodegenMember, f as CodegenReferencedComponent, g as decodeText, h as buildCodegenClasses, i as RestoreImageExtractInput, j as HasOptionalFont, k as CliPublishSettings, l as AUTO_GENERATED_CODE_MARK, m as ResolvedPackageCodegenPlan, n as RestoreImageCropInput, o as RestoreOptions, p as PublishCodeGenerationOptions, r as RestoreImageCropper, s as RestoreResult, t as RestoreFileSystem, u as CodegenClass, v as publishCodeGeneration, w as Plugin, x as ICodeWriterConfig, y as resolvePackageCodegenPlan, z as ResolvedPublishAtlasOptions } from "./restore-BOfsCM47.js";
2
+ import { A as ExtrasMap, B as ResolvedPublishOptions, C as MaybePromise, D as CliAtlasSettings, E as PluginModule, F as RootProjectSettings, I as publish, L as PublishOptions, M as HasOptionalSrc, N as HasOptionalUrl, O as CliCodeGenerationSettings, P as PublishDependency, R as ResolvePublishOptionsOverrides, S as LoadedPlugin, T as PluginManifest, V as resolvePublishOptions, _ as encodeText, a as RestoreImageExtractor, b as CodeWriter, c as restore, d as CodegenMember, f as CodegenReferencedComponent, g as decodeText, h as buildCodegenClasses, i as RestoreImageExtractInput, j as HasOptionalFont, k as CliPublishSettings, l as AUTO_GENERATED_CODE_MARK, m as ResolvedPackageCodegenPlan, n as RestoreImageCropInput, o as RestoreOptions, p as PublishCodeGenerationOptions, r as RestoreImageCropper, s as RestoreResult, t as RestoreFileSystem, u as CodegenClass, v as publishCodeGeneration, w as Plugin, x as ICodeWriterConfig, y as resolvePackageCodegenPlan, z as ResolvedPublishAtlasOptions } from "./restore-CbscKkNw.js";
3
3
  import { ApplyUamTransactionAppDiagnostic, ApplyUamTransactionAppError, ApplyUamTransactionAppInput, ApplyUamTransactionAppResult, applyUamTransactionApp, applyUamTransactionAppAsync } from "./uam-transaction.js";
4
4
  import { Document, ProjectDiagnostic, ProjectValidationReport, Transform, UamProject } from "@openfairygui/core";
5
5
 
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { _ as validateProject, a as decodeText, g as createTransform, h as atlas, i as buildCodegenClasses, l as resolvePackageCodegenPlan, n as resolvePublishOptions, o as encodeText, r as AUTO_GENERATED_CODE_MARK, s as publishCodeGeneration, t as publish } from "./publish-BBoK-yeR.js";
2
- import { t as restore } from "./restore-DOcyfc0b.js";
1
+ import { _ as validateProject, a as decodeText, g as createTransform, h as atlas, i as buildCodegenClasses, l as resolvePackageCodegenPlan, n as resolvePublishOptions, o as encodeText, r as AUTO_GENERATED_CODE_MARK, s as publishCodeGeneration, t as publish } from "./publish-Cy09yjfn.js";
2
+ import { t as restore } from "./restore-DB_L_xq4.js";
3
3
  import { applyUamTransactionApp, applyUamTransactionAppAsync } from "./uam-transaction.js";
4
4
  //#region src/inspect.ts
5
5
  function mapResource(resource) {
package/dist/node.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_publish = require("./publish-CxKatE3e.cjs");
3
- const require_restore = require("./restore-QScbHJ7Y.cjs");
2
+ const require_publish = require("./publish-DhMv_lEP.cjs");
3
+ const require_restore = require("./restore-n61oZ4nw.cjs");
4
4
  let _openfairygui_core = require("@openfairygui/core");
5
5
  let _openfairygui_core_node = require("@openfairygui/core/node");
6
6
  //#region src/adapters/node/plugins.ts
@@ -19,16 +19,33 @@ async function loadPlugins(doc, pluginsDir) {
19
19
  for (const entry of entries) {
20
20
  if (!entry.isDirectory()) continue;
21
21
  const pluginDir = path.join(pluginsDir, entry.name);
22
+ let manifest;
23
+ try {
24
+ manifest = await readPluginManifest(fs, path, pluginDir);
25
+ } catch (error) {
26
+ doc.getLogger().warn(`publish: Plugin "${entry.name}" was skipped: ${require_publish.formatPluginError(error)}`);
27
+ continue;
28
+ }
29
+ if (!manifest) continue;
30
+ if (!manifest.main) {
31
+ const error = /* @__PURE__ */ new Error(`Codegen plugin "${manifest.name}" is missing package.json main.`);
32
+ if (manifest.required) throw error;
33
+ doc.getLogger().warn(`publish: Plugin "${manifest.name}" was skipped: ${error.message}`);
34
+ continue;
35
+ }
22
36
  try {
23
- const manifest = await readPluginManifest(fs, path, pluginDir);
24
- if (!manifest) continue;
25
37
  const plugin = await loadPlugin(resolvePluginMain(path, pluginDir, manifest));
26
38
  plugins.push({
27
39
  name: manifest.name,
28
- plugin
40
+ plugin,
41
+ failureMode: manifest.required ? "abort" : manifest.failureMode
29
42
  });
30
43
  } catch (error) {
31
- doc.getLogger().warn(`publish: Plugin "${entry.name}" was skipped: ${require_publish.formatPluginError(error)}`);
44
+ if (!manifest.required && manifest.failureMode === "warn") {
45
+ doc.getLogger().warn(`publish: Plugin "${manifest.name}" was skipped: ${require_publish.formatPluginError(error)}`);
46
+ continue;
47
+ }
48
+ throw new Error(`publish: Failed to load plugin "${manifest.name}": ${require_publish.formatPluginError(error)}`);
32
49
  }
33
50
  }
34
51
  return plugins;
@@ -38,7 +55,6 @@ async function readPluginManifest(fs, path, pluginDir) {
38
55
  const content = await fs.readFile(manifestPath, "utf-8");
39
56
  const manifest = JSON.parse(content);
40
57
  if (!manifest.name) throw new Error(`Codegen plugin at ${pluginDir} is missing package.json name.`);
41
- if (!manifest.main) throw new Error(`Codegen plugin "${manifest.name}" is missing package.json main.`);
42
58
  return manifest;
43
59
  }
44
60
  function resolvePluginMain(path, pluginDir, manifest) {
@@ -103,6 +119,77 @@ async function loadNodePublishPlugins(document, assetsPath) {
103
119
  if (!projectDir) return [];
104
120
  return loadPlugins(document, (await importNative$2("node:path")).join(projectDir, "plugins"));
105
121
  }
122
+ async function publishToStagedOutput(output, run) {
123
+ const [fs, path, { randomUUID }] = await Promise.all([
124
+ importNative$2("node:fs/promises"),
125
+ importNative$2("node:path"),
126
+ importNative$2("node:crypto")
127
+ ]);
128
+ const target = path.resolve(output);
129
+ const parent = path.dirname(target);
130
+ const name = path.basename(target);
131
+ const staging = path.join(parent, `.${name}.publish-${randomUUID()}`);
132
+ const backup = path.join(parent, `.${name}.publish-backup-${randomUUID()}`);
133
+ await fs.mkdir(parent, { recursive: true });
134
+ let existed = false;
135
+ try {
136
+ const stat = await fs.lstat(target);
137
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`publishNode: output must be a regular directory: ${target}`);
138
+ await assertNoSymlinks(fs, path, target);
139
+ existed = true;
140
+ } catch (error) {
141
+ if (error.code !== "ENOENT") throw error;
142
+ }
143
+ try {
144
+ if (existed) await fs.cp(target, staging, {
145
+ recursive: true,
146
+ errorOnExist: true,
147
+ force: false
148
+ });
149
+ else await fs.mkdir(staging);
150
+ } catch (error) {
151
+ await fs.rm(staging, {
152
+ recursive: true,
153
+ force: true
154
+ });
155
+ throw error;
156
+ }
157
+ try {
158
+ await run(staging);
159
+ } catch (error) {
160
+ await fs.rm(staging, {
161
+ recursive: true,
162
+ force: true
163
+ });
164
+ throw error;
165
+ }
166
+ if (!existed) {
167
+ await fs.rename(staging, target);
168
+ return;
169
+ }
170
+ await fs.rename(target, backup);
171
+ try {
172
+ await fs.rename(staging, target);
173
+ } catch (error) {
174
+ await fs.rename(backup, target);
175
+ await fs.rm(staging, {
176
+ recursive: true,
177
+ force: true
178
+ });
179
+ throw error;
180
+ }
181
+ await fs.rm(backup, {
182
+ recursive: true,
183
+ force: true
184
+ }).catch(() => void 0);
185
+ }
186
+ async function assertNoSymlinks(fs, path, directory) {
187
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
188
+ const entryPath = path.join(directory, entry.name);
189
+ if (entry.isSymbolicLink()) throw new Error(`publishNode: symbolic links are not supported in output directories: ${entryPath}`);
190
+ if (entry.isDirectory()) await assertNoSymlinks(fs, path, entryPath);
191
+ }
192
+ }
106
193
  /**
107
194
  * Publish a FairyGUI project through the standard Node host adapter.
108
195
  *
@@ -115,17 +202,25 @@ async function publishNode(options) {
115
202
  const [fileSystem, assetsPath] = await Promise.all([createNodePublishFileSystem(), resolveNodeAssetsPath(document, configuredAssetsPath)]);
116
203
  const [encoder, plugins] = await Promise.all([configuredEncoder === void 0 ? loadSharpBackend() : Promise.resolve(configuredEncoder), configuredPlugins === void 0 ? loadNodePublishPlugins(document, assetsPath) : Promise.resolve(configuredPlugins)]);
117
204
  if (!encoder) throw new Error("publishNode: Sharp is required for a complete publish. Install sharp or provide an encoder.");
118
- await document.transform(require_publish.publish({
119
- ...publishOptions,
120
- basePath: assetsPath,
121
- encoder,
122
- atlas: {
123
- ...atlas,
124
- readFileRaw: fileSystem.readFileRaw
125
- },
126
- fs: fileSystem,
127
- plugins
128
- }));
205
+ const run = async (output) => {
206
+ await document.transform(require_publish.publish({
207
+ ...publishOptions,
208
+ output,
209
+ basePath: assetsPath,
210
+ encoder,
211
+ atlas: {
212
+ ...atlas,
213
+ readFileRaw: fileSystem.readFileRaw
214
+ },
215
+ fs: fileSystem,
216
+ plugins
217
+ }));
218
+ };
219
+ if (publishOptions.output) {
220
+ await publishToStagedOutput(publishOptions.output, run);
221
+ return;
222
+ }
223
+ await run(void 0);
129
224
  }
130
225
  //#endregion
131
226
  //#region src/adapters/node/restore.ts
package/dist/node.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { r as AtlasRasterBackend } from "./atlas-CSqHsG0X.cjs";
2
- import { L as PublishOptions, S as LoadedPlugin, o as RestoreOptions, s as RestoreResult } from "./restore-D6F1ELdM.cjs";
2
+ import { L as PublishOptions, S as LoadedPlugin, o as RestoreOptions, s as RestoreResult } from "./restore-k3oX26Fs.cjs";
3
3
  import { Document, ProjectValidationReport } from "@openfairygui/core";
4
4
 
5
5
  //#region src/adapters/node/publish.d.ts
package/dist/node.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { r as AtlasRasterBackend } from "./atlas-BtWa1DwO.js";
2
- import { L as PublishOptions, S as LoadedPlugin, o as RestoreOptions, s as RestoreResult } from "./restore-BOfsCM47.js";
2
+ import { L as PublishOptions, S as LoadedPlugin, o as RestoreOptions, s as RestoreResult } from "./restore-CbscKkNw.js";
3
3
  import { Document, ProjectValidationReport } from "@openfairygui/core";
4
4
 
5
5
  //#region src/adapters/node/publish.d.ts
package/dist/node.js CHANGED
@@ -1,5 +1,5 @@
1
- import { _ as validateProject, m as formatPluginError, t as publish, u as resolveProjectBasePath } from "./publish-BBoK-yeR.js";
2
- import { t as restore } from "./restore-DOcyfc0b.js";
1
+ import { _ as validateProject, m as formatPluginError, t as publish, u as resolveProjectBasePath } from "./publish-Cy09yjfn.js";
2
+ import { t as restore } from "./restore-DB_L_xq4.js";
3
3
  import { createProjectValidationReport, liftDocumentToUamProject } from "@openfairygui/core";
4
4
  import { NodeIO } from "@openfairygui/core/node";
5
5
  //#region src/adapters/node/plugins.ts
@@ -18,16 +18,33 @@ async function loadPlugins(doc, pluginsDir) {
18
18
  for (const entry of entries) {
19
19
  if (!entry.isDirectory()) continue;
20
20
  const pluginDir = path.join(pluginsDir, entry.name);
21
+ let manifest;
22
+ try {
23
+ manifest = await readPluginManifest(fs, path, pluginDir);
24
+ } catch (error) {
25
+ doc.getLogger().warn(`publish: Plugin "${entry.name}" was skipped: ${formatPluginError(error)}`);
26
+ continue;
27
+ }
28
+ if (!manifest) continue;
29
+ if (!manifest.main) {
30
+ const error = /* @__PURE__ */ new Error(`Codegen plugin "${manifest.name}" is missing package.json main.`);
31
+ if (manifest.required) throw error;
32
+ doc.getLogger().warn(`publish: Plugin "${manifest.name}" was skipped: ${error.message}`);
33
+ continue;
34
+ }
21
35
  try {
22
- const manifest = await readPluginManifest(fs, path, pluginDir);
23
- if (!manifest) continue;
24
36
  const plugin = await loadPlugin(resolvePluginMain(path, pluginDir, manifest));
25
37
  plugins.push({
26
38
  name: manifest.name,
27
- plugin
39
+ plugin,
40
+ failureMode: manifest.required ? "abort" : manifest.failureMode
28
41
  });
29
42
  } catch (error) {
30
- doc.getLogger().warn(`publish: Plugin "${entry.name}" was skipped: ${formatPluginError(error)}`);
43
+ if (!manifest.required && manifest.failureMode === "warn") {
44
+ doc.getLogger().warn(`publish: Plugin "${manifest.name}" was skipped: ${formatPluginError(error)}`);
45
+ continue;
46
+ }
47
+ throw new Error(`publish: Failed to load plugin "${manifest.name}": ${formatPluginError(error)}`);
31
48
  }
32
49
  }
33
50
  return plugins;
@@ -37,7 +54,6 @@ async function readPluginManifest(fs, path, pluginDir) {
37
54
  const content = await fs.readFile(manifestPath, "utf-8");
38
55
  const manifest = JSON.parse(content);
39
56
  if (!manifest.name) throw new Error(`Codegen plugin at ${pluginDir} is missing package.json name.`);
40
- if (!manifest.main) throw new Error(`Codegen plugin "${manifest.name}" is missing package.json main.`);
41
57
  return manifest;
42
58
  }
43
59
  function resolvePluginMain(path, pluginDir, manifest) {
@@ -102,6 +118,77 @@ async function loadNodePublishPlugins(document, assetsPath) {
102
118
  if (!projectDir) return [];
103
119
  return loadPlugins(document, (await importNative$2("node:path")).join(projectDir, "plugins"));
104
120
  }
121
+ async function publishToStagedOutput(output, run) {
122
+ const [fs, path, { randomUUID }] = await Promise.all([
123
+ importNative$2("node:fs/promises"),
124
+ importNative$2("node:path"),
125
+ importNative$2("node:crypto")
126
+ ]);
127
+ const target = path.resolve(output);
128
+ const parent = path.dirname(target);
129
+ const name = path.basename(target);
130
+ const staging = path.join(parent, `.${name}.publish-${randomUUID()}`);
131
+ const backup = path.join(parent, `.${name}.publish-backup-${randomUUID()}`);
132
+ await fs.mkdir(parent, { recursive: true });
133
+ let existed = false;
134
+ try {
135
+ const stat = await fs.lstat(target);
136
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`publishNode: output must be a regular directory: ${target}`);
137
+ await assertNoSymlinks(fs, path, target);
138
+ existed = true;
139
+ } catch (error) {
140
+ if (error.code !== "ENOENT") throw error;
141
+ }
142
+ try {
143
+ if (existed) await fs.cp(target, staging, {
144
+ recursive: true,
145
+ errorOnExist: true,
146
+ force: false
147
+ });
148
+ else await fs.mkdir(staging);
149
+ } catch (error) {
150
+ await fs.rm(staging, {
151
+ recursive: true,
152
+ force: true
153
+ });
154
+ throw error;
155
+ }
156
+ try {
157
+ await run(staging);
158
+ } catch (error) {
159
+ await fs.rm(staging, {
160
+ recursive: true,
161
+ force: true
162
+ });
163
+ throw error;
164
+ }
165
+ if (!existed) {
166
+ await fs.rename(staging, target);
167
+ return;
168
+ }
169
+ await fs.rename(target, backup);
170
+ try {
171
+ await fs.rename(staging, target);
172
+ } catch (error) {
173
+ await fs.rename(backup, target);
174
+ await fs.rm(staging, {
175
+ recursive: true,
176
+ force: true
177
+ });
178
+ throw error;
179
+ }
180
+ await fs.rm(backup, {
181
+ recursive: true,
182
+ force: true
183
+ }).catch(() => void 0);
184
+ }
185
+ async function assertNoSymlinks(fs, path, directory) {
186
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
187
+ const entryPath = path.join(directory, entry.name);
188
+ if (entry.isSymbolicLink()) throw new Error(`publishNode: symbolic links are not supported in output directories: ${entryPath}`);
189
+ if (entry.isDirectory()) await assertNoSymlinks(fs, path, entryPath);
190
+ }
191
+ }
105
192
  /**
106
193
  * Publish a FairyGUI project through the standard Node host adapter.
107
194
  *
@@ -114,17 +201,25 @@ async function publishNode(options) {
114
201
  const [fileSystem, assetsPath] = await Promise.all([createNodePublishFileSystem(), resolveNodeAssetsPath(document, configuredAssetsPath)]);
115
202
  const [encoder, plugins] = await Promise.all([configuredEncoder === void 0 ? loadSharpBackend() : Promise.resolve(configuredEncoder), configuredPlugins === void 0 ? loadNodePublishPlugins(document, assetsPath) : Promise.resolve(configuredPlugins)]);
116
203
  if (!encoder) throw new Error("publishNode: Sharp is required for a complete publish. Install sharp or provide an encoder.");
117
- await document.transform(publish({
118
- ...publishOptions,
119
- basePath: assetsPath,
120
- encoder,
121
- atlas: {
122
- ...atlas,
123
- readFileRaw: fileSystem.readFileRaw
124
- },
125
- fs: fileSystem,
126
- plugins
127
- }));
204
+ const run = async (output) => {
205
+ await document.transform(publish({
206
+ ...publishOptions,
207
+ output,
208
+ basePath: assetsPath,
209
+ encoder,
210
+ atlas: {
211
+ ...atlas,
212
+ readFileRaw: fileSystem.readFileRaw
213
+ },
214
+ fs: fileSystem,
215
+ plugins
216
+ }));
217
+ };
218
+ if (publishOptions.output) {
219
+ await publishToStagedOutput(publishOptions.output, run);
220
+ return;
221
+ }
222
+ await run(void 0);
128
223
  }
129
224
  //#endregion
130
225
  //#region src/adapters/node/restore.ts
@@ -2439,6 +2439,9 @@ const FGUI_TYPESCRIPT_BINDER_TEMPLATE = `{{generatedMark}}
2439
2439
  function formatPluginError(error) {
2440
2440
  return error instanceof Error ? error.message : String(error);
2441
2441
  }
2442
+ function shouldAbortPluginFailure(plugin) {
2443
+ return plugin.failureMode !== "warn";
2444
+ }
2442
2445
  //#endregion
2443
2446
  //#region src/path-utils.ts
2444
2447
  function trimTrailingSlashes(value) {
@@ -2524,7 +2527,9 @@ async function publishCodeGeneration(doc, options) {
2524
2527
  handled = true;
2525
2528
  logger.info(`publish: Generated code using plugin "${plugin.name}"`);
2526
2529
  } catch (error) {
2527
- logger.warn(`publish: Code generation plugin "${plugin.name}" failed: ${formatPluginError(error)}`);
2530
+ const message = `publish: Code generation plugin "${plugin.name}" failed: ${formatPluginError(error)}`;
2531
+ if (shouldAbortPluginFailure(plugin)) throw new Error(message);
2532
+ logger.warn(message);
2528
2533
  }
2529
2534
  }
2530
2535
  if (handled) return;
@@ -2995,7 +3000,9 @@ async function runPublishPluginHook(plugins, hook, doc, options) {
2995
3000
  try {
2996
3001
  await fn(doc, options);
2997
3002
  } catch (error) {
2998
- logger.warn(`publish: Plugin "${plugin.name}" ${hook} failed: ${formatPluginError(error)}`);
3003
+ const message = `publish: Plugin "${plugin.name}" ${hook} failed: ${formatPluginError(error)}`;
3004
+ if (shouldAbortPluginFailure(plugin)) throw new Error(message);
3005
+ logger.warn(message);
2999
3006
  }
3000
3007
  }
3001
3008
  }
@@ -2439,6 +2439,9 @@ const FGUI_TYPESCRIPT_BINDER_TEMPLATE = `{{generatedMark}}
2439
2439
  function formatPluginError(error) {
2440
2440
  return error instanceof Error ? error.message : String(error);
2441
2441
  }
2442
+ function shouldAbortPluginFailure(plugin) {
2443
+ return plugin.failureMode !== "warn";
2444
+ }
2442
2445
  //#endregion
2443
2446
  //#region src/path-utils.ts
2444
2447
  function trimTrailingSlashes(value) {
@@ -2524,7 +2527,9 @@ async function publishCodeGeneration(doc, options) {
2524
2527
  handled = true;
2525
2528
  logger.info(`publish: Generated code using plugin "${plugin.name}"`);
2526
2529
  } catch (error) {
2527
- logger.warn(`publish: Code generation plugin "${plugin.name}" failed: ${formatPluginError(error)}`);
2530
+ const message = `publish: Code generation plugin "${plugin.name}" failed: ${formatPluginError(error)}`;
2531
+ if (shouldAbortPluginFailure(plugin)) throw new Error(message);
2532
+ logger.warn(message);
2528
2533
  }
2529
2534
  }
2530
2535
  if (handled) return;
@@ -2995,7 +3000,9 @@ async function runPublishPluginHook(plugins, hook, doc, options) {
2995
3000
  try {
2996
3001
  await fn(doc, options);
2997
3002
  } catch (error) {
2998
- logger.warn(`publish: Plugin "${plugin.name}" ${hook} failed: ${formatPluginError(error)}`);
3003
+ const message = `publish: Plugin "${plugin.name}" ${hook} failed: ${formatPluginError(error)}`;
3004
+ if (shouldAbortPluginFailure(plugin)) throw new Error(message);
3005
+ logger.warn(message);
2999
3006
  }
3000
3007
  }
3001
3008
  }
@@ -166,6 +166,8 @@ interface PluginManifest {
166
166
  };
167
167
  icon?: string;
168
168
  main: string;
169
+ required?: boolean;
170
+ failureMode?: 'abort' | 'warn';
169
171
  }
170
172
  interface ICodeWriterConfig {
171
173
  blockStart?: string;
@@ -194,6 +196,7 @@ interface Plugin {
194
196
  interface LoadedPlugin {
195
197
  name: string;
196
198
  plugin: Plugin;
199
+ failureMode?: 'abort' | 'warn';
197
200
  }
198
201
  type PluginModule = Plugin & {
199
202
  default?: Plugin;
@@ -1,4 +1,4 @@
1
- import { d as basename, f as normalizeComparablePath, p as trimTrailingSlashes } from "./publish-BBoK-yeR.js";
1
+ import { d as basename, f as normalizeComparablePath, p as trimTrailingSlashes } from "./publish-Cy09yjfn.js";
2
2
  import { BinaryReader, ProjectType, ProjectWriter, generateId } from "@openfairygui/core";
3
3
  //#region src/restore-internals/output-transaction.ts
4
4
  function isPathWithin(root, candidate) {
@@ -166,6 +166,8 @@ interface PluginManifest {
166
166
  };
167
167
  icon?: string;
168
168
  main: string;
169
+ required?: boolean;
170
+ failureMode?: 'abort' | 'warn';
169
171
  }
170
172
  interface ICodeWriterConfig {
171
173
  blockStart?: string;
@@ -194,6 +196,7 @@ interface Plugin {
194
196
  interface LoadedPlugin {
195
197
  name: string;
196
198
  plugin: Plugin;
199
+ failureMode?: 'abort' | 'warn';
197
200
  }
198
201
  type PluginModule = Plugin & {
199
202
  default?: Plugin;
@@ -1,4 +1,4 @@
1
- const require_publish = require("./publish-CxKatE3e.cjs");
1
+ const require_publish = require("./publish-DhMv_lEP.cjs");
2
2
  let _openfairygui_core = require("@openfairygui/core");
3
3
  //#region src/restore-internals/output-transaction.ts
4
4
  function isPathWithin(root, candidate) {
package/dist/web.cjs CHANGED
@@ -1,32 +1,10 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_publish = require("./publish-CxKatE3e.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;
package/dist/web.js CHANGED
@@ -1,31 +1,9 @@
1
- import { _ as validateProject, c as resolveCodeGenerationSettings, n as resolvePublishOptions, t as publish } from "./publish-BBoK-yeR.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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfairygui/functions",
3
- "version": "0.3.0",
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"
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
 
@@ -55,7 +72,6 @@ async function readPluginManifest(
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
75
  return manifest as PluginManifest;
60
76
  }
61
77
 
@@ -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
  }
@@ -6,7 +6,8 @@ import type {
6
6
  PublishFileSystem,
7
7
  PublishSourceFileSystem,
8
8
  } from '../../publish/contracts.js';
9
- import { XMLParser, XMLValidator } from 'fast-xml-parser';
9
+ import { validateSafeSvgSource } from '@openfairygui/core';
10
+ import { XMLParser } from 'fast-xml-parser';
10
11
 
11
12
  type BrowserCanvas = OffscreenCanvas | HTMLCanvasElement;
12
13
 
@@ -41,30 +42,8 @@ interface BrowserRaster {
41
42
  height: number;
42
43
  }
43
44
 
44
- const MAX_SVG_SOURCE_BYTES = 8 * 1024 * 1024;
45
45
  const MAX_SVG_DIMENSION = 16_384;
46
46
  const MAX_SVG_PIXELS = 64 * 1024 * 1024;
47
- const MAX_SVG_NODES = 50_000;
48
- const UNSAFE_SVG_ELEMENTS = new Set([
49
- 'a',
50
- 'animate',
51
- 'animatecolor',
52
- 'animatemotion',
53
- 'animatetransform',
54
- 'audio',
55
- 'canvas',
56
- 'discard',
57
- 'embed',
58
- 'feimage',
59
- 'foreignobject',
60
- 'iframe',
61
- 'image',
62
- 'object',
63
- 'script',
64
- 'set',
65
- 'style',
66
- 'video',
67
- ]);
68
47
 
69
48
  type ParsedSvgEntry = Record<string, unknown> & { ':@'?: Record<string, unknown> };
70
49
 
@@ -87,57 +66,16 @@ function parseSvgLength(value: unknown, name: string): number | undefined {
87
66
  return parsed;
88
67
  }
89
68
 
90
- function validateSvgAttribute(name: string, value: unknown): void {
91
- const normalizedName = name.toLowerCase();
92
- if (normalizedName === 'xmlns' || normalizedName.startsWith('xmlns:')) return;
93
- const localName = svgLocalName(name);
94
- const text = String(value);
95
- if (localName.startsWith('on')) unsafeSvg(`event attribute "${name}" is not allowed`);
96
- if (localName === 'style' || localName === 'src') unsafeSvg(`attribute "${name}" is not allowed`);
97
- if (localName === 'href' && !/^#[A-Za-z_][\w:.-]*$/u.test(text)) {
98
- unsafeSvg(`external reference in "${name}" is not allowed`);
99
- }
100
- if (/(?:^|[\s("'=])(?:https?:|file:|javascript:|data:|\/\/)/iu.test(text)) {
101
- unsafeSvg(`external URL in "${name}" is not allowed`);
102
- }
103
- for (const match of text.matchAll(/url\s*\(([^)]*)\)/giu)) {
104
- const reference = (match[1] ?? '').trim().replace(/^(['"])(.*)\1$/u, '$2');
105
- if (!/^#[A-Za-z_][\w:.-]*$/u.test(reference)) unsafeSvg(`external url() in "${name}" is not allowed`);
106
- }
107
- }
108
-
109
- function visitSvgEntry(entry: ParsedSvgEntry): void {
110
- const pending = [entry];
111
- let nodeCount = 0;
112
- while (pending.length > 0) {
113
- const current = pending.pop()!;
114
- for (const [name, value] of Object.entries(current)) {
115
- if (name === ':@' || name.startsWith('#') || name.startsWith('?')) continue;
116
- if (++nodeCount > MAX_SVG_NODES) unsafeSvg('node count exceeds the supported limit');
117
- const localName = svgLocalName(name);
118
- if (UNSAFE_SVG_ELEMENTS.has(localName)) unsafeSvg(`element <${name}> is not allowed`);
119
- for (const [attributeName, attributeValue] of Object.entries(current[':@'] ?? {})) {
120
- validateSvgAttribute(attributeName, attributeValue);
121
- }
122
- if (Array.isArray(value)) {
123
- for (const child of value) {
124
- if (child && typeof child === 'object' && !Array.isArray(child)) pending.push(child as ParsedSvgEntry);
125
- }
126
- }
127
- }
128
- }
129
- }
130
-
131
69
  function validateSvg(bytes: Uint8Array): void {
132
- if (bytes.byteLength === 0 || bytes.byteLength > MAX_SVG_SOURCE_BYTES) unsafeSvg('source size is unsupported');
133
- let source: string;
134
70
  try {
135
- source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
136
- } catch {
137
- unsafeSvg('source is not valid UTF-8');
71
+ const source = validateSafeSvgSource(bytes);
72
+ validateSvgDimensions(source);
73
+ } catch (error) {
74
+ unsafeSvg(error instanceof Error ? error.message : String(error));
138
75
  }
139
- if (/<!\s*(?:doctype|entity)\b|<\?xml-stylesheet\b/iu.test(source)) unsafeSvg('DTD, entities, and stylesheets are not allowed');
140
- if (XMLValidator.validate(source, { allowBooleanAttributes: false }) !== true) unsafeSvg('source is not well-formed XML');
76
+ }
77
+
78
+ function validateSvgDimensions(source: string): void {
141
79
  const parsed = new XMLParser({
142
80
  preserveOrder: true,
143
81
  ignoreAttributes: false,
@@ -152,7 +90,6 @@ function validateSvg(bytes: Uint8Array): void {
152
90
  .map((name) => ({ entry, name })));
153
91
  if (roots.length !== 1 || svgLocalName(roots[0]!.name) !== 'svg') unsafeSvg('a single <svg> root is required');
154
92
  const root = roots[0]!.entry;
155
- visitSvgEntry(root);
156
93
  const attributes = root[':@'] ?? {};
157
94
  const width = parseSvgLength(attributes.width, 'width');
158
95
  const height = parseSvgLength(attributes.height, 'height');
package/src/codegen.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  UNITY_BINDER_TEMPLATE,
13
13
  UNITY_COMPONENT_TEMPLATE,
14
14
  } from './codegen-templates.js';
15
- import { formatPluginError, type LoadedPlugin } from './plugins/types.js';
15
+ import { formatPluginError, type LoadedPlugin, shouldAbortPluginFailure } from './plugins/types.js';
16
16
  import { dirname, isAbsolutePathLike, trimTrailingSlashes } from './path-utils.js';
17
17
  import type { PublishFileSystem } from './publish/contracts.js';
18
18
  import type { CliCodeGenerationSettings, RootProjectSettings } from './shared-types.js';
@@ -119,7 +119,9 @@ export async function publishCodeGeneration(doc: Document, options: PublishCodeG
119
119
  handled = true;
120
120
  logger.info(`publish: Generated code using plugin "${plugin.name}"`);
121
121
  } catch (error) {
122
- logger.warn(`publish: Code generation plugin "${plugin.name}" failed: ${formatPluginError(error)}`);
122
+ const message = `publish: Code generation plugin "${plugin.name}" failed: ${formatPluginError(error)}`;
123
+ if (shouldAbortPluginFailure(plugin)) throw new Error(message);
124
+ logger.warn(message);
123
125
  }
124
126
  }
125
127
  if (handled) {
@@ -15,6 +15,8 @@ export interface PluginManifest {
15
15
  };
16
16
  icon?: string;
17
17
  main: string;
18
+ required?: boolean;
19
+ failureMode?: 'abort' | 'warn';
18
20
  }
19
21
 
20
22
  export interface ICodeWriterConfig {
@@ -47,6 +49,7 @@ export interface Plugin {
47
49
  export interface LoadedPlugin {
48
50
  name: string;
49
51
  plugin: Plugin;
52
+ failureMode?: 'abort' | 'warn';
50
53
  }
51
54
 
52
55
  export type PluginModule = Plugin & { default?: Plugin };
@@ -54,3 +57,7 @@ export type PluginModule = Plugin & { default?: Plugin };
54
57
  export function formatPluginError(error: unknown): string {
55
58
  return error instanceof Error ? error.message : String(error);
56
59
  }
60
+
61
+ export function shouldAbortPluginFailure(plugin: LoadedPlugin): boolean {
62
+ return plugin.failureMode !== 'warn';
63
+ }
package/src/publish.ts CHANGED
@@ -13,7 +13,7 @@ import { prepareMovieClipResource } from './atlas/inputs.js';
13
13
  import type { PreparedJtaData } from './atlas/jta.js';
14
14
  import { publishCodeGeneration, resolveProjectBasePath } from './codegen.js';
15
15
  import { dirname, isAbsolutePathLike, trimTrailingSlashes } from './path-utils.js';
16
- import { formatPluginError, type LoadedPlugin } from './plugins/types.js';
16
+ import { formatPluginError, type LoadedPlugin, shouldAbortPluginFailure } from './plugins/types.js';
17
17
  import type { PublishFileSystem } from './publish/contracts.js';
18
18
  import {
19
19
  annotatePackagePublishArtifacts,
@@ -75,7 +75,9 @@ async function runPublishPluginHook(
75
75
  try {
76
76
  await fn(doc, options);
77
77
  } catch (error) {
78
- logger.warn(`publish: Plugin "${plugin.name}" ${hook} failed: ${formatPluginError(error)}`);
78
+ const message = `publish: Plugin "${plugin.name}" ${hook} failed: ${formatPluginError(error)}`;
79
+ if (shouldAbortPluginFailure(plugin)) throw new Error(message);
80
+ logger.warn(message);
79
81
  }
80
82
  }
81
83
  }