@openfairygui/functions 0.2.0-alpha.2 → 0.2.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +48 -6
  2. package/dist/atlas-C6tbl7nn.d.ts +193 -0
  3. package/dist/atlas-CHsu2Y8i.d.cts +193 -0
  4. package/dist/index.cjs +16 -3603
  5. package/dist/index.d.cts +5 -294
  6. package/dist/index.d.ts +5 -294
  7. package/dist/index.js +3 -3594
  8. package/dist/node.cjs +256 -0
  9. package/dist/node.d.cts +36 -0
  10. package/dist/node.d.ts +36 -0
  11. package/dist/node.js +254 -0
  12. package/dist/publish-BJ_eelME.js +3267 -0
  13. package/dist/publish-xFWT9Slz.cjs +3338 -0
  14. package/dist/restore-BW2xacB3.cjs +936 -0
  15. package/dist/restore-BeWaJNjR.d.cts +288 -0
  16. package/dist/restore-Clk62n0O.js +931 -0
  17. package/dist/restore-Dh0-Nvms.d.ts +288 -0
  18. package/dist/uam-transaction.cjs +44 -1
  19. package/dist/uam-transaction.d.cts +16 -1
  20. package/dist/uam-transaction.d.ts +16 -1
  21. package/dist/uam-transaction.js +44 -1
  22. package/dist/web.cjs +274 -0
  23. package/dist/web.d.cts +41 -0
  24. package/dist/web.d.ts +41 -0
  25. package/dist/web.js +273 -0
  26. package/package.json +28 -4
  27. package/src/adapters/node/plugins.ts +82 -0
  28. package/src/adapters/node/publish.ts +130 -0
  29. package/src/adapters/node/restore.ts +187 -0
  30. package/src/adapters/web/publish.ts +159 -0
  31. package/src/adapters/web/raster.ts +251 -0
  32. package/src/atlas/font.ts +95 -0
  33. package/src/atlas/inputs.ts +515 -0
  34. package/src/atlas/jta.ts +211 -0
  35. package/src/atlas/packing.ts +767 -0
  36. package/src/atlas.ts +116 -1221
  37. package/src/codegen.ts +106 -67
  38. package/src/index.ts +43 -3
  39. package/src/node.ts +8 -0
  40. package/src/plugins/types.ts +56 -0
  41. package/src/publish/contracts.ts +80 -0
  42. package/src/publish/external-resources.ts +117 -0
  43. package/src/publish/options.ts +180 -0
  44. package/src/publish/package-context.ts +608 -0
  45. package/src/publish/resource-references.ts +210 -0
  46. package/src/publish.ts +290 -968
  47. package/src/restore-internals/font.ts +100 -0
  48. package/src/restore-internals/movie-clip.ts +104 -0
  49. package/src/restore-internals/output-transaction.ts +164 -0
  50. package/src/restore.ts +112 -311
  51. package/src/shared-types.ts +4 -8
  52. package/src/uam-transaction.ts +68 -0
  53. package/src/utils.ts +28 -0
  54. package/src/web.ts +11 -0
package/src/restore.ts CHANGED
@@ -3,10 +3,22 @@ import {
3
3
  type Document,
4
4
  type FileSystem,
5
5
  generateId,
6
+ type Package,
6
7
  ProjectType,
7
8
  ProjectWriter,
8
- type Package,
9
9
  } from '@openfairygui/core';
10
+ import {
11
+ assertRestoreOutputDir,
12
+ basename,
13
+ commitRestoreOutput,
14
+ createRestoreStagingDir,
15
+ isPathWithin,
16
+ normalizeRestoreOutputDir,
17
+ resolveOutputProjectPath,
18
+ trimTrailingSlashes,
19
+ } from './restore-internals/output-transaction.js';
20
+ import { serializeFont, type RestorableFontGlyph } from './restore-internals/font.js';
21
+ import { serializeMovieClip, type RestorableMovieFrame } from './restore-internals/movie-clip.js';
10
22
 
11
23
  export interface RestoreImageCropInput {
12
24
  sourcePath: string;
@@ -45,7 +57,8 @@ export interface RestoreFileSystem extends Pick<FileSystem, 'readFile' | 'readFi
45
57
  readdir(path: string): Promise<string[]>;
46
58
  isFile(path: string): Promise<boolean>;
47
59
  resolvePath(path: string): string | Promise<string>;
48
- rm?: (path: string, options?: { recursive?: boolean; force?: boolean }) => Promise<void>;
60
+ rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
61
+ rename(from: string, to: string): Promise<void>;
49
62
  }
50
63
 
51
64
  export interface RestoreOptions {
@@ -119,29 +132,6 @@ interface RestorableSprite {
119
132
  getOriginalHeight(): number;
120
133
  }
121
134
 
122
- interface RestorableMovieFrame {
123
- getRectX(): number;
124
- getRectY(): number;
125
- getRectWidth(): number;
126
- getRectHeight(): number;
127
- getAddDelay(): number;
128
- getSpriteId(): string;
129
- }
130
-
131
- interface RestorableFontGlyph {
132
- getAdvance(): number;
133
- getChannel(): number;
134
- getChar(): string;
135
- getCharId(): number;
136
- getHeight(): number;
137
- getImg(): string;
138
- getWidth(): number;
139
- getX(): number;
140
- getXOffset(): number;
141
- getY(): number;
142
- getYOffset(): number;
143
- }
144
-
145
135
  type RestorableFontResource = RestorableResource & {
146
136
  listGlyphs(): RestorableFontGlyph[];
147
137
  getBranch?(): string;
@@ -163,9 +153,6 @@ interface RestorableDisplayObject {
163
153
  setFont?(font: string): unknown;
164
154
  }
165
155
 
166
- const JTA_FILE_MARK = 'yytou';
167
- const JTA_VERSION = 102;
168
- const JTA_DEFAULT_FPS = 24;
169
156
  const TRANSPARENT_PNG_1X1 = Uint8Array.from([
170
157
  137, 80, 78, 71, 13, 10, 26, 10,
171
158
  0, 0, 0, 13, 73, 72, 68, 82,
@@ -178,10 +165,23 @@ const TRANSPARENT_PNG_1X1 = Uint8Array.from([
178
165
  66, 96, 130,
179
166
  ]);
180
167
 
168
+ function assertSafeRestoreSegment(value: string, label: string): void {
169
+ if (!value || value === '.' || value === '..' || value.includes('\0') || /[\\/:]/u.test(value)) {
170
+ throw new Error(`restore: Invalid ${label} "${value}".`);
171
+ }
172
+ }
173
+
181
174
  function normalizeVirtualPath(path: string | undefined): string {
182
- const normalized = (path ?? '').replace(/\\/g, '/').trim();
183
- if (!normalized || normalized === '/') return '';
184
- return normalized.replace(/^\/+/, '').replace(/\/+$/, '');
175
+ const raw = (path ?? '').trim();
176
+ if (!raw || raw === '/') return '';
177
+ if (raw.includes('\0') || raw.startsWith('\\') || raw.startsWith('//') || /^[a-z]:/iu.test(raw)) {
178
+ throw new Error(`restore: Invalid resource path "${raw}".`);
179
+ }
180
+ const segments = raw.replace(/\\/g, '/').split('/').filter(Boolean);
181
+ if (segments.some((segment) => segment === '.' || segment === '..' || segment.includes(':'))) {
182
+ throw new Error(`restore: Invalid resource path "${raw}".`);
183
+ }
184
+ return segments.join('/');
185
185
  }
186
186
 
187
187
  function resourceFileName(resource: RestorableResource): string {
@@ -338,59 +338,6 @@ function findImageResource(pkg: Package, itemId: string): RestorableResource | n
338
338
  }) ?? null;
339
339
  }
340
340
 
341
- function fontGlyphCharId(glyph: RestorableFontGlyph): number {
342
- const charId = glyph.getCharId();
343
- if (charId > 0) return charId;
344
- const char = glyph.getChar();
345
- return char ? (char.codePointAt(0) ?? 0) : 0;
346
- }
347
-
348
- function scaledFrameDelay(milliseconds: number): number {
349
- return milliseconds <= 0 ? 0 : Math.max(1, Math.round(milliseconds / (1000 / JTA_DEFAULT_FPS)));
350
- }
351
-
352
- function jtaSpeed(interval: number): number {
353
- return interval <= 0 ? 1 : Math.max(1, Math.round(interval / (1000 / JTA_DEFAULT_FPS)));
354
- }
355
-
356
- function writeInt16(value: number): Uint8Array {
357
- const data = new Uint8Array(2);
358
- new DataView(data.buffer).setInt16(0, value);
359
- return data;
360
- }
361
-
362
- function writeUint16(value: number): Uint8Array {
363
- const data = new Uint8Array(2);
364
- new DataView(data.buffer).setUint16(0, value);
365
- return data;
366
- }
367
-
368
- function writeInt32(value: number): Uint8Array {
369
- const data = new Uint8Array(4);
370
- new DataView(data.buffer).setInt32(0, value);
371
- return data;
372
- }
373
-
374
- function writeByte(value: number): Uint8Array {
375
- return new Uint8Array([value & 0xff]);
376
- }
377
-
378
- function concatBytes(chunks: Uint8Array[]): Uint8Array {
379
- const length = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
380
- const data = new Uint8Array(length);
381
- let offset = 0;
382
- for (const chunk of chunks) {
383
- data.set(chunk, offset);
384
- offset += chunk.byteLength;
385
- }
386
- return data;
387
- }
388
-
389
- function encodeJtaUtf(value: string): Uint8Array {
390
- const bytes = new TextEncoder().encode(value);
391
- return concatBytes([writeUint16(bytes.byteLength), bytes]);
392
- }
393
-
394
341
  function isPublishedBinaryFile(fileName: string): boolean {
395
342
  return /_fui\.bytes$/i.test(fileName) || /\.fui$/i.test(fileName) || /\.bin$/i.test(fileName);
396
343
  }
@@ -401,135 +348,18 @@ function inferPackageName(fileName: string): string {
401
348
  return fileName.replace(/\.bin$/i, '');
402
349
  }
403
350
 
404
- function trimTrailingSlashes(value: string): string {
405
- return value.replace(/[/\\]+$/, '');
406
- }
407
-
408
- function normalizeComparablePath(value: string): string {
409
- const normalized = trimTrailingSlashes(value).replace(/\\/g, '/');
410
- const driveMatch = normalized.match(/^([a-z]:)(?:\/(.*))?$/i);
411
- const drivePrefix = driveMatch?.[1].toLowerCase() ?? '';
412
- const remainder = driveMatch ? (driveMatch[2] ?? '') : normalized;
413
- const hasRoot = driveMatch ? true : remainder.startsWith('/');
414
- const rawSegments = remainder.split('/').filter((segment) => segment.length > 0);
415
- const segments: string[] = [];
416
-
417
- for (const segment of rawSegments) {
418
- if (segment === '.') continue;
419
- if (segment === '..') {
420
- if (segments.length > 0 && segments[segments.length - 1] !== '..') {
421
- segments.pop();
422
- } else if (!hasRoot) {
423
- segments.push('..');
424
- }
425
- continue;
426
- }
427
- segments.push(segment);
428
- }
429
-
430
- const joined = segments.join('/');
431
- const comparable = drivePrefix
432
- ? `${drivePrefix}/${joined}`.replace(/\/$/, '')
433
- : hasRoot
434
- ? `/${joined}`.replace(/\/$/, '')
435
- : joined || '.';
436
- // Restore prefers a conservative same-directory guard: false positives are safer than
437
- // missing a Windows-style case-only path alias and deleting the source publish dir.
438
- return comparable.toLowerCase();
439
- }
440
-
441
- function dirname(filePath: string): string {
442
- const trimmed = trimTrailingSlashes(filePath);
443
- const match = trimmed.match(/^(.*)[/\\][^/\\]+$/);
444
- return match?.[1] ?? '';
445
- }
446
-
447
- function basename(filePath: string): string {
448
- const trimmed = trimTrailingSlashes(filePath);
449
- const match = trimmed.match(/([^/\\]+)$/);
450
- return match?.[1] ?? '';
451
- }
452
-
453
- function resolveOutputProjectPath(output: string, fs: Pick<RestoreFileSystem, 'join'>): string {
454
- if (/\.fairy$/i.test(output)) return output;
455
- const normalizedOutput = trimTrailingSlashes(output);
456
- const projectName = basename(normalizedOutput) || 'Restored';
457
- return fs.join(normalizedOutput, `${projectName}.fairy`);
458
- }
459
-
460
- async function prepareRestoreOutputDir(
461
- inputDir: string,
462
- outputDir: string,
463
- outputProjectPath: string,
464
- fs: RestoreFileSystem,
465
- force: boolean,
466
- outputIsProjectFile: boolean,
467
- ): Promise<void> {
468
- const [resolvedInputDir, resolvedOutputDir] = await Promise.all([
469
- Promise.resolve(fs.resolvePath(inputDir)),
470
- Promise.resolve(fs.resolvePath(outputDir)),
471
- ]);
472
- if (normalizeComparablePath(resolvedInputDir) === normalizeComparablePath(resolvedOutputDir)) {
473
- throw new Error('Restore output directory must be different from the published input directory.');
474
- }
475
-
476
- if (outputIsProjectFile) {
477
- if (!(await fs.exists(outputDir))) {
478
- await fs.mkdir(outputDir);
479
- return;
480
- }
481
- try {
482
- await fs.readdir(outputDir);
483
- } catch {
484
- throw new Error(`Restore output path is not a directory: ${outputDir}`);
485
- }
486
-
487
- if (!(await fs.exists(outputProjectPath))) return;
488
- if (!force) {
489
- throw new Error(`Restore output file already exists: ${outputProjectPath}. Use --force to overwrite it.`);
490
- }
491
- if (!fs.rm) {
492
- throw new Error('Restore output file already exists and the provided fs does not support rm(...).');
493
- }
494
- await fs.rm(outputProjectPath, { recursive: true, force: true });
495
- return;
496
- }
497
-
498
- const exists = await fs.exists(outputDir);
499
- if (!exists) {
500
- await fs.mkdir(outputDir);
501
- return;
502
- }
503
-
504
- let entries: string[];
505
- try {
506
- entries = await fs.readdir(outputDir);
507
- } catch {
508
- throw new Error(`Restore output path is not a directory: ${outputDir}`);
509
- }
510
-
511
- if (entries.length === 0) return;
512
- if (!force) {
513
- throw new Error(`Restore output directory is not empty: ${outputDir}. Use --force to overwrite it.`);
514
- }
515
- if (!fs.rm) {
516
- throw new Error('Restore output directory is not empty and the provided fs does not support rm(...).');
517
- }
518
- await fs.rm(outputDir, { recursive: true, force: true });
519
- await fs.mkdir(outputDir);
520
- }
521
-
522
351
  export async function restore(options: RestoreOptions): Promise<RestoreResult> {
523
352
  const sourceDir = trimTrailingSlashes(options.inputDir);
524
- const outputIsProjectFile = /\.fairy$/i.test(options.output);
525
- const outputProjectPath = resolveOutputProjectPath(options.output, options.fs);
526
- const outputDir = dirname(outputProjectPath) || '.';
527
- await prepareRestoreOutputDir(sourceDir, outputDir, outputProjectPath, options.fs, options.force === true, outputIsProjectFile);
353
+ const outputDir = normalizeRestoreOutputDir(options.output);
354
+ const outputProjectPath = resolveOutputProjectPath(outputDir, options.fs);
355
+ await assertRestoreOutputDir(sourceDir, outputDir, options.fs, options.force === true);
528
356
 
529
357
  const packageFilter = options.packages?.length ? new Set(options.packages) : null;
530
- const candidateBinaryPaths = (await options.fs.readdir(sourceDir))
358
+ const binaryNames = (await options.fs.readdir(sourceDir))
531
359
  .filter((name) => isPublishedBinaryFile(name))
532
- .filter((name) => !packageFilter || packageFilter.has(inferPackageName(name)))
360
+ .filter((name) => !packageFilter || packageFilter.has(inferPackageName(name)));
361
+ for (const binaryName of binaryNames) assertSafeRestoreSegment(binaryName, 'published binary file name');
362
+ const candidateBinaryPaths = binaryNames
533
363
  .map((name) => options.fs.join(sourceDir, name))
534
364
  .sort((left, right) => left.localeCompare(right));
535
365
  const binaryPaths = (await Promise.all(
@@ -543,7 +373,7 @@ export async function restore(options: RestoreOptions): Promise<RestoreResult> {
543
373
  }
544
374
 
545
375
  const restorer = new RestoreWorkflow(options.fs);
546
- return restorer.restore({
376
+ const document = await restorer.prepare({
547
377
  binaryPaths,
548
378
  sourceDir,
549
379
  outputProjectPath,
@@ -551,6 +381,26 @@ export async function restore(options: RestoreOptions): Promise<RestoreResult> {
551
381
  cropImage: options.cropImage,
552
382
  extractImage: options.extractImage,
553
383
  });
384
+ const stagingDir = await createRestoreStagingDir(outputDir, options.fs);
385
+ const stagingProjectPath = options.fs.join(stagingDir, basename(outputProjectPath));
386
+ const warnings: string[] = [];
387
+ try {
388
+ await restorer.write(document, {
389
+ binaryPaths,
390
+ sourceDir,
391
+ outputProjectPath: stagingProjectPath,
392
+ projectType: options.projectType,
393
+ cropImage: options.cropImage,
394
+ extractImage: options.extractImage,
395
+ }, warnings);
396
+ const cleanupWarning = await commitRestoreOutput(stagingDir, outputDir, options.fs);
397
+ if (cleanupWarning) warnings.push(cleanupWarning);
398
+ } catch (error) {
399
+ await options.fs.rm(stagingDir, { recursive: true, force: true }).catch(() => undefined);
400
+ throw error;
401
+ }
402
+
403
+ return { document, projectPath: outputProjectPath, warnings };
554
404
  }
555
405
 
556
406
  class RestoreWorkflow {
@@ -560,13 +410,14 @@ class RestoreWorkflow {
560
410
  this._fs = fs;
561
411
  }
562
412
 
563
- async restore(options: RestoreExecutionOptions): Promise<RestoreResult> {
564
- const warnings: string[] = [];
413
+ async prepare(options: RestoreExecutionOptions): Promise<Document> {
565
414
  const reader = new BinaryReader(this._fs);
566
415
  const doc = await reader.readMany(options.binaryPaths);
416
+ this._assertDocumentPaths(doc);
567
417
  this._initializeProjectDefaults(doc, options.projectType);
568
418
  this._initializeImageFileNames(doc);
569
419
  this._initializeLooseResourceFileNames(doc);
420
+ this._assertDocumentPaths(doc);
570
421
  await this._synthesizeLooseSkeletonResources(doc, options.sourceDir);
571
422
  this._initializeRestoredResourceRelations(doc);
572
423
  this._initializePublishedFontTextureIds(doc);
@@ -575,16 +426,30 @@ class RestoreWorkflow {
575
426
  this._initializePublishedTextFontResources(doc);
576
427
  this._initializeDisplayObjectFileNames(doc);
577
428
  this._initializePublishedFontDefaults(doc);
429
+ this._assertDocumentPaths(doc);
430
+ return doc;
431
+ }
578
432
 
433
+ async write(doc: Document, options: RestoreExecutionOptions, warnings: string[]): Promise<void> {
579
434
  const writer = new ProjectWriter(this._fs);
580
435
  await writer.write(doc, options.outputProjectPath);
581
436
  await this._restoreAssets(doc, options, warnings);
437
+ }
582
438
 
583
- return {
584
- document: doc,
585
- projectPath: options.outputProjectPath,
586
- warnings,
587
- };
439
+ private _assertDocumentPaths(doc: Document): void {
440
+ for (const pkg of doc.getRoot().listPackages()) {
441
+ assertSafeRestoreSegment(pkg.getName(), 'package name');
442
+ assertSafeRestoreSegment(pkg.getPublishName() || pkg.getName(), 'package publish name');
443
+ for (const resource of pkg.listResources() as RestorableResource[]) {
444
+ normalizeVirtualPath(resource.getPath?.());
445
+ const branch = resource.getBranch?.() ?? '';
446
+ if (branch) assertSafeRestoreSegment(branch, 'branch name');
447
+ const fileName = resourceFileName(resource);
448
+ if (fileName) assertSafeRestoreSegment(fileName, 'resource file name');
449
+ const publishedFileName = resourcePublishedFileName(resource);
450
+ if (publishedFileName) assertSafeRestoreSegment(publishedFileName, 'published resource file name');
451
+ }
452
+ }
588
453
  }
589
454
 
590
455
  private _initializeProjectDefaults(doc: Document, projectType?: number): void {
@@ -757,9 +622,16 @@ class RestoreWorkflow {
757
622
  ): Promise<RestorableResource | null> {
758
623
  const resources = pkg.listResources() as RestorableResource[];
759
624
  const existing = this._findResourceByFile(resources, owner, 'ImageResource', fileName);
760
- if (existing) return existing;
761
625
  const sourcePath = await this._resolveLooseSourceFile(pkg, sourceDir, fileName);
762
- if (!sourcePath) return null;
626
+ if (!sourcePath) return existing ?? null;
627
+ if (existing) {
628
+ existing.setExtras?.({
629
+ ...(existing.getExtras?.() ?? {}),
630
+ _publishedFile: fileBaseName(sourcePath),
631
+ _restoreAsLooseImage: true,
632
+ });
633
+ return existing;
634
+ }
763
635
  const resource = doc.createImageResource(stripExtension(fileName));
764
636
  resource
765
637
  .setId(generateId())
@@ -772,7 +644,7 @@ class RestoreWorkflow {
772
644
  ...(resource.getExtras?.() ?? {}),
773
645
  _publishedFile: fileBaseName(sourcePath),
774
646
  _suppressPackageSize: true,
775
- _syntheticLooseImage: true,
647
+ _restoreAsLooseImage: true,
776
648
  });
777
649
  pkg.addResource(resource);
778
650
  return resource as RestorableResource;
@@ -1022,8 +894,8 @@ class RestoreWorkflow {
1022
894
  warnings: string[],
1023
895
  ): Promise<void> {
1024
896
  for (const resource of pkg.listResources() as RestorableResource[]) {
1025
- const syntheticLooseImage = resource.getExtras?.()?._syntheticLooseImage === true;
1026
- if (!['SoundResource', 'MiscResource', 'SpineResource', 'DragonBonesResource'].includes(resource.propertyType) && !syntheticLooseImage) {
897
+ const restoreAsLooseImage = resource.getExtras?.()?._restoreAsLooseImage === true;
898
+ if (!['SoundResource', 'MiscResource', 'SpineResource', 'DragonBonesResource'].includes(resource.propertyType) && !restoreAsLooseImage) {
1027
899
  continue;
1028
900
  }
1029
901
  const fileName = resourceFileName(resource);
@@ -1065,52 +937,7 @@ class RestoreWorkflow {
1065
937
 
1066
938
  const outputPath = this._resourceOutputPath(outputProjectPath, pkg, resource, fileName);
1067
939
  await this._mkdirForFile(outputPath);
1068
- await this._fs.writeFile(outputPath, this._serializeFont(pkg, resource, glyphs));
1069
- }
1070
-
1071
- private _serializeFont(pkg: Package, resource: RestorableResource, glyphs: RestorableFontGlyph[]): string {
1072
- const isTtf = resource.getTtf?.() === true;
1073
- const lines = isTtf
1074
- ? this._serializeTtfFontHeader(pkg, resource, glyphs)
1075
- : [
1076
- 'info creator=UIBuilder',
1077
- `common lineHeight=${resource.getLineHeight?.() ?? 0}`,
1078
- ];
1079
-
1080
- for (const glyph of glyphs) {
1081
- const charId = fontGlyphCharId(glyph);
1082
- if (isTtf) {
1083
- lines.push(
1084
- `char id=${charId} x=${glyph.getX()} y=${glyph.getY()} width=${glyph.getWidth()} height=${glyph.getHeight()} `
1085
- + `xoffset=${glyph.getXOffset()} yoffset=${glyph.getYOffset()} xadvance=${glyph.getAdvance()} page=0 chnl=${glyph.getChannel()}`,
1086
- );
1087
- } else {
1088
- lines.push(
1089
- `char id=${charId} img=${glyph.getImg()} xoffset=${glyph.getXOffset()} yoffset=${glyph.getYOffset()} xadvance=${glyph.getAdvance()}`,
1090
- );
1091
- }
1092
- }
1093
-
1094
- return `${lines.join('\n')}\n`;
1095
- }
1096
-
1097
- private _serializeTtfFontHeader(pkg: Package, resource: RestorableResource, glyphs: RestorableFontGlyph[]): string[] {
1098
- const fileName = resourceFileName(resource);
1099
- const face = stripExtension(fileName) || resource.getName?.() || 'Font';
1100
- const lineHeight = resource.getLineHeight?.() ?? 0;
1101
- const fontSize = resource.getFontSize?.() ?? lineHeight;
1102
- const textureId = resource.getTextureId?.() ?? '';
1103
- const textureResource = textureId ? pkg.getResourceById(textureId) as RestorableResource | null : null;
1104
- const textureName = textureResource ? resourceFileName(textureResource) : `${face}_atlas.png`;
1105
- const scaleW = textureResource?.getWidth?.() ?? 256;
1106
- const scaleH = textureResource?.getHeight?.() ?? 256;
1107
- const base = Math.max(Math.min(fontSize, lineHeight) - 6, 0);
1108
- return [
1109
- `info face="${face}" size=${fontSize} bold=0 italic=0 charset="" unicode=1 stretchH=100 smooth=1 aa=1 padding=0,0,0,0 spacing=1,1 outline=0`,
1110
- `common lineHeight=${lineHeight} base=${base} scaleW=${scaleW} scaleH=${scaleH} pages=1 packed=0 alphaChnl=${resource.getTint?.() ? 1 : 0} redChnl=0 greenChnl=0 blueChnl=0`,
1111
- `page id=0 file="${textureName}"`,
1112
- `chars count=${glyphs.length}`,
1113
- ];
940
+ await this._fs.writeFile(outputPath, serializeFont(pkg, resource, glyphs));
1114
941
  }
1115
942
 
1116
943
  private async _writeMovieClipFile(
@@ -1157,7 +984,7 @@ class RestoreWorkflow {
1157
984
 
1158
985
  const outputPath = this._resourceOutputPath(options.outputProjectPath, pkg, resource, fileName);
1159
986
  await this._mkdirForFile(outputPath);
1160
- await this._fs.writeFileRaw(outputPath, this._serializeMovieClip(resource, frames, textures));
987
+ await this._fs.writeFileRaw(outputPath, serializeMovieClip(resource, frames, textures));
1161
988
  }
1162
989
 
1163
990
  private async _writeSyntheticFontGlyphImages(pkg: Package, outputProjectPath: string): Promise<void> {
@@ -1187,55 +1014,19 @@ class RestoreWorkflow {
1187
1014
  return sprites;
1188
1015
  }
1189
1016
 
1190
- private _serializeMovieClip(
1191
- resource: RestorableResource,
1192
- frames: RestorableMovieFrame[],
1193
- textures: Uint8Array[],
1194
- ): Uint8Array {
1195
- const chunks: Uint8Array[] = [
1196
- encodeJtaUtf(JTA_FILE_MARK),
1197
- writeInt32(JTA_VERSION),
1198
- writeByte(0),
1199
- writeByte(0),
1200
- writeByte(0),
1201
- writeByte(0),
1202
- writeUint16(0),
1203
- writeUint16(0),
1204
- writeUint16(resource.getWidth?.() ?? 0),
1205
- writeUint16(resource.getHeight?.() ?? 0),
1206
- writeByte(jtaSpeed(resource.getInterval?.() ?? 0)),
1207
- writeByte(scaledFrameDelay(resource.getRepeatDelay?.() ?? 0)),
1208
- writeByte(resource.getSwing?.() ? 1 : 0),
1209
- writeInt16(frames.length),
1210
- ];
1211
-
1212
- for (const [index, frame] of frames.entries()) {
1213
- chunks.push(
1214
- writeInt16(scaledFrameDelay(frame.getAddDelay())),
1215
- writeInt16(frame.getRectX()),
1216
- writeInt16(frame.getRectY()),
1217
- writeInt16(frame.getRectWidth()),
1218
- writeInt16(frame.getRectHeight()),
1219
- writeInt16(textures[index]?.byteLength === 0 ? -1 : index),
1220
- );
1221
- }
1222
-
1223
- chunks.push(writeInt16(textures.length));
1224
- for (const texture of textures) {
1225
- chunks.push(writeInt32(texture.byteLength), texture);
1226
- }
1227
-
1228
- return concatBytes(chunks);
1229
- }
1230
-
1231
1017
  private _sourceFileCandidates(pkg: Package, fileName: string, outputFileName = fileName): string[] {
1232
1018
  const publishName = pkg.getPublishName() || pkg.getName();
1233
- return Array.from(new Set([
1019
+ assertSafeRestoreSegment(publishName, 'package publish name');
1020
+ assertSafeRestoreSegment(fileName, 'published source file name');
1021
+ assertSafeRestoreSegment(outputFileName, 'published source file name');
1022
+ const candidates = Array.from(new Set([
1234
1023
  `${publishName}_${fileName}`,
1235
1024
  fileName,
1236
1025
  `${publishName}_${outputFileName}`,
1237
1026
  outputFileName,
1238
1027
  ]));
1028
+ for (const candidate of candidates) assertSafeRestoreSegment(candidate, 'published source file name');
1029
+ return candidates;
1239
1030
  }
1240
1031
 
1241
1032
  private async _resolveLooseSourceFile(pkg: Package, sourceDir: string, outputFileName: string): Promise<string | null> {
@@ -1248,9 +1039,16 @@ class RestoreWorkflow {
1248
1039
  }
1249
1040
 
1250
1041
  private async _resolveSourceFile(sourceDir: string, candidates: string[]): Promise<string | null> {
1042
+ const resolvedSourceDir = await Promise.resolve(this._fs.resolvePath(sourceDir));
1251
1043
  for (const candidate of candidates) {
1044
+ assertSafeRestoreSegment(candidate, 'published source file name');
1252
1045
  const sourcePath = this._fs.join(sourceDir, candidate);
1253
- if (await this._fs.isFile(sourcePath)) return sourcePath;
1046
+ if (!(await this._fs.isFile(sourcePath))) continue;
1047
+ const resolvedSourcePath = await Promise.resolve(this._fs.resolvePath(sourcePath));
1048
+ if (!isPathWithin(resolvedSourceDir, resolvedSourcePath)) {
1049
+ throw new Error(`restore: Published source file resolves outside the input directory: ${candidate}.`);
1050
+ }
1051
+ return resolvedSourcePath;
1254
1052
  }
1255
1053
  return null;
1256
1054
  }
@@ -1263,6 +1061,9 @@ class RestoreWorkflow {
1263
1061
  ): string {
1264
1062
  const basePath = this._fs.dirname(outputProjectPath);
1265
1063
  const branch = resource.getBranch?.() ?? '';
1064
+ assertSafeRestoreSegment(pkg.getName(), 'package name');
1065
+ if (branch) assertSafeRestoreSegment(branch, 'branch name');
1066
+ assertSafeRestoreSegment(fileName, 'resource file name');
1266
1067
  const assetsDir = branch ? `assets_${branch}` : 'assets';
1267
1068
  const virtualPath = normalizeVirtualPath(resource.getPath?.());
1268
1069
  const pkgDir = this._fs.join(basePath, assetsDir, pkg.getName());
@@ -1,4 +1,6 @@
1
- import type { FileSystem, ProjectSettings, PublishSettings } from '@openfairygui/core';
1
+ import type { ProjectSettings, PublishSettings } from '@openfairygui/core';
2
+
3
+ export type { PublishFileSystem } from './publish/contracts.js';
2
4
 
3
5
  export type ExtrasMap = Record<string, unknown>;
4
6
 
@@ -41,6 +43,7 @@ export interface PublishDependency {
41
43
 
42
44
  export interface PackagePublishArtifactsExtras extends ExtrasMap {
43
45
  publishedResourceIds?: string[];
46
+ exportedResourceIds?: string[];
44
47
  publishedIncludeBranches?: boolean;
45
48
  publishedEffectiveResourceIds?: Record<string, string>;
46
49
  }
@@ -56,10 +59,3 @@ export interface HasOptionalSrc {
56
59
  export interface HasOptionalUrl {
57
60
  getUrl?(): string | undefined;
58
61
  }
59
-
60
- export type PublishFileSystem = Pick<FileSystem, 'join' | 'mkdir' | 'writeFileRaw'> & {
61
- deleteFile?: (path: string) => Promise<void>;
62
- exists?: FileSystem['exists'];
63
- readdir?: FileSystem['readdir'];
64
- readFileRaw?: FileSystem['readFileRaw'];
65
- };
@@ -20,8 +20,10 @@ export interface ApplyUamTransactionAppError {
20
20
  opIndex?: number;
21
21
  opId?: string;
22
22
  opKind?: UamTransactionOperation['kind'];
23
+ operationKind?: UamTransactionOperation['kind'];
23
24
  selector?: Record<string, unknown>;
24
25
  issues?: UamValidationIssue[] | UamTransactionSupportIssue[];
26
+ diagnostics: ApplyUamTransactionAppDiagnostic[];
25
27
  }
26
28
 
27
29
  export type ApplyUamTransactionAppResult =
@@ -41,6 +43,70 @@ function mapTransactionErrorStage(error: UamTransactionError): ApplyUamTransacti
41
43
  }
42
44
  }
43
45
 
46
+ export interface ApplyUamTransactionAppDiagnostic {
47
+ code: string;
48
+ message: string;
49
+ severity: 'error';
50
+ path?: string;
51
+ nodeKind?: string;
52
+ resourceKind?: string;
53
+ gearKind?: string;
54
+ field?: string;
55
+ operationKind?: UamTransactionOperation['kind'];
56
+ opIndex?: number;
57
+ opId?: string;
58
+ }
59
+
60
+ function compactDiagnostic(diagnostic: ApplyUamTransactionAppDiagnostic): ApplyUamTransactionAppDiagnostic {
61
+ return Object.fromEntries(Object.entries(diagnostic).filter(([, value]) => value !== undefined)) as ApplyUamTransactionAppDiagnostic;
62
+ }
63
+
64
+ function isTransactionSupportIssue(issue: UamValidationIssue | UamTransactionSupportIssue): issue is UamTransactionSupportIssue {
65
+ return 'code' in issue;
66
+ }
67
+
68
+ function mapTransactionDiagnostics(error: UamTransactionError): ApplyUamTransactionAppDiagnostic[] {
69
+ if (error.issues?.length) {
70
+ return error.issues.map((issue) => {
71
+ if (isTransactionSupportIssue(issue)) {
72
+ return compactDiagnostic({
73
+ code: issue.code,
74
+ message: issue.message,
75
+ severity: 'error' as const,
76
+ path: issue.path,
77
+ nodeKind: issue.nodeKind,
78
+ resourceKind: issue.resourceKind,
79
+ gearKind: issue.gearKind,
80
+ field: issue.field,
81
+ operationKind: issue.operationKind ?? error.opKind,
82
+ opIndex: error.opIndex,
83
+ opId: error.opId,
84
+ });
85
+ }
86
+ return compactDiagnostic({
87
+ code: error.code,
88
+ message: issue.message,
89
+ severity: 'error' as const,
90
+ path: issue.path,
91
+ operationKind: error.opKind,
92
+ opIndex: error.opIndex,
93
+ opId: error.opId,
94
+ });
95
+ });
96
+ }
97
+ return [
98
+ compactDiagnostic({
99
+ code: error.code,
100
+ message: error.message,
101
+ severity: 'error',
102
+ path: error.opIndex === undefined ? undefined : `operations[${error.opIndex}]`,
103
+ operationKind: error.opKind,
104
+ opIndex: error.opIndex,
105
+ opId: error.opId,
106
+ }),
107
+ ];
108
+ }
109
+
44
110
  export function applyUamTransactionApp(input: ApplyUamTransactionAppInput): ApplyUamTransactionAppResult {
45
111
  try {
46
112
  return {
@@ -58,8 +124,10 @@ export function applyUamTransactionApp(input: ApplyUamTransactionAppInput): Appl
58
124
  opIndex: error.opIndex,
59
125
  opId: error.opId,
60
126
  opKind: error.opKind,
127
+ operationKind: error.opKind,
61
128
  selector: error.selector,
62
129
  issues: error.issues,
130
+ diagnostics: mapTransactionDiagnostics(error),
63
131
  },
64
132
  };
65
133
  }