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

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/src/publish.ts CHANGED
@@ -17,7 +17,8 @@ import {
17
17
  } from '@openfairygui/core';
18
18
  import { createTransform } from './utils.js';
19
19
  import { atlas, type AtlasOptions } from './atlas.js';
20
- import { publishCodeGeneration } from './codegen.js';
20
+ import { publishCodeGeneration, resolveProjectBasePath } from './codegen.js';
21
+ import { formatPluginError, loadPlugins, type LoadedPlugin } from './plugins/loader.js';
21
22
  import type {
22
23
  CliPublishSettings,
23
24
  HasOptionalFont,
@@ -28,10 +29,10 @@ import type {
28
29
 
29
30
  export interface PublishOptions {
30
31
  /**
31
- * Output directory for published files (.fui + atlas PNGs).
32
- * Required.
32
+ * Output directory override for published files (.fui + atlas PNGs).
33
+ * When omitted, publish uses package-level or project-level publish paths.
33
34
  */
34
- output: string;
35
+ output?: string;
35
36
 
36
37
  /**
37
38
  * Compress the binary data with zlib raw deflate. Default: false.
@@ -96,6 +97,58 @@ export interface ResolvedPublishOptions {
96
97
  atlas: ResolvedPublishAtlasOptions;
97
98
  }
98
99
 
100
+ interface ResolvedProjectPublishConfig extends ResolvedPublishOptions {
101
+ projectType: number;
102
+ includeBranches: boolean;
103
+ activeBranch: string;
104
+ includeHighResolution: number;
105
+ separatedAtlasForBranch: boolean;
106
+ globalOutputPath: string;
107
+ globalBranchOutputPath: string;
108
+ }
109
+
110
+ interface ResolvedPackagePublishPlan {
111
+ pkg: Package;
112
+ outputDir?: string;
113
+ publishName: string;
114
+ fileName: string;
115
+ compressed: boolean;
116
+ fileExtension: string;
117
+ includeBranches: boolean;
118
+ activeBranch: string;
119
+ includeHighResolution: number;
120
+ separatedAtlasForBranch: boolean;
121
+ atlas: ResolvedPublishAtlasOptions;
122
+ }
123
+
124
+ async function runPublishPluginHook(
125
+ plugins: LoadedPlugin[],
126
+ hook: 'onPublishStart' | 'onPublishEnd',
127
+ doc: Document,
128
+ options: PublishOptions,
129
+ ): Promise<void> {
130
+ const logger = doc.getLogger();
131
+ for (const plugin of plugins) {
132
+ const fn = plugin.plugin[hook];
133
+ if (typeof fn !== 'function') continue;
134
+ try {
135
+ await fn(doc, options);
136
+ } catch (error) {
137
+ logger.warn(`publish: Plugin "${plugin.name}" ${hook} failed: ${formatPluginError(error)}`);
138
+ }
139
+ }
140
+ }
141
+
142
+ function resolvePublishPluginsDir(doc: Document, options: PublishOptions): string {
143
+ const fs = options.fs;
144
+ const projectDir = doc.getProjectDir?.() ?? '';
145
+ if (projectDir) return fs?.join ? fs.join(projectDir, 'plugins') : `${projectDir.replace(/[/\\]+$/, '')}/plugins`;
146
+
147
+ const projectBasePath = resolveProjectBasePath(options.basePath);
148
+ if (!projectBasePath) return '';
149
+ return fs?.join ? fs.join(projectBasePath, 'plugins') : `${projectBasePath.replace(/[/\\]+$/, '')}/plugins`;
150
+ }
151
+
99
152
  interface ImageResourceExtras extends Record<string, unknown> {
100
153
  _fileName?: string;
101
154
  }
@@ -112,7 +165,9 @@ interface BranchAwarePublishedResource {
112
165
  interface PackagePublishContext {
113
166
  referencedIds: Set<string>;
114
167
  publishedResourceIds: Set<string>;
168
+ exportedResourceIds: Set<string>;
115
169
  pixelHitTestImageIds: Set<string>;
170
+ highResolutionItemIds: Map<string, Array<string | null>>;
116
171
  effectiveResourceIds: Map<string, string>;
117
172
  includeBranches: boolean;
118
173
  }
@@ -138,8 +193,10 @@ interface TransitionWithPublishRefs {
138
193
 
139
194
  interface ChildWithPublishRefs extends HasOptionalFont {
140
195
  getId?(): string;
196
+ getPackageId?(): string;
141
197
  getSrc?(): string;
142
198
  getUrl?(): string;
199
+ getInstanceSound?(): string;
143
200
  getDefaultItem?(): string;
144
201
  getIcon?(): string;
145
202
  getSelectedIcon?(): string;
@@ -273,6 +330,24 @@ export function resolvePublishOptions(
273
330
  };
274
331
  }
275
332
 
333
+ function trimTrailingSlashes(value: string): string {
334
+ return value.replace(/[/\\]+$/, '');
335
+ }
336
+
337
+ function isAbsolutePathLike(value: string): boolean {
338
+ return /^(?:[a-zA-Z]:[/\\]|[/\\]{1,2})/u.test(value);
339
+ }
340
+
341
+ function joinPathSegments(left: string, right: string): string {
342
+ const normalizedLeft = trimTrailingSlashes(left);
343
+ const normalizedRight = right.replace(/^[/\\]+/, '');
344
+ if (!normalizedLeft) return normalizedRight;
345
+ if (!normalizedRight) return normalizedLeft;
346
+ const separator = normalizedLeft.includes('\\') ? '\\' : '/';
347
+ return `${normalizedLeft}${separator}${normalizedRight}`;
348
+ }
349
+
350
+
276
351
  function dirname(filePath: string): string {
277
352
  const trimmed = filePath.replace(/[/\\]+$/, '');
278
353
  const match = trimmed.match(/^(.*)[/\\][^/\\]+$/);
@@ -311,6 +386,10 @@ function isMovieClipResource(resource: ReturnType<Package['listResources']>[numb
311
386
  return resource.propertyType === 'MovieClipResource';
312
387
  }
313
388
 
389
+ function isHighResolutionResource(resource: ReturnType<Package['listResources']>[number]): resource is ImageResource | MovieClipResource {
390
+ return isImageResource(resource) || isMovieClipResource(resource);
391
+ }
392
+
314
393
  function isMiscResource(resource: ReturnType<Package['listResources']>[number]): resource is MiscResource {
315
394
  return resource.propertyType === 'MiscResource';
316
395
  }
@@ -423,14 +502,15 @@ function extname(fileName: string): string {
423
502
  return normalized.slice(lastDot);
424
503
  }
425
504
 
426
- function resolvePublishedMiscFileName(resource: MiscResource): string {
505
+ function resolvePublishedMiscFileName(resource: MiscResource, projectType: number): string {
427
506
  const file = resource.getFile();
507
+ if (projectType !== UNITY_PROJECT_TYPE) return file;
428
508
  if (file.toLowerCase().endsWith('.atlas')) return `${file}.txt`;
429
509
  return file;
430
510
  }
431
511
 
432
- function resolvePublishedSkeletonFileName(resource: SpineResource | DragonBonesResource): string {
433
- if (isSpineResource(resource) && resource.getFile().toLowerCase().endsWith('.skel')) {
512
+ function resolvePublishedSkeletonFileName(resource: SpineResource | DragonBonesResource, projectType: number): string {
513
+ if (projectType === UNITY_PROJECT_TYPE && isSpineResource(resource) && resource.getFile().toLowerCase().endsWith('.skel')) {
434
514
  return `${resource.getFile()}.bytes`;
435
515
  }
436
516
  return resource.getFile();
@@ -481,11 +561,93 @@ function buildBranchResourceKey(resource: {
481
561
  return `${resource.propertyType}|${resource.getPath() ?? ''}|${resource.getName() ?? ''}`;
482
562
  }
483
563
 
564
+ const HIGH_RESOLUTION_LEVELS = [
565
+ { scale: 2, bit: 1, slot: 0 },
566
+ { scale: 3, bit: 2, slot: 1 },
567
+ { scale: 4, bit: 4, slot: 2 },
568
+ ] as const;
569
+
570
+ function buildHighResolutionResourceKey(resource: {
571
+ propertyType: string;
572
+ getPath(): string;
573
+ getName(): string;
574
+ getBranch?(): string;
575
+ }, name = resource.getName()): string {
576
+ return `${resource.propertyType}|${resource.getBranch?.() ?? ''}|${resource.getPath() ?? ''}|${name}`;
577
+ }
578
+
579
+ function isHighResolutionVariantName(name: string): boolean {
580
+ return /@(?:2|3|4)x(?:\.[^./\\]+)?$/iu.test(name);
581
+ }
582
+
583
+ function appendHighResolutionScaleToName(name: string, scale: number): string {
584
+ const extensionIndex = name.lastIndexOf('.');
585
+ if (extensionIndex > 0) {
586
+ return `${name.slice(0, extensionIndex)}@${scale}x${name.slice(extensionIndex)}`;
587
+ }
588
+ return `${name}@${scale}x`;
589
+ }
590
+
591
+ function trimTrailingMissingHighResolutionIds(ids: Array<string | null>): Array<string | null> {
592
+ while (ids.length > 0 && !ids[ids.length - 1]) {
593
+ ids.pop();
594
+ }
595
+ return ids;
596
+ }
597
+
598
+ function collectHighResolutionItemIds(
599
+ resources: ReturnType<Package['listResources']>,
600
+ publishedResourceIds: Set<string>,
601
+ includeHighResolution: number,
602
+ ): Map<string, Array<string | null>> {
603
+ const result = new Map<string, Array<string | null>>();
604
+ if (includeHighResolution <= 0) return result;
605
+
606
+ const highResolutionResourceByKey = new Map<string, ImageResource | MovieClipResource>();
607
+ for (const resource of resources) {
608
+ if (!isHighResolutionResource(resource)) continue;
609
+ highResolutionResourceByKey.set(buildHighResolutionResourceKey(resource), resource);
610
+ }
611
+
612
+ for (const resource of resources) {
613
+ if (!isHighResolutionResource(resource)) continue;
614
+ if (!publishedResourceIds.has(resource.getId())) continue;
615
+ if (isHighResolutionVariantName(resource.getName())) continue;
616
+
617
+ const ids: Array<string | null> = [];
618
+ for (const level of HIGH_RESOLUTION_LEVELS) {
619
+ if ((includeHighResolution & level.bit) === 0) {
620
+ ids[level.slot] = null;
621
+ continue;
622
+ }
623
+
624
+ const highResolutionResource = highResolutionResourceByKey.get(
625
+ buildHighResolutionResourceKey(resource, appendHighResolutionScaleToName(resource.getName(), level.scale)),
626
+ );
627
+ if (!highResolutionResource) {
628
+ ids[level.slot] = null;
629
+ continue;
630
+ }
631
+
632
+ const highResolutionId = highResolutionResource.getId();
633
+ publishedResourceIds.add(highResolutionId);
634
+ ids[level.slot] = highResolutionId;
635
+ }
636
+
637
+ trimTrailingMissingHighResolutionIds(ids);
638
+ if (ids.length > 0) result.set(resource.getId(), ids);
639
+ }
640
+
641
+ return result;
642
+ }
643
+
484
644
  function collectPackagePublishContext(
485
645
  pkg: Package,
486
646
  options: {
647
+ projectType: number;
487
648
  includeBranches: boolean;
488
649
  activeBranch: string;
650
+ includeHighResolution: number;
489
651
  },
490
652
  ): PackagePublishContext {
491
653
  const pkgId = pkg.getId();
@@ -494,6 +656,27 @@ function collectPackagePublishContext(
494
656
  const referencedIds = new Set<string>();
495
657
  const pixelHitTestImageIds = new Set<string>();
496
658
  const spriteItemIds = new Set<string>();
659
+ const collectExportedResourceIds = (
660
+ sourceResources: ReturnType<Package['listResources']>,
661
+ sourcePublishedResourceIds: Set<string>,
662
+ ): Set<string> => {
663
+ const exportedResourceIds = new Set<string>(sourcePublishedResourceIds);
664
+ const resourcesById = new Map(sourceResources.map((resource) => [resource.getId(), resource] as const));
665
+ let changed = true;
666
+ while (changed) {
667
+ changed = false;
668
+ for (const resourceId of [...exportedResourceIds]) {
669
+ const resource = resourcesById.get(resourceId);
670
+ if (!resource || !isSkeletonResource(resource)) continue;
671
+ for (const requiredId of resource.getRequireIds()) {
672
+ if (!requiredId || exportedResourceIds.has(requiredId)) continue;
673
+ exportedResourceIds.add(requiredId);
674
+ changed = true;
675
+ }
676
+ }
677
+ }
678
+ return exportedResourceIds;
679
+ };
497
680
 
498
681
  for (const atlas of pkg.listAtlases()) {
499
682
  for (const sprite of atlas.listSprites()) {
@@ -531,6 +714,7 @@ function collectPackagePublishContext(
531
714
  child.getSelectedIcon?.(),
532
715
  child.getDropdown?.(),
533
716
  child.getSound?.(),
717
+ child.getInstanceSound?.(),
534
718
  child.getInstanceIcon?.(),
535
719
  child.getInstanceSelectedIcon?.(),
536
720
  child.getVtScrollBarRes?.(),
@@ -608,20 +792,16 @@ function collectPackagePublishContext(
608
792
  }
609
793
  }
610
794
 
611
- let changed = true;
612
- while (changed) {
613
- changed = false;
614
- for (const resource of resources) {
615
- if (!isSkeletonResource(resource)) continue;
616
- if (!publishedResourceIds.has(resource.getId())) continue;
617
- for (const requiredId of resource.getRequireIds()) {
618
- if (!requiredId || publishedResourceIds.has(requiredId)) continue;
619
- publishedResourceIds.add(requiredId);
620
- changed = true;
621
- }
622
- }
795
+ for (const resourceId of collectExportedResourceIds(resources, publishedResourceIds)) {
796
+ publishedResourceIds.add(resourceId);
623
797
  }
624
798
 
799
+ const highResolutionItemIds = collectHighResolutionItemIds(
800
+ resources,
801
+ publishedResourceIds,
802
+ options.includeHighResolution,
803
+ );
804
+
625
805
  if (!options.includeBranches) {
626
806
  const mainByKey = new Map<string, ReturnType<Package['listResources']>[number]>();
627
807
  const activeBranchByKey = new Map<string, ReturnType<Package['listResources']>[number]>();
@@ -685,7 +865,9 @@ function collectPackagePublishContext(
685
865
  return {
686
866
  referencedIds,
687
867
  publishedResourceIds,
868
+ exportedResourceIds: collectExportedResourceIds(resources, publishedResourceIds),
688
869
  pixelHitTestImageIds,
870
+ highResolutionItemIds,
689
871
  effectiveResourceIds,
690
872
  includeBranches: false,
691
873
  };
@@ -694,7 +876,9 @@ function collectPackagePublishContext(
694
876
  return {
695
877
  referencedIds,
696
878
  publishedResourceIds,
879
+ exportedResourceIds: collectExportedResourceIds(resources, publishedResourceIds),
697
880
  pixelHitTestImageIds,
881
+ highResolutionItemIds,
698
882
  effectiveResourceIds: new Map([...publishedResourceIds].map((resourceId) => [resourceId, resourceId])),
699
883
  includeBranches: true,
700
884
  };
@@ -768,29 +952,42 @@ async function annotatePackagePublishArtifacts(
768
952
  basePath: string | undefined,
769
953
  encoder: PublishEncoder | undefined,
770
954
  options: {
955
+ projectType: number;
771
956
  includeBranches: boolean;
772
957
  activeBranch: string;
958
+ includeHighResolution: number;
773
959
  },
774
960
  ): Promise<void> {
775
- const { publishedResourceIds, pixelHitTestImageIds, effectiveResourceIds, includeBranches } = collectPackagePublishContext(pkg, options);
961
+ const {
962
+ publishedResourceIds,
963
+ exportedResourceIds,
964
+ pixelHitTestImageIds,
965
+ highResolutionItemIds,
966
+ effectiveResourceIds,
967
+ includeBranches,
968
+ } = collectPackagePublishContext(pkg, options);
776
969
  for (const resource of pkg.listResources()) {
777
970
  setPublishedIdExtra(resource, effectiveResourceIds.get(resource.getId()) ?? null);
971
+ if (isHighResolutionResource(resource)) {
972
+ resource.setHighResolutionItemIds(highResolutionItemIds.get(resource.getId()) ?? []);
973
+ }
778
974
  }
779
975
  await applyPixelHitTests(pkg, pixelHitTestImageIds, basePath, encoder);
780
976
  const extras = (pkg.getExtras() as PackagePublishArtifactsExtras | undefined) ?? {};
781
977
  pkg.setExtras({
782
978
  ...extras,
783
979
  publishedResourceIds: [...publishedResourceIds].sort((a, b) => a.localeCompare(b)),
980
+ exportedResourceIds: [...exportedResourceIds].sort((a, b) => a.localeCompare(b)),
784
981
  publishedIncludeBranches: includeBranches,
785
982
  publishedEffectiveResourceIds: Object.fromEntries(effectiveResourceIds),
786
983
  });
787
984
  for (const resource of pkg.listResources()) {
788
985
  if (isMiscResource(resource)) {
789
- setPublishedFileExtra(resource, resolvePublishedMiscFileName(resource));
986
+ setPublishedFileExtra(resource, resolvePublishedMiscFileName(resource, options.projectType));
790
987
  continue;
791
988
  }
792
989
  if (isSkeletonResource(resource)) {
793
- setPublishedFileExtra(resource, resolvePublishedSkeletonFileName(resource));
990
+ setPublishedFileExtra(resource, resolvePublishedSkeletonFileName(resource, options.projectType));
794
991
  }
795
992
  }
796
993
  }
@@ -800,6 +997,11 @@ function getAnnotatedPublishedResourceIds(pkg: Package): Set<string> {
800
997
  return new Set(extras.publishedResourceIds ?? []);
801
998
  }
802
999
 
1000
+ function getAnnotatedExportedResourceIds(pkg: Package): Set<string> {
1001
+ const extras = (pkg.getExtras() as PackagePublishArtifactsExtras | undefined) ?? {};
1002
+ return new Set(extras.exportedResourceIds ?? []);
1003
+ }
1004
+
803
1005
  function getPublishedSkeletonDependencyImageIds(
804
1006
  pkg: Package,
805
1007
  publishedResourceIds: Set<string>,
@@ -863,14 +1065,14 @@ async function exportPackageExternalResources(
863
1065
  readFileRaw: PublishFileSystem['readFileRaw'] | undefined,
864
1066
  logger: Document['getLogger'] extends () => infer T ? T : never,
865
1067
  ): Promise<void> {
866
- const publishedResourceIds = getAnnotatedPublishedResourceIds(pkg);
867
- const skeletonDependencyImageIds = getPublishedSkeletonDependencyImageIds(pkg, publishedResourceIds);
868
- if (publishedResourceIds.size === 0) return;
1068
+ const exportedResourceIds = getAnnotatedExportedResourceIds(pkg);
1069
+ const skeletonDependencyImageIds = getPublishedSkeletonDependencyImageIds(pkg, exportedResourceIds);
1070
+ if (exportedResourceIds.size === 0) return;
869
1071
  if (!basePath || !readFileRaw) {
870
1072
  const hasPublishedExternal = pkg.listResources().some((resource) => {
871
1073
  return (
872
1074
  (isMiscResource(resource) || isSkeletonResource(resource))
873
- && publishedResourceIds.has(resource.getId())
1075
+ && exportedResourceIds.has(resource.getId())
874
1076
  ) || skeletonDependencyImageIds.has(resource.getId());
875
1077
  });
876
1078
  if (hasPublishedExternal) {
@@ -881,7 +1083,7 @@ async function exportPackageExternalResources(
881
1083
 
882
1084
  for (const resource of pkg.listResources()) {
883
1085
  const resourceId = resource.getId();
884
- const isSkeletonExternal = publishedResourceIds.has(resourceId) && (isMiscResource(resource) || isSkeletonResource(resource));
1086
+ const isSkeletonExternal = exportedResourceIds.has(resourceId) && (isMiscResource(resource) || isSkeletonResource(resource));
885
1087
  const isSkeletonImageDependency = skeletonDependencyImageIds.has(resourceId) && isImageResource(resource);
886
1088
  if (!isSkeletonExternal && !isSkeletonImageDependency) continue;
887
1089
 
@@ -932,17 +1134,145 @@ async function exportPackageExternalResources(
932
1134
  */
933
1135
  export function publish(options: PublishOptions): Transform {
934
1136
  return createTransform('publish', async (doc: Document): Promise<void> => {
1137
+ const resolveConfiguredOutputPath = (value?: string, projectBasePath?: string): string | undefined => {
1138
+ const trimmed = value?.trim();
1139
+ if (!trimmed) return undefined;
1140
+ if (isAbsolutePathLike(trimmed) || !projectBasePath) {
1141
+ return trimTrailingSlashes(trimmed);
1142
+ }
1143
+ return trimTrailingSlashes(options.fs ? options.fs.join(projectBasePath, trimmed) : joinPathSegments(projectBasePath, trimmed));
1144
+ };
1145
+
1146
+ const resolveProjectPublishConfig = (): ResolvedProjectPublishConfig => {
1147
+ const settings = (doc.getRoot().getSettings?.() ?? {}) as RootProjectSettings;
1148
+ const publishSettings: CliPublishSettings = settings.publish ?? {};
1149
+ const resolved = resolvePublishOptions(doc, {
1150
+ compressed: options.compressed,
1151
+ fileExtension: options.fileExtension,
1152
+ packages: options.packages,
1153
+ atlas: options.atlas,
1154
+ });
1155
+ const branchProcessing = publishSettings.branchProcessing ?? 0;
1156
+ const includeBranches = branchProcessing === 0;
1157
+
1158
+ return {
1159
+ ...resolved,
1160
+ projectType: doc.getRoot().getProjectType(),
1161
+ includeBranches,
1162
+ activeBranch: includeBranches ? '' : (options.branch ?? ''),
1163
+ includeHighResolution: publishSettings.includeHighResolution ?? 0,
1164
+ separatedAtlasForBranch: includeBranches && publishSettings.seperatedAtlasForBranch === true,
1165
+ globalOutputPath: publishSettings.path?.trim() ?? '',
1166
+ globalBranchOutputPath: publishSettings.branchPath?.trim() ?? '',
1167
+ };
1168
+ };
1169
+
1170
+ const resolvePackagePublishPlan = (pkg: Package, config: ResolvedProjectPublishConfig, projectBasePath?: string): ResolvedPackagePublishPlan => {
1171
+ let outputDir: string | undefined;
1172
+
1173
+ if (options.output) {
1174
+ outputDir = trimTrailingSlashes(options.output);
1175
+ } else {
1176
+ const candidates: Array<string | undefined> = [];
1177
+ if (!config.includeBranches && config.activeBranch) {
1178
+ candidates.push(pkg.getPublishBranchPath(), config.globalBranchOutputPath);
1179
+ }
1180
+ candidates.push(pkg.getPublishPath(), config.globalOutputPath);
1181
+
1182
+ for (const candidate of candidates) {
1183
+ const resolved = resolveConfiguredOutputPath(candidate, projectBasePath);
1184
+ if (!resolved) continue;
1185
+ outputDir = resolved;
1186
+ break;
1187
+ }
1188
+ }
1189
+ const publishName = pkg.getPublishName() || pkg.getName();
1190
+
1191
+ return {
1192
+ pkg,
1193
+ outputDir,
1194
+ publishName,
1195
+ fileName: resolvePublishFileName(publishName, config.fileExtension),
1196
+ compressed: config.compressed,
1197
+ fileExtension: config.fileExtension,
1198
+ includeBranches: config.includeBranches,
1199
+ activeBranch: config.activeBranch,
1200
+ includeHighResolution: config.includeHighResolution,
1201
+ separatedAtlasForBranch: config.separatedAtlasForBranch,
1202
+ atlas: config.atlas,
1203
+ };
1204
+ };
1205
+
1206
+ const createNoopPublishFs = (): PublishFileSystem => ({
1207
+ async writeFileRaw(): Promise<void> {
1208
+ // No-op for layout-only publish flows.
1209
+ },
1210
+ async mkdir(): Promise<void> {
1211
+ // No-op for layout-only publish flows.
1212
+ },
1213
+ join(...paths: string[]): string {
1214
+ return paths.join('/');
1215
+ },
1216
+ });
1217
+
1218
+ const publishPackage = async ( plan: ResolvedPackagePublishPlan, writerFs: FileSystem, packageIndex: number) => {
1219
+ const atlasRuntimeOptions = resolvePublishAtlasRuntimeOptions(plan.fileExtension);
1220
+ await atlas({
1221
+ ...plan.atlas,
1222
+ ...(options.atlas ?? {}),
1223
+ separatedAtlasForBranch: plan.separatedAtlasForBranch,
1224
+ encoder: options.encoder,
1225
+ basePath: options.basePath,
1226
+ outputPath: options.fs ? plan.outputDir : undefined,
1227
+ mkdir: options.fs ? options.fs.mkdir : undefined,
1228
+ readFileRaw: options.atlas?.readFileRaw ?? options.fs?.readFileRaw,
1229
+ packages: [plan.pkg.getName()],
1230
+ ...atlasRuntimeOptions,
1231
+ })(doc);
1232
+
1233
+ if (!options.fs) return;
1234
+ if (!plan.outputDir) {
1235
+ throw new Error('publish: no output directory resolved. Provide --output, or configure global publish.path / package publishPath.');
1236
+ }
1237
+
1238
+ await options.fs.mkdir(plan.outputDir);
1239
+
1240
+ const filePath = options.fs.join(plan.outputDir, plan.fileName);
1241
+ const bwOptions: BinaryWriterOptions = {
1242
+ compressed: plan.compressed,
1243
+ packageIndex,
1244
+ };
1245
+
1246
+ const bw = new BinaryWriter(writerFs);
1247
+ await bw.write(doc, filePath, bwOptions);
1248
+ await exportPackageSounds(
1249
+ plan.pkg,
1250
+ plan.outputDir,
1251
+ options.basePath,
1252
+ options.fs,
1253
+ options.atlas?.readFileRaw ?? options.fs.readFileRaw,
1254
+ logger,
1255
+ );
1256
+ await exportPackageExternalResources(
1257
+ plan.pkg,
1258
+ plan.outputDir,
1259
+ options.basePath,
1260
+ options.fs,
1261
+ options.atlas?.readFileRaw ?? options.fs.readFileRaw,
1262
+ logger,
1263
+ );
1264
+
1265
+ logger.info(`publish: Written ${plan.fileName}`);
1266
+ };
1267
+
935
1268
  const root = doc.getRoot();
936
1269
  const logger = doc.getLogger();
937
- const settings = (root.getSettings?.() ?? {}) as RootProjectSettings;
938
- const publishSettings: CliPublishSettings = settings.publish ?? {};
939
- const resolved = resolvePublishOptions(doc, {
940
- compressed: options.compressed,
941
- fileExtension: options.fileExtension,
942
- packages: options.packages,
943
- atlas: options.atlas,
944
- });
945
- const ext = resolved.fileExtension;
1270
+ const projectBasePath = resolveProjectBasePath(options.basePath) || doc.getProjectDir?.() || '';
1271
+ const pluginsDir = resolvePublishPluginsDir(doc, options);
1272
+ const plugins = pluginsDir ? await loadPlugins(doc, pluginsDir) : [];
1273
+ await runPublishPluginHook(plugins, 'onPublishStart', doc, options);
1274
+
1275
+ const resolved = resolveProjectPublishConfig();
946
1276
 
947
1277
  // Step 1: Determine which packages to publish
948
1278
  let allPackages = root.listPackages();
@@ -953,14 +1283,10 @@ export function publish(options: PublishOptions): Transform {
953
1283
 
954
1284
  if (allPackages.length === 0) {
955
1285
  logger.warn('publish: No packages to publish.');
1286
+ await runPublishPluginHook(plugins, 'onPublishEnd', doc, options);
956
1287
  return;
957
1288
  }
958
1289
 
959
- const branchProcessing = publishSettings.branchProcessing ?? 0;
960
- const includeBranches = branchProcessing === 0;
961
- const activeBranch = includeBranches ? '' : (options.branch ?? '');
962
- const atlasRuntimeOptions = resolvePublishAtlasRuntimeOptions(ext);
963
-
964
1290
  const allDocPackages = root.listPackages();
965
1291
  // Build a pkgId→name map for dependency resolution
966
1292
  const pkgMap = new Map<string, Package>();
@@ -971,82 +1297,61 @@ export function publish(options: PublishOptions): Transform {
971
1297
  for (const pkg of allPackages) {
972
1298
  // Compute dependency list and selected publish artifacts before atlas packing,
973
1299
  // so merged-branch publishes can pack the overridden resources with main IDs.
974
- _computeDependencies(pkg, pkgMap);
1300
+ _computeDependencies(doc, pkg, pkgMap);
975
1301
  await annotatePackagePublishArtifacts(
976
1302
  pkg,
977
1303
  options.basePath,
978
1304
  options.encoder as PublishEncoder | undefined,
979
1305
  {
980
- includeBranches,
981
- activeBranch,
1306
+ projectType: resolved.projectType,
1307
+ includeBranches: resolved.includeBranches,
1308
+ activeBranch: resolved.activeBranch,
1309
+ includeHighResolution: resolved.includeHighResolution,
982
1310
  },
983
1311
  );
984
1312
  }
985
1313
 
986
- // Step 2: Atlas packing
987
- const atlasOpts: AtlasOptions = {
988
- ...resolved.atlas,
989
- ...(options.atlas ?? {}),
990
- separatedAtlasForBranch: includeBranches && publishSettings.seperatedAtlasForBranch === true,
991
- encoder: options.encoder,
992
- basePath: options.basePath,
993
- outputPath: options.fs ? options.output : undefined,
994
- mkdir: options.fs ? options.fs.mkdir : undefined,
995
- readFileRaw: options.atlas?.readFileRaw ?? options.fs?.readFileRaw,
996
- ...atlasRuntimeOptions,
997
- };
998
- await atlas(atlasOpts)(doc);
1314
+ const plans = allPackages.map((pkg) => resolvePackagePublishPlan(pkg, resolved, projectBasePath));
999
1315
 
1000
- // Step 3: Write .fui binary per package
1001
1316
  if (!options.fs) {
1002
1317
  logger.info(`publish: No fs provided — layout computed for ${allPackages.length} package(s), skipping file output.`);
1318
+ const noopWriterFs = toBinaryWriterFileSystem(createNoopPublishFs());
1319
+ for (const plan of plans) {
1320
+ await publishPackage(plan, noopWriterFs, allDocPackages.indexOf(plan.pkg));
1321
+ }
1322
+ await runPublishPluginHook(plugins, 'onPublishEnd', doc, options);
1003
1323
  return;
1004
1324
  }
1005
1325
 
1006
- await options.fs.mkdir(options.output);
1326
+ const unresolvedPlan = plans.find((plan) => !plan.outputDir);
1327
+ if (unresolvedPlan) {
1328
+ throw new Error(
1329
+ `publish: no output directory resolved for package "${unresolvedPlan.pkg.getName()}". ` +
1330
+ 'Provide --output, or configure global publish.path / package publishPath.',
1331
+ );
1332
+ }
1007
1333
 
1008
1334
  const writerFs = toBinaryWriterFileSystem(options.fs);
1009
1335
 
1010
- for (const pkg of allPackages) {
1011
- const pkgIndex = allDocPackages.indexOf(pkg);
1012
- const publishName = pkg.getPublishName() || pkg.getName();
1013
- const fileName = resolvePublishFileName(publishName, ext);
1014
- const filePath = options.fs.join(options.output, fileName);
1015
-
1016
- const bwOptions: BinaryWriterOptions = {
1017
- compressed: resolved.compressed,
1018
- packageIndex: pkgIndex,
1019
- };
1020
-
1021
- const bw = new BinaryWriter(writerFs);
1022
- await bw.write(doc, filePath, bwOptions);
1023
- await exportPackageSounds(
1024
- pkg,
1025
- options.output,
1026
- options.basePath,
1027
- options.fs,
1028
- options.atlas?.readFileRaw ?? options.fs.readFileRaw,
1029
- logger,
1030
- );
1031
- await exportPackageExternalResources(
1032
- pkg,
1033
- options.output,
1034
- options.basePath,
1035
- options.fs,
1036
- options.atlas?.readFileRaw ?? options.fs.readFileRaw,
1037
- logger,
1038
- );
1039
-
1040
- logger.info(`publish: Written ${fileName}`);
1336
+ for (const plan of plans) {
1337
+ const pkgIndex = allDocPackages.indexOf(plan.pkg);
1338
+ await publishPackage(plan, writerFs, pkgIndex);
1041
1339
  }
1042
1340
 
1043
1341
  await publishCodeGeneration(doc, {
1044
1342
  basePath: options.basePath,
1045
1343
  fs: options.fs,
1046
1344
  packages: allPackages,
1345
+ plugins,
1047
1346
  });
1048
1347
 
1049
- logger.info(`publish: Published ${allPackages.length} package(s) to ${options.output}`);
1348
+ const publishedTargets = [...new Set(plans.map((plan) => plan.outputDir).filter((value): value is string => Boolean(value)))];
1349
+ logger.info(
1350
+ publishedTargets.length > 0
1351
+ ? `publish: Published ${allPackages.length} package(s) to ${publishedTargets.join(', ')}`
1352
+ : `publish: Published ${allPackages.length} package(s)`,
1353
+ );
1354
+ await runPublishPluginHook(plugins, 'onPublishEnd', doc, options);
1050
1355
  });
1051
1356
  }
1052
1357
 
@@ -1055,25 +1360,109 @@ export function publish(options: PublishOptions): Transform {
1055
1360
  * The editor only adds dependencies for packages referenced via bitmap font URLs.
1056
1361
  * @internal
1057
1362
  */
1058
- function _computeDependencies(pkg: Package, pkgMap: Map<string, Package>): void {
1363
+ function _computeDependencies(doc: Document, pkg: Package, pkgMap: Map<string, Package>): void {
1059
1364
  const referencedPkgIds = new Set<string>();
1060
-
1061
- function scanFontUrl(font: string | string[] | null | undefined): void {
1062
- if (!font) return;
1063
- const fontStr = Array.isArray(font) ? font[0] : String(font);
1064
- if (typeof fontStr !== 'string' || !fontStr.startsWith('ui://')) return;
1065
- const rest = fontStr.slice(5);
1365
+ const pkgId = pkg.getId();
1366
+ const packageOrder = new Map(doc.getRoot().listPackages().map((entry, index) => [entry.getId(), index] as const));
1367
+ const addDependencyPackageId = (dependencyPkgId: string | null | undefined): void => {
1368
+ const normalized = dependencyPkgId?.trim() ?? '';
1369
+ if (!normalized || normalized === pkgId) return;
1370
+ referencedPkgIds.add(normalized);
1371
+ };
1372
+ const extractPackageIdFromUiUrl = (value: string): string | null => {
1373
+ if (!value.startsWith('ui://')) return null;
1374
+ const rest = value.slice(5);
1375
+ if (!rest) return null;
1376
+ const slashIndex = rest.indexOf('/');
1377
+ if (slashIndex >= 0) {
1378
+ return rest.slice(0, slashIndex) || null;
1379
+ }
1066
1380
  if (rest.length >= 8) {
1067
- const depPkgId = rest.slice(0, 8);
1068
- if (depPkgId !== pkg.getId()) referencedPkgIds.add(depPkgId);
1381
+ return rest.slice(0, 8);
1069
1382
  }
1070
- }
1383
+ return null;
1384
+ };
1385
+ const addDependencyPackageIdFromUiValue = (value: string | null | undefined): void => {
1386
+ if (!value || typeof value !== 'string') return;
1387
+ addDependencyPackageId(extractPackageIdFromUiUrl(value));
1388
+ };
1389
+ const addDependencyPackageIdsFromText = (value: string | null | undefined): void => {
1390
+ if (!value || typeof value !== 'string') return;
1391
+ const matches = value.matchAll(/ui:\/\/([0-9a-z]{8})/giu);
1392
+ for (const match of matches) {
1393
+ addDependencyPackageId(match[1] ?? '');
1394
+ }
1395
+ };
1396
+ const addDependencyPackageIdsFromUnknown = (value: unknown): void => {
1397
+ if (Array.isArray(value)) {
1398
+ for (const entry of value) addDependencyPackageIdsFromUnknown(entry);
1399
+ return;
1400
+ }
1401
+ if (typeof value === 'string') {
1402
+ addDependencyPackageIdFromUiValue(value);
1403
+ addDependencyPackageIdsFromText(value);
1404
+ }
1405
+ };
1406
+ const addDependencyFontRef = (value: string | string[] | null | undefined): void => {
1407
+ if (Array.isArray(value)) {
1408
+ for (const entry of value) addDependencyPackageIdFromUiValue(entry);
1409
+ return;
1410
+ }
1411
+ addDependencyPackageIdFromUiValue(value ?? undefined);
1412
+ };
1071
1413
 
1072
1414
  for (const res of pkg.listResources()) {
1073
1415
  if (res.propertyType !== 'Component') continue;
1074
- for (const child of res.listChildren?.() ?? []) {
1075
- // Only font="ui://..." references generate dependencies
1076
- scanFontUrl((child as HasOptionalFont).getFont?.());
1416
+ const component = res as ComponentWithPublishRefs;
1417
+ for (const child of component.listChildren?.() ?? []) {
1418
+ addDependencyPackageId(child.getPackageId?.());
1419
+ addDependencyFontRef(child.getFont?.());
1420
+ addDependencyPackageIdsFromText(child.getText?.());
1421
+ for (const ref of [
1422
+ child.getUrl?.(),
1423
+ child.getDefaultItem?.(),
1424
+ child.getIcon?.(),
1425
+ child.getSelectedIcon?.(),
1426
+ child.getDropdown?.(),
1427
+ child.getSound?.(),
1428
+ child.getInstanceSound?.(),
1429
+ child.getInstanceIcon?.(),
1430
+ child.getInstanceSelectedIcon?.(),
1431
+ child.getVtScrollBarRes?.(),
1432
+ child.getHzScrollBarRes?.(),
1433
+ child.getHeaderRes?.(),
1434
+ child.getFooterRes?.(),
1435
+ ]) {
1436
+ addDependencyPackageIdFromUiValue(ref);
1437
+ }
1438
+ for (const item of child.getInstanceComboItems?.() ?? []) {
1439
+ addDependencyPackageIdFromUiValue(item.icon ?? undefined);
1440
+ }
1441
+ for (const item of child.getListItems?.() ?? []) {
1442
+ addDependencyPackageIdFromUiValue(item.icon ?? undefined);
1443
+ addDependencyPackageIdFromUiValue(item.url ?? undefined);
1444
+ }
1445
+ for (const gear of child.listGears?.() ?? []) {
1446
+ addDependencyPackageIdsFromUnknown(gear.getValues?.());
1447
+ addDependencyPackageIdsFromUnknown(gear.getDefaultValue?.());
1448
+ }
1449
+ }
1450
+ addDependencyFontRef(component.getFont?.());
1451
+ for (const ref of [
1452
+ component.getDropdown?.(),
1453
+ component.getHeaderRes?.(),
1454
+ component.getFooterRes?.(),
1455
+ component.getVtScrollBarRes?.(),
1456
+ component.getHzScrollBarRes?.(),
1457
+ component.getSound?.(),
1458
+ ]) {
1459
+ addDependencyPackageIdFromUiValue(ref);
1460
+ }
1461
+ for (const transition of component.listTransitions?.() ?? []) {
1462
+ for (const item of transition.listItems?.() ?? []) {
1463
+ addDependencyPackageIdsFromUnknown(item.getStartValue?.());
1464
+ addDependencyPackageIdsFromUnknown(item.getEndValue?.());
1465
+ }
1077
1466
  }
1078
1467
  }
1079
1468
 
@@ -1082,7 +1471,12 @@ function _computeDependencies(pkg: Package, pkgMap: Map<string, Package>): void
1082
1471
  }
1083
1472
 
1084
1473
  if (referencedPkgIds.size > 0) {
1085
- const sortedIds = [...referencedPkgIds].sort((a, b) => a.localeCompare(b));
1474
+ const sortedIds = [...referencedPkgIds].sort((a, b) => {
1475
+ const orderA = packageOrder.get(a) ?? Number.MAX_SAFE_INTEGER;
1476
+ const orderB = packageOrder.get(b) ?? Number.MAX_SAFE_INTEGER;
1477
+ if (orderA !== orderB) return orderA - orderB;
1478
+ return a.localeCompare(b);
1479
+ });
1086
1480
  for (const refId of sortedIds) {
1087
1481
  const depPkg = pkgMap.get(refId);
1088
1482
  if (depPkg) {