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

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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { a as decodeText, c as resolvePackageCodegenPlan, d as atlas, f as createTransform, i as buildCodegenClasses, n as resolvePublishOptions, o as encodeText, r as AUTO_GENERATED_CODE_MARK, s as publishCodeGeneration, t as publish } from "./publish-Bl-GK9kt.js";
1
+ import { a as decodeText, c as resolvePackageCodegenPlan, d as atlas, f as createTransform, i as buildCodegenClasses, n as resolvePublishOptions, o as encodeText, r as AUTO_GENERATED_CODE_MARK, s as publishCodeGeneration, t as publish } from "./publish-DsPK0SJ1.js";
2
2
  import { applyUamTransactionApp } from "./uam-transaction.js";
3
3
  import { BinaryReader, ProjectType, ProjectWriter, generateId } from "@openfairygui/core";
4
4
  //#region src/inspect.ts
@@ -392,10 +392,16 @@ const TRANSPARENT_PNG_1X1 = Uint8Array.from([
392
392
  96,
393
393
  130
394
394
  ]);
395
+ function assertSafeRestoreSegment(value, label) {
396
+ if (!value || value === "." || value === ".." || value.includes("\0") || /[\\/:]/u.test(value)) throw new Error(`restore: Invalid ${label} "${value}".`);
397
+ }
395
398
  function normalizeVirtualPath(path) {
396
- const normalized = (path ?? "").replace(/\\/g, "/").trim();
397
- if (!normalized || normalized === "/") return "";
398
- return normalized.replace(/^\/+/, "").replace(/\/+$/, "");
399
+ const raw = (path ?? "").trim();
400
+ if (!raw || raw === "/") return "";
401
+ if (raw.includes("\0") || raw.startsWith("\\") || raw.startsWith("//") || /^[a-z]:/iu.test(raw)) throw new Error(`restore: Invalid resource path "${raw}".`);
402
+ const segments = raw.replace(/\\/g, "/").split("/").filter(Boolean);
403
+ if (segments.some((segment) => segment === "." || segment === ".." || segment.includes(":"))) throw new Error(`restore: Invalid resource path "${raw}".`);
404
+ return segments.join("/");
399
405
  }
400
406
  function resourceFileName(resource) {
401
407
  return resource.getFileName?.() || resource.getFile?.() || resource.getName?.() || "";
@@ -579,44 +585,40 @@ function normalizeComparablePath(value) {
579
585
  const joined = segments.join("/");
580
586
  return (drivePrefix ? `${drivePrefix}/${joined}`.replace(/\/$/, "") : hasRoot ? `/${joined}`.replace(/\/$/, "") : joined || ".").toLowerCase();
581
587
  }
582
- function dirname(filePath) {
583
- return trimTrailingSlashes(filePath).match(/^(.*)[/\\][^/\\]+$/)?.[1] ?? "";
588
+ function isPathWithin(root, candidate) {
589
+ const normalizedRoot = normalizeComparablePath(root);
590
+ return normalizeComparablePath(candidate).startsWith(`${normalizedRoot}/`);
584
591
  }
585
592
  function basename(filePath) {
586
593
  return trimTrailingSlashes(filePath).match(/([^/\\]+)$/)?.[1] ?? "";
587
594
  }
588
- function resolveOutputProjectPath(output, fs) {
589
- if (/\.fairy$/i.test(output)) return output;
590
- const normalizedOutput = trimTrailingSlashes(output);
591
- const projectName = basename(normalizedOutput) || "Restored";
592
- return fs.join(normalizedOutput, `${projectName}.fairy`);
595
+ function normalizeRestoreOutputDir(output) {
596
+ const normalized = trimTrailingSlashes(output);
597
+ const name = basename(normalized);
598
+ if (!normalized || /\.fairy$/i.test(normalized) || !name || name === "." || name === ".." || /^[a-z]:$/iu.test(name)) throw new Error("restore: Output must be a non-root project directory, not a .fairy file.");
599
+ return normalized;
593
600
  }
594
- async function prepareRestoreOutputDir(inputDir, outputDir, outputProjectPath, fs, force, outputIsProjectFile) {
595
- const [resolvedInputDir, resolvedOutputDir] = await Promise.all([Promise.resolve(fs.resolvePath(inputDir)), Promise.resolve(fs.resolvePath(outputDir))]);
596
- if (normalizeComparablePath(resolvedInputDir) === normalizeComparablePath(resolvedOutputDir)) throw new Error("Restore output directory must be different from the published input directory.");
597
- if (outputIsProjectFile) {
598
- if (!await fs.exists(outputDir)) {
599
- await fs.mkdir(outputDir);
600
- return;
601
- }
602
- try {
603
- await fs.readdir(outputDir);
604
- } catch {
605
- throw new Error(`Restore output path is not a directory: ${outputDir}`);
606
- }
607
- if (!await fs.exists(outputProjectPath)) return;
608
- if (!force) throw new Error(`Restore output file already exists: ${outputProjectPath}. Use --force to overwrite it.`);
609
- if (!fs.rm) throw new Error("Restore output file already exists and the provided fs does not support rm(...).");
610
- await fs.rm(outputProjectPath, {
611
- recursive: true,
612
- force: true
613
- });
614
- return;
615
- }
616
- if (!await fs.exists(outputDir)) {
617
- await fs.mkdir(outputDir);
618
- return;
601
+ function resolveOutputProjectPath(outputDir, fs) {
602
+ return fs.join(outputDir, `${basename(outputDir)}.fairy`);
603
+ }
604
+ async function resolvePathForContainment(filePath, fs) {
605
+ const missingSegments = [];
606
+ let existingPath = filePath;
607
+ while (!await fs.exists(existingPath)) {
608
+ const parentPath = fs.dirname(existingPath);
609
+ if (!parentPath || parentPath === existingPath) return Promise.resolve(fs.resolvePath(filePath));
610
+ missingSegments.unshift(basename(existingPath));
611
+ existingPath = parentPath;
619
612
  }
613
+ const resolvedExistingPath = await Promise.resolve(fs.resolvePath(existingPath));
614
+ return missingSegments.reduce((resolvedPath, segment) => fs.join(resolvedPath, segment), resolvedExistingPath);
615
+ }
616
+ async function assertRestoreOutputDir(inputDir, outputDir, fs, force) {
617
+ const [resolvedInputDir, resolvedOutputDir] = await Promise.all([resolvePathForContainment(inputDir, fs), resolvePathForContainment(outputDir, fs)]);
618
+ const normalizedInputDir = normalizeComparablePath(resolvedInputDir);
619
+ const normalizedOutputDir = normalizeComparablePath(resolvedOutputDir);
620
+ if (normalizedInputDir === normalizedOutputDir || isPathWithin(normalizedInputDir, normalizedOutputDir) || isPathWithin(normalizedOutputDir, normalizedInputDir)) throw new Error("Restore output directory must be independent from the published input directory.");
621
+ if (!await fs.exists(outputDir)) return;
620
622
  let entries;
621
623
  try {
622
624
  entries = await fs.readdir(outputDir);
@@ -625,23 +627,63 @@ async function prepareRestoreOutputDir(inputDir, outputDir, outputProjectPath, f
625
627
  }
626
628
  if (entries.length === 0) return;
627
629
  if (!force) throw new Error(`Restore output directory is not empty: ${outputDir}. Use --force to overwrite it.`);
628
- if (!fs.rm) throw new Error("Restore output directory is not empty and the provided fs does not support rm(...).");
629
- await fs.rm(outputDir, {
630
- recursive: true,
631
- force: true
632
- });
633
- await fs.mkdir(outputDir);
630
+ }
631
+ async function createRestoreStagingDir(outputDir, fs) {
632
+ const parentDir = fs.dirname(outputDir) || ".";
633
+ await fs.mkdir(parentDir);
634
+ for (let attempt = 0; attempt < 8; attempt += 1) {
635
+ const stagingDir = fs.join(parentDir, `.${basename(outputDir)}.restore-${generateId()}`);
636
+ if (await fs.exists(stagingDir)) continue;
637
+ await fs.mkdir(stagingDir);
638
+ return stagingDir;
639
+ }
640
+ throw new Error(`restore: Could not allocate a staging directory beside ${outputDir}.`);
641
+ }
642
+ async function commitRestoreOutput(stagingDir, outputDir, fs) {
643
+ if (!await fs.exists(outputDir)) {
644
+ await fs.rename(stagingDir, outputDir);
645
+ return null;
646
+ }
647
+ const parentDir = fs.dirname(outputDir) || ".";
648
+ let backupDir = "";
649
+ for (let attempt = 0; attempt < 8; attempt += 1) {
650
+ const candidate = fs.join(parentDir, `.${basename(outputDir)}.restore-backup-${generateId()}`);
651
+ if (!await fs.exists(candidate)) {
652
+ backupDir = candidate;
653
+ break;
654
+ }
655
+ }
656
+ if (!backupDir) throw new Error(`restore: Could not allocate a backup directory beside ${outputDir}.`);
657
+ await fs.rename(outputDir, backupDir);
658
+ try {
659
+ await fs.rename(stagingDir, outputDir);
660
+ } catch (error) {
661
+ await fs.rename(backupDir, outputDir);
662
+ throw error;
663
+ }
664
+ try {
665
+ await fs.rm(backupDir, {
666
+ recursive: true,
667
+ force: true
668
+ });
669
+ return null;
670
+ } catch {
671
+ return `restore: Previous output retained at ${backupDir}; remove it after checking the restored project.`;
672
+ }
634
673
  }
635
674
  async function restore(options) {
636
675
  const sourceDir = trimTrailingSlashes(options.inputDir);
637
- const outputIsProjectFile = /\.fairy$/i.test(options.output);
638
- const outputProjectPath = resolveOutputProjectPath(options.output, options.fs);
639
- await prepareRestoreOutputDir(sourceDir, dirname(outputProjectPath) || ".", outputProjectPath, options.fs, options.force === true, outputIsProjectFile);
676
+ const outputDir = normalizeRestoreOutputDir(options.output);
677
+ const outputProjectPath = resolveOutputProjectPath(outputDir, options.fs);
678
+ await assertRestoreOutputDir(sourceDir, outputDir, options.fs, options.force === true);
640
679
  const packageFilter = options.packages?.length ? new Set(options.packages) : null;
641
- const candidateBinaryPaths = (await options.fs.readdir(sourceDir)).filter((name) => isPublishedBinaryFile(name)).filter((name) => !packageFilter || packageFilter.has(inferPackageName(name))).map((name) => options.fs.join(sourceDir, name)).sort((left, right) => left.localeCompare(right));
680
+ const binaryNames = (await options.fs.readdir(sourceDir)).filter((name) => isPublishedBinaryFile(name)).filter((name) => !packageFilter || packageFilter.has(inferPackageName(name)));
681
+ for (const binaryName of binaryNames) assertSafeRestoreSegment(binaryName, "published binary file name");
682
+ const candidateBinaryPaths = binaryNames.map((name) => options.fs.join(sourceDir, name)).sort((left, right) => left.localeCompare(right));
642
683
  const binaryPaths = (await Promise.all(candidateBinaryPaths.map(async (filePath) => await options.fs.isFile(filePath) ? filePath : null))).filter((filePath) => !!filePath).sort((left, right) => left.localeCompare(right));
643
684
  if (binaryPaths.length === 0) throw new Error(`No FairyGUI published binary files found in ${sourceDir}.`);
644
- return new RestoreWorkflow(options.fs).restore({
685
+ const restorer = new RestoreWorkflow(options.fs);
686
+ const document = await restorer.prepare({
645
687
  binaryPaths,
646
688
  sourceDir,
647
689
  outputProjectPath,
@@ -649,18 +691,45 @@ async function restore(options) {
649
691
  cropImage: options.cropImage,
650
692
  extractImage: options.extractImage
651
693
  });
694
+ const stagingDir = await createRestoreStagingDir(outputDir, options.fs);
695
+ const stagingProjectPath = options.fs.join(stagingDir, basename(outputProjectPath));
696
+ const warnings = [];
697
+ try {
698
+ await restorer.write(document, {
699
+ binaryPaths,
700
+ sourceDir,
701
+ outputProjectPath: stagingProjectPath,
702
+ projectType: options.projectType,
703
+ cropImage: options.cropImage,
704
+ extractImage: options.extractImage
705
+ }, warnings);
706
+ const cleanupWarning = await commitRestoreOutput(stagingDir, outputDir, options.fs);
707
+ if (cleanupWarning) warnings.push(cleanupWarning);
708
+ } catch (error) {
709
+ await options.fs.rm(stagingDir, {
710
+ recursive: true,
711
+ force: true
712
+ }).catch(() => void 0);
713
+ throw error;
714
+ }
715
+ return {
716
+ document,
717
+ projectPath: outputProjectPath,
718
+ warnings
719
+ };
652
720
  }
653
721
  var RestoreWorkflow = class {
654
722
  _fs;
655
723
  constructor(fs) {
656
724
  this._fs = fs;
657
725
  }
658
- async restore(options) {
659
- const warnings = [];
726
+ async prepare(options) {
660
727
  const doc = await new BinaryReader(this._fs).readMany(options.binaryPaths);
728
+ this._assertDocumentPaths(doc);
661
729
  this._initializeProjectDefaults(doc, options.projectType);
662
730
  this._initializeImageFileNames(doc);
663
731
  this._initializeLooseResourceFileNames(doc);
732
+ this._assertDocumentPaths(doc);
664
733
  await this._synthesizeLooseSkeletonResources(doc, options.sourceDir);
665
734
  this._initializeRestoredResourceRelations(doc);
666
735
  this._initializePublishedFontTextureIds(doc);
@@ -669,13 +738,27 @@ var RestoreWorkflow = class {
669
738
  this._initializePublishedTextFontResources(doc);
670
739
  this._initializeDisplayObjectFileNames(doc);
671
740
  this._initializePublishedFontDefaults(doc);
741
+ this._assertDocumentPaths(doc);
742
+ return doc;
743
+ }
744
+ async write(doc, options, warnings) {
672
745
  await new ProjectWriter(this._fs).write(doc, options.outputProjectPath);
673
746
  await this._restoreAssets(doc, options, warnings);
674
- return {
675
- document: doc,
676
- projectPath: options.outputProjectPath,
677
- warnings
678
- };
747
+ }
748
+ _assertDocumentPaths(doc) {
749
+ for (const pkg of doc.getRoot().listPackages()) {
750
+ assertSafeRestoreSegment(pkg.getName(), "package name");
751
+ assertSafeRestoreSegment(pkg.getPublishName() || pkg.getName(), "package publish name");
752
+ for (const resource of pkg.listResources()) {
753
+ normalizeVirtualPath(resource.getPath?.());
754
+ const branch = resource.getBranch?.() ?? "";
755
+ if (branch) assertSafeRestoreSegment(branch, "branch name");
756
+ const fileName = resourceFileName(resource);
757
+ if (fileName) assertSafeRestoreSegment(fileName, "resource file name");
758
+ const publishedFileName = resourcePublishedFileName(resource);
759
+ if (publishedFileName) assertSafeRestoreSegment(publishedFileName, "published resource file name");
760
+ }
761
+ }
679
762
  }
680
763
  _initializeProjectDefaults(doc, projectType) {
681
764
  doc.getRoot().setProjectId(generateId()).setProjectType(projectType ?? ProjectType.Unity).setVersion("3.0").setSettings({
@@ -1109,27 +1192,40 @@ var RestoreWorkflow = class {
1109
1192
  }
1110
1193
  _sourceFileCandidates(pkg, fileName, outputFileName = fileName) {
1111
1194
  const publishName = pkg.getPublishName() || pkg.getName();
1112
- return Array.from(new Set([
1195
+ assertSafeRestoreSegment(publishName, "package publish name");
1196
+ assertSafeRestoreSegment(fileName, "published source file name");
1197
+ assertSafeRestoreSegment(outputFileName, "published source file name");
1198
+ const candidates = Array.from(new Set([
1113
1199
  `${publishName}_${fileName}`,
1114
1200
  fileName,
1115
1201
  `${publishName}_${outputFileName}`,
1116
1202
  outputFileName
1117
1203
  ]));
1204
+ for (const candidate of candidates) assertSafeRestoreSegment(candidate, "published source file name");
1205
+ return candidates;
1118
1206
  }
1119
1207
  async _resolveLooseSourceFile(pkg, sourceDir, outputFileName) {
1120
1208
  const candidates = outputFileName.endsWith(".atlas") ? this._sourceFileCandidates(pkg, `${outputFileName}.txt`, outputFileName) : outputFileName.endsWith(".skel") ? this._sourceFileCandidates(pkg, `${outputFileName}.bytes`, outputFileName) : this._sourceFileCandidates(pkg, outputFileName);
1121
1209
  return this._resolveSourceFile(sourceDir, candidates);
1122
1210
  }
1123
1211
  async _resolveSourceFile(sourceDir, candidates) {
1212
+ const resolvedSourceDir = await Promise.resolve(this._fs.resolvePath(sourceDir));
1124
1213
  for (const candidate of candidates) {
1214
+ assertSafeRestoreSegment(candidate, "published source file name");
1125
1215
  const sourcePath = this._fs.join(sourceDir, candidate);
1126
- if (await this._fs.isFile(sourcePath)) return sourcePath;
1216
+ if (!await this._fs.isFile(sourcePath)) continue;
1217
+ const resolvedSourcePath = await Promise.resolve(this._fs.resolvePath(sourcePath));
1218
+ if (!isPathWithin(resolvedSourceDir, resolvedSourcePath)) throw new Error(`restore: Published source file resolves outside the input directory: ${candidate}.`);
1219
+ return resolvedSourcePath;
1127
1220
  }
1128
1221
  return null;
1129
1222
  }
1130
1223
  _resourceOutputPath(outputProjectPath, pkg, resource, fileName) {
1131
1224
  const basePath = this._fs.dirname(outputProjectPath);
1132
1225
  const branch = resource.getBranch?.() ?? "";
1226
+ assertSafeRestoreSegment(pkg.getName(), "package name");
1227
+ if (branch) assertSafeRestoreSegment(branch, "branch name");
1228
+ assertSafeRestoreSegment(fileName, "resource file name");
1133
1229
  const assetsDir = branch ? `assets_${branch}` : "assets";
1134
1230
  const virtualPath = normalizeVirtualPath(resource.getPath?.());
1135
1231
  const pkgDir = this._fs.join(basePath, assetsDir, pkg.getName());
package/dist/node.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_publish = require("./publish-CtCXsMdj.cjs");
2
+ const require_publish = require("./publish-D7268_u4.cjs");
3
3
  //#region src/adapters/node/plugins.ts
4
4
  const importNative$1 = new Function("id", "return import(id)");
5
5
  async function loadPlugins(doc, pluginsDir) {
@@ -89,8 +89,8 @@ async function resolveNodeAssetsPath(document, assetsPath) {
89
89
  }
90
90
  async function loadSharpBackend() {
91
91
  try {
92
- const sharp = await importNative("sharp");
93
- return sharp.default ?? sharp;
92
+ const loaded = await importNative("sharp");
93
+ return loaded.default ?? loaded;
94
94
  } catch {
95
95
  return;
96
96
  }
@@ -111,7 +111,7 @@ async function publishNode(options) {
111
111
  const { document, assetsPath: configuredAssetsPath, atlas, encoder: configuredEncoder, plugins: configuredPlugins, ...publishOptions } = options;
112
112
  const [fileSystem, assetsPath] = await Promise.all([createNodePublishFileSystem(), resolveNodeAssetsPath(document, configuredAssetsPath)]);
113
113
  const [encoder, plugins] = await Promise.all([configuredEncoder === void 0 ? loadSharpBackend() : Promise.resolve(configuredEncoder), configuredPlugins === void 0 ? loadNodePublishPlugins(document, assetsPath) : Promise.resolve(configuredPlugins)]);
114
- if (!encoder) document.getLogger().warn("publish: Sharp is unavailable; atlas layout will be generated without PNG output.");
114
+ if (!encoder) throw new Error("publishNode: Sharp is required for a complete publish. Install sharp or provide an encoder.");
115
115
  await document.transform(require_publish.publish({
116
116
  ...publishOptions,
117
117
  basePath: assetsPath,
package/dist/node.d.cts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { r as AtlasRasterBackend } from "./atlas-CHsu2Y8i.cjs";
2
- import { O as PublishOptions, m as LoadedPlugin } from "./codegen-C7PPZrB3.cjs";
2
+ import { O as PublishOptions, m as LoadedPlugin } from "./codegen-B8ZM1F4j.cjs";
3
3
  import { Document } 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-CDn6TirX.js";
2
- import { O as PublishOptions, m as LoadedPlugin } from "./codegen-CEnmtjBP.js";
2
+ import { O as PublishOptions, m as LoadedPlugin } from "./codegen-CfbDHuFt.js";
3
3
  import { Document } from "@openfairygui/core";
4
4
 
5
5
  //#region src/adapters/node/publish.d.ts
package/dist/node.js CHANGED
@@ -1,4 +1,4 @@
1
- import { l as resolveProjectBasePath, t as publish, u as formatPluginError } from "./publish-Bl-GK9kt.js";
1
+ import { l as resolveProjectBasePath, t as publish, u as formatPluginError } from "./publish-DsPK0SJ1.js";
2
2
  //#region src/adapters/node/plugins.ts
3
3
  const importNative$1 = new Function("id", "return import(id)");
4
4
  async function loadPlugins(doc, pluginsDir) {
@@ -88,8 +88,8 @@ async function resolveNodeAssetsPath(document, assetsPath) {
88
88
  }
89
89
  async function loadSharpBackend() {
90
90
  try {
91
- const sharp = await importNative("sharp");
92
- return sharp.default ?? sharp;
91
+ const loaded = await importNative("sharp");
92
+ return loaded.default ?? loaded;
93
93
  } catch {
94
94
  return;
95
95
  }
@@ -110,7 +110,7 @@ async function publishNode(options) {
110
110
  const { document, assetsPath: configuredAssetsPath, atlas, encoder: configuredEncoder, plugins: configuredPlugins, ...publishOptions } = options;
111
111
  const [fileSystem, assetsPath] = await Promise.all([createNodePublishFileSystem(), resolveNodeAssetsPath(document, configuredAssetsPath)]);
112
112
  const [encoder, plugins] = await Promise.all([configuredEncoder === void 0 ? loadSharpBackend() : Promise.resolve(configuredEncoder), configuredPlugins === void 0 ? loadNodePublishPlugins(document, assetsPath) : Promise.resolve(configuredPlugins)]);
113
- if (!encoder) document.getLogger().warn("publish: Sharp is unavailable; atlas layout will be generated without PNG output.");
113
+ if (!encoder) throw new Error("publishNode: Sharp is required for a complete publish. Install sharp or provide an encoder.");
114
114
  await document.transform(publish({
115
115
  ...publishOptions,
116
116
  basePath: assetsPath,
@@ -751,7 +751,8 @@ const ATLAS_DEFAULTS = {
751
751
  preserveInputOrderOnTie: false,
752
752
  directSingleImageOutput: false,
753
753
  extractAlpha: false,
754
- separatedAtlasForBranch: false
754
+ separatedAtlasForBranch: false,
755
+ strictOutput: false
755
756
  };
756
757
  function getPublishedItemId(resource) {
757
758
  return (resource.getExtras() ?? {})._publishedId ?? resource.getId();
@@ -908,8 +909,9 @@ function atlas(_options = {}) {
908
909
  const packageFilter = options.packages ? new Set(options.packages) : null;
909
910
  for (const pkg of root.listPackages()) {
910
911
  if (packageFilter && !packageFilter.has(pkg.getName())) continue;
911
- const selectedPublishIds = new Set((pkg.getExtras() ?? {}).publishedResourceIds ?? []);
912
- const allResources = selectedPublishIds.size > 0 ? pkg.listResources().filter((resource) => selectedPublishIds.has(resource.getId())) : pkg.listResources();
912
+ const publishedResourceIds = pkg.getExtras()?.publishedResourceIds;
913
+ const selectedPublishIds = new Set(publishedResourceIds);
914
+ const allResources = publishedResourceIds !== void 0 && (options.strictOutput || selectedPublishIds.size > 0) ? pkg.listResources().filter((resource) => selectedPublishIds.has(resource.getId())) : pkg.listResources();
913
915
  const skeletonDependencyImageIds = getSelectedSkeletonDependencyImageIds(allResources);
914
916
  const orderedResources = await resolveEditorCompatibleResourceOrder(pkg, allResources, options);
915
917
  const orderedAllResources = sortResourcesByOrder(allResources, new Map(orderedResources.map((resource, index) => [resource.getId(), index])), new Map(allResources.map((resource, index) => [resource.getId(), index])));
@@ -998,6 +1000,7 @@ function atlas(_options = {}) {
998
1000
  await _collectFontTexture(doc, res, pkg, options);
999
1001
  }
1000
1002
  if (inputs.length === 0) continue;
1003
+ if (options.strictOutput && (!encoder || !options.basePath || !options.outputPath)) throw new Error(`atlas: Package "${pkg.getName()}" requires encoder, basePath, and outputPath for complete raster output.`);
1001
1004
  let totalPageCount = 0;
1002
1005
  let usedDirectOutput = false;
1003
1006
  const { autoInputs, fixedPageGroups, standaloneGroups, reservedPageIndexes } = groupStandaloneInputs(doc, inputs, options);
@@ -1095,7 +1098,7 @@ function reserveAutoPageStart(branchPageOffsets, branchOrdinal, reservedPageInde
1095
1098
  async function emitPagedAtlasGroup(doc, pkg, allResources, inputs, context) {
1096
1099
  if (inputs.length === 0) return 0;
1097
1100
  const pages = packAtlasPages(inputs, context.options, context.forceSinglePage === true);
1098
- if (pages.length === 0) return 0;
1101
+ assertPackedInputCoverage(pages, inputs.length, `package "${pkg.getName()}"`);
1099
1102
  for (let pageOffset = 0; pageOffset < pages.length; pageOffset += 1) {
1100
1103
  const page = pages[pageOffset];
1101
1104
  const pageIndex = context.pageStart + pageOffset;
@@ -1121,7 +1124,7 @@ async function emitStandaloneAtlasGroup(doc, pkg, group, context) {
1121
1124
  multipleOfFour: true,
1122
1125
  square: false
1123
1126
  } : void 0);
1124
- if (pages.length === 0) return 0;
1127
+ assertPackedInputCoverage(pages, group.inputs.length, `standalone texture in package "${pkg.getName()}"`);
1125
1128
  for (let pageOffset = 0; pageOffset < pages.length; pageOffset += 1) {
1126
1129
  const page = pages[pageOffset];
1127
1130
  const baseFileName = resolveStandaloneAtlasOutputFileName(pkg, group.resource, group.branchName);
@@ -1164,6 +1167,12 @@ function packAtlasPages(inputs, options, forceSinglePage, sizeOverrides) {
1164
1167
  preserveInputOrderOnTie: options.preserveInputOrderOnTie
1165
1168
  }).pack(inputs.map((input, index) => inputToCompatRect(input, index))) ?? [];
1166
1169
  }
1170
+ function assertPackedInputCoverage(pages, inputCount, label) {
1171
+ const packedIndexes = /* @__PURE__ */ new Set();
1172
+ for (const page of pages) for (const outputRect of page.outputRects) packedIndexes.add(outputRect.index);
1173
+ const hasEveryInput = Array.from({ length: inputCount }, (_, index) => packedIndexes.has(index)).every(Boolean);
1174
+ if (packedIndexes.size !== inputCount || !hasEveryInput) throw new Error(`atlas: Could not pack every input for ${label}.`);
1175
+ }
1167
1176
  function attachSpritesToAtlas(doc, allResources, inputs, outputRects, atlasNode) {
1168
1177
  for (const packedRect of outputRects) {
1169
1178
  const input = inputs[packedRect.index];
@@ -1223,7 +1232,9 @@ async function writeAtlasPageImage(pkg, inputs, page, atlasFileName, encoder, op
1223
1232
  } else if (input.rasterizedBuffer) imageBuffer = input.rasterizedBuffer;
1224
1233
  else {
1225
1234
  if (!isImageResource$1(input.resource)) {
1226
- logger.warn(`atlas: Non-image input "${input.id}" is missing inline buffer, skipping compositing.`);
1235
+ const message = `atlas: Non-image input "${input.id}" is missing inline buffer.`;
1236
+ if (options.strictOutput) throw new Error(message);
1237
+ logger.warn(`${message} Skipping compositing.`);
1227
1238
  continue;
1228
1239
  }
1229
1240
  imageBuffer = await encoder(_resolveImagePath(input.resource, pkg, options.basePath)).toBuffer();
@@ -1235,7 +1246,9 @@ async function writeAtlasPageImage(pkg, inputs, page, atlasFileName, encoder, op
1235
1246
  top: packedRect.y
1236
1247
  });
1237
1248
  } catch {
1238
- logger.warn(`atlas: Could not read image "${input.id}" for compositing.`);
1249
+ const message = `atlas: Could not read image "${input.id}" for compositing.`;
1250
+ if (options.strictOutput) throw new Error(message);
1251
+ logger.warn(message);
1239
1252
  }
1240
1253
  }
1241
1254
  const outputFile = `${options.outputPath}/${atlasFileName}`;
@@ -1352,7 +1365,9 @@ async function emitDirectImageOutput(doc, pkg, input, encoder, options, logger,
1352
1365
  }]).png().toFile(outputFile);
1353
1366
  }
1354
1367
  } catch {
1355
- logger.warn(`atlas: Could not write direct-output atlas "${atlasFileName}".`);
1368
+ const message = `atlas: Could not write direct-output atlas "${atlasFileName}".`;
1369
+ if (options.strictOutput) throw new Error(message);
1370
+ logger.warn(message);
1356
1371
  }
1357
1372
  }
1358
1373
  function getInputBranchName(input) {
@@ -1578,6 +1593,7 @@ async function _collectImage(resource, pkg, inputs, encoder, options, doTrim, lo
1578
1593
  sourceHasAlpha = true;
1579
1594
  }
1580
1595
  } catch {
1596
+ if (options.strictOutput) throw new Error(`atlas: Could not read image "${filePath}".`);
1581
1597
  if (origW === 0 || origH === 0) {
1582
1598
  logger.warn(`atlas: Could not read image "${filePath}", skipping.`);
1583
1599
  return;
@@ -1616,7 +1632,11 @@ async function _collectImage(resource, pkg, inputs, encoder, options, doTrim, lo
1616
1632
  }
1617
1633
  /** Collect MovieClip frame textures from a .jta file into the inputs array. */
1618
1634
  async function _collectMovieClipFrames(doc, resource, pkg, inputs, encoder, options, logger) {
1619
- if (!options.basePath || !options.readFileRaw) return;
1635
+ if (!options.basePath || !options.readFileRaw) {
1636
+ if (options.strictOutput) throw new Error(`atlas: MovieClip "${resource.getId()}" requires basePath and readFileRaw for complete raster output.`);
1637
+ return;
1638
+ }
1639
+ if (!encoder && options.strictOutput) throw new Error(`atlas: MovieClip "${resource.getId()}" requires an encoder for complete raster output.`);
1620
1640
  const mcId = resource.getId();
1621
1641
  const mcName = resource.getName() + ".jta";
1622
1642
  const mcPath = resource.getPath() ?? "/";
@@ -1639,7 +1659,7 @@ async function _collectMovieClipFrames(doc, resource, pkg, inputs, encoder, opti
1639
1659
  const exportFrameIndex = firstFrameIndexByTextureIndex.get(textureIndex);
1640
1660
  if (exportFrameIndex === void 0) continue;
1641
1661
  const itemId = `${mcId}_${exportFrameIndex}`;
1642
- const input = await _createMovieClipFrameInput(jta.frames[textureIndex], itemId, resource, encoder);
1662
+ const input = await _createMovieClipFrameInput(jta.frames[textureIndex], itemId, resource, encoder, options.strictOutput);
1643
1663
  if (!input) continue;
1644
1664
  inputs.push(input);
1645
1665
  spriteIdByTextureIndex.set(textureIndex, itemId);
@@ -1653,7 +1673,7 @@ async function _collectMovieClipFrames(doc, resource, pkg, inputs, encoder, opti
1653
1673
  }
1654
1674
  } else for (let frameIndex = 0; frameIndex < jta.frames.length; frameIndex += 1) {
1655
1675
  const itemId = `${mcId}_${frameIndex}`;
1656
- const input = await _createMovieClipFrameInput(jta.frames[frameIndex], itemId, resource, encoder);
1676
+ const input = await _createMovieClipFrameInput(jta.frames[frameIndex], itemId, resource, encoder, options.strictOutput);
1657
1677
  if (!input) continue;
1658
1678
  inputs.push(input);
1659
1679
  const frame = doc.createMovieFrame(itemId);
@@ -1665,10 +1685,12 @@ async function _collectMovieClipFrames(doc, resource, pkg, inputs, encoder, opti
1665
1685
  resource.setHeight(jta.meta?.height ?? 0);
1666
1686
  }
1667
1687
  } catch {
1668
- logger.warn(`atlas: Could not parse MovieClip "${filePath}", skipping frames.`);
1688
+ const message = `atlas: Could not parse MovieClip "${filePath}".`;
1689
+ if (options.strictOutput) throw new Error(message);
1690
+ logger.warn(`${message} Skipping frames.`);
1669
1691
  }
1670
1692
  }
1671
- async function _createMovieClipFrameInput(buffer, itemId, resource, encoder) {
1693
+ async function _createMovieClipFrameInput(buffer, itemId, resource, encoder, strictOutput) {
1672
1694
  if (!encoder || buffer.length === 0) return null;
1673
1695
  try {
1674
1696
  const meta = await encoder(buffer).metadata();
@@ -1688,6 +1710,7 @@ async function _createMovieClipFrameInput(buffer, itemId, resource, encoder) {
1688
1710
  sourceKind: "movieclip-frame"
1689
1711
  };
1690
1712
  } catch {
1713
+ if (strictOutput) throw new Error(`atlas: Could not decode MovieClip frame "${itemId}".`);
1691
1714
  return null;
1692
1715
  }
1693
1716
  }
@@ -3000,13 +3023,13 @@ function getPublishedSkeletonDependencyImageIds(pkg, publishedResourceIds) {
3000
3023
  }
3001
3024
  return imageIds;
3002
3025
  }
3003
- async function exportPackageSounds(pkg, outputDir, basePath, fs, readFileRaw, logger) {
3026
+ async function exportPackageSounds(pkg, outputDir, basePath, fs, readFileRaw) {
3004
3027
  const publishedResourceIds = getAnnotatedPublishedResourceIds(pkg);
3005
3028
  if (publishedResourceIds.size === 0) return;
3006
3029
  if (!basePath || !readFileRaw) {
3007
3030
  if (pkg.listResources().some((resource) => {
3008
3031
  return isSoundResource(resource) && publishedResourceIds.has(resource.getId());
3009
- })) logger.warn(`publish: Sound resources in package "${pkg.getName()}" were not exported because basePath/readFileRaw is unavailable.`);
3032
+ })) throw new Error(`publish: Sound resources in package "${pkg.getName()}" require basePath and readFileRaw for output.`);
3010
3033
  return;
3011
3034
  }
3012
3035
  for (const resource of pkg.listResources()) {
@@ -3019,18 +3042,18 @@ async function exportPackageSounds(pkg, outputDir, basePath, fs, readFileRaw, lo
3019
3042
  const data = await readFileRaw(sourcePath);
3020
3043
  await fs.writeFileRaw(targetPath, data);
3021
3044
  } catch {
3022
- logger.warn(`publish: Could not export sound "${resource.getId()}" from package "${pkg.getName()}".`);
3045
+ throw new Error(`publish: Could not export sound "${resource.getId()}" from package "${pkg.getName()}".`);
3023
3046
  }
3024
3047
  }
3025
3048
  }
3026
- async function exportPackageExternalResources(pkg, outputDir, basePath, fs, readFileRaw, logger) {
3049
+ async function exportPackageExternalResources(pkg, outputDir, basePath, fs, readFileRaw) {
3027
3050
  const exportedResourceIds = getAnnotatedExportedResourceIds(pkg);
3028
3051
  const skeletonDependencyImageIds = getPublishedSkeletonDependencyImageIds(pkg, exportedResourceIds);
3029
3052
  if (exportedResourceIds.size === 0) return;
3030
3053
  if (!basePath || !readFileRaw) {
3031
3054
  if (pkg.listResources().some((resource) => {
3032
3055
  return (isMiscResource(resource) || isSkeletonResource(resource)) && exportedResourceIds.has(resource.getId()) || skeletonDependencyImageIds.has(resource.getId());
3033
- })) logger.warn(`publish: External resources in package "${pkg.getName()}" were not exported because basePath/readFileRaw is unavailable.`);
3056
+ })) throw new Error(`publish: External resources in package "${pkg.getName()}" require basePath and readFileRaw for output.`);
3034
3057
  return;
3035
3058
  }
3036
3059
  for (const resource of pkg.listResources()) {
@@ -3052,7 +3075,7 @@ async function exportPackageExternalResources(pkg, outputDir, basePath, fs, read
3052
3075
  const data = await readFileRaw(sourcePath);
3053
3076
  await fs.writeFileRaw(targetPath, data);
3054
3077
  } catch {
3055
- logger.warn(`publish: Could not export external resource "${resource.getId()}" from package "${pkg.getName()}".`);
3078
+ throw new Error(`publish: Could not export external resource "${resource.getId()}" from package "${pkg.getName()}".`);
3056
3079
  }
3057
3080
  }
3058
3081
  }
@@ -3068,18 +3091,17 @@ async function exportPackageExternalResources(pkg, outputDir, basePath, fs, read
3068
3091
  * `publishNode()` or `publishBrowser()` through their dedicated entries.
3069
3092
  *
3070
3093
  * ```ts
3071
- * import sharp from 'sharp';
3072
- * const io = new NodeIO();
3073
- * const doc = await io.readProject('./project.fairy');
3094
+ * import { NodeIO } from '@openfairygui/core/node';
3095
+ * import { publishNode } from '@openfairygui/functions/node';
3096
+ * const doc = await new NodeIO().readProject('./project.fairy');
3074
3097
  *
3075
- * await doc.transform(publish({
3098
+ * await publishNode({
3099
+ * document: doc,
3076
3100
  * output: './release/',
3077
3101
  * compressed: true,
3078
- * encoder: sharp,
3079
- * basePath: './assets/',
3102
+ * assetsPath: './assets/',
3080
3103
  * fileExtension: 'bytes',
3081
- * fs: io.createFileSystem(),
3082
- * }));
3104
+ * });
3083
3105
  * ```
3084
3106
  */
3085
3107
  function publish(options) {
@@ -3147,6 +3169,12 @@ function publish(options) {
3147
3169
  }
3148
3170
  });
3149
3171
  const publishPackage = async (plan, writerFs, packageIndex) => {
3172
+ if (options.fs && !plan.outputDir) throw new Error("publish: no output directory resolved. Provide --output, or configure global publish.path / package publishPath.");
3173
+ if (options.fs) {
3174
+ await options.fs.mkdir(plan.outputDir);
3175
+ await exportPackageSounds(plan.pkg, plan.outputDir, options.basePath, options.fs, options.atlas?.readFileRaw ?? options.fs.readFileRaw);
3176
+ await exportPackageExternalResources(plan.pkg, plan.outputDir, options.basePath, options.fs, options.atlas?.readFileRaw ?? options.fs.readFileRaw);
3177
+ }
3150
3178
  const atlasRuntimeOptions = resolvePublishAtlasRuntimeOptions(plan.fileExtension);
3151
3179
  await atlas({
3152
3180
  ...plan.atlas,
@@ -3157,20 +3185,17 @@ function publish(options) {
3157
3185
  outputPath: options.fs ? plan.outputDir : void 0,
3158
3186
  mkdir: options.fs ? options.fs.mkdir : void 0,
3159
3187
  readFileRaw: options.atlas?.readFileRaw ?? options.fs?.readFileRaw,
3188
+ strictOutput: options.fs !== void 0,
3160
3189
  packages: [plan.pkg.getName()],
3161
3190
  ...atlasRuntimeOptions
3162
3191
  })(doc);
3163
3192
  if (!options.fs) return;
3164
- if (!plan.outputDir) throw new Error("publish: no output directory resolved. Provide --output, or configure global publish.path / package publishPath.");
3165
- await options.fs.mkdir(plan.outputDir);
3166
3193
  const filePath = options.fs.join(plan.outputDir, plan.fileName);
3167
3194
  const bwOptions = {
3168
3195
  compressed: plan.compressed,
3169
3196
  packageIndex
3170
3197
  };
3171
3198
  await new _openfairygui_core.BinaryWriter(writerFs).write(doc, filePath, bwOptions);
3172
- await exportPackageSounds(plan.pkg, plan.outputDir, options.basePath, options.fs, options.atlas?.readFileRaw ?? options.fs.readFileRaw, logger);
3173
- await exportPackageExternalResources(plan.pkg, plan.outputDir, options.basePath, options.fs, options.atlas?.readFileRaw ?? options.fs.readFileRaw, logger);
3174
3199
  logger.info(`publish: Written ${plan.fileName}`);
3175
3200
  };
3176
3201
  const root = doc.getRoot();
@@ -3203,7 +3228,9 @@ function publish(options) {
3203
3228
  }
3204
3229
  const plans = allPackages.map((pkg) => resolvePackagePublishPlan(pkg, resolved, projectBasePath));
3205
3230
  if (!options.fs) {
3206
- logger.info(`publish: No fs provided — layout computed for ${allPackages.length} package(s), skipping file output.`);
3231
+ const outputPlan = plans.find((plan) => !!plan.outputDir);
3232
+ if (outputPlan) throw new Error(`publish: Output for package "${outputPlan.pkg.getName()}" requires a filesystem. Omit output and publish paths to run a layout-only transform.`);
3233
+ logger.info(`publish: Layout computed for ${allPackages.length} package(s); no output directory was requested.`);
3207
3234
  const noopWriterFs = toBinaryWriterFileSystem(createNoopPublishFs());
3208
3235
  for (const plan of plans) await publishPackage(plan, noopWriterFs, allDocPackages.indexOf(plan.pkg));
3209
3236
  await runPublishPluginHook(plugins, "onPublishEnd", doc, options);