@open-assets/open-assets 0.1.2

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/lib/init.mjs ADDED
@@ -0,0 +1,545 @@
1
+ import { writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import { join, basename } from "path";
3
+ import { createInterface } from "readline";
4
+
5
+ // ── Prompt helpers (zero dependencies) ──────────────────────────────────────
6
+
7
+ function createRL() {
8
+ return createInterface({ input: process.stdin, output: process.stdout });
9
+ }
10
+
11
+ function ask(rl, question, defaultValue) {
12
+ const suffix = defaultValue ? ` (${defaultValue})` : "";
13
+ return new Promise((resolve) => {
14
+ rl.question(` ${question}${suffix}: `, (answer) => {
15
+ resolve(answer.trim() || defaultValue || "");
16
+ });
17
+ });
18
+ }
19
+
20
+ function askConfirm(rl, question, defaultYes = true) {
21
+ const hint = defaultYes ? "Y/n" : "y/N";
22
+ return new Promise((resolve) => {
23
+ rl.question(` ${question} [${hint}]: `, (answer) => {
24
+ const a = answer.trim().toLowerCase();
25
+ if (a === "") resolve(defaultYes);
26
+ else resolve(a === "y" || a === "yes");
27
+ });
28
+ });
29
+ }
30
+
31
+ function askCheckbox(rl, question, options) {
32
+ return new Promise((resolve) => {
33
+ console.log(`\n ${question}\n`);
34
+ options.forEach((opt, i) => {
35
+ console.log(` ${i + 1}) ${opt.label}`);
36
+ });
37
+ console.log();
38
+ rl.question(" Enter numbers separated by commas (e.g. 1,2,3): ", (answer) => {
39
+ const indices = answer
40
+ .split(",")
41
+ .map((s) => parseInt(s.trim(), 10) - 1)
42
+ .filter((i) => i >= 0 && i < options.length);
43
+ resolve(indices.map((i) => options[i].value));
44
+ });
45
+ });
46
+ }
47
+
48
+ function askSelect(rl, question, options) {
49
+ return new Promise((resolve) => {
50
+ console.log(`\n ${question}\n`);
51
+ options.forEach((opt, i) => {
52
+ console.log(` ${i + 1}) ${opt.label}`);
53
+ });
54
+ console.log();
55
+ rl.question(" Enter number: ", (answer) => {
56
+ const idx = parseInt(answer.trim(), 10) - 1;
57
+ if (idx >= 0 && idx < options.length) resolve(options[idx].value);
58
+ else resolve(options[0].value);
59
+ });
60
+ });
61
+ }
62
+
63
+ // ── Asset type presets ──────────────────────────────────────────────────────
64
+
65
+ const ASSET_TYPES = {
66
+ screenshots: {
67
+ label: "App Store Screenshots",
68
+ collection: (platforms) => {
69
+ const exportSizes = [];
70
+
71
+ if (platforms.includes("ios")) {
72
+ exportSizes.push({
73
+ platform: "App Store",
74
+ sizes: [
75
+ { name: "iphone-6.9", label: 'iPhone 6.9"', width: 1320, height: 2868 },
76
+ { name: "iphone-6.7", label: 'iPhone 6.7"', width: 1290, height: 2796 },
77
+ { name: "iphone-6.5", label: 'iPhone 6.5"', width: 1284, height: 2778 },
78
+ { name: "ipad-13", label: 'iPad 13"', width: 2064, height: 2752 },
79
+ ],
80
+ });
81
+ }
82
+
83
+ if (platforms.includes("android")) {
84
+ exportSizes.push({
85
+ platform: "Google Play",
86
+ sizes: [
87
+ { name: "phone", label: "Phone", width: 1080, height: 1920 },
88
+ { name: "tablet-7", label: '7" Tablet', width: 1200, height: 1920 },
89
+ { name: "tablet-10", label: '10" Tablet', width: 1600, height: 2560 },
90
+ ],
91
+ });
92
+ }
93
+
94
+ return {
95
+ id: "screenshots",
96
+ label: "Screenshots",
97
+ tags: ["screenshots", ...platforms],
98
+ sourceSize: { width: 440, height: 956 },
99
+ borderRadius: 4,
100
+ templates: [
101
+ { src: "src/screenshots/01-hero.html", name: "01-hero", label: "Hero" },
102
+ ],
103
+ exportSizes,
104
+ customExport: { defaultWidth: 1320, defaultHeight: 2868 },
105
+ };
106
+ },
107
+ dirs: ["src/screenshots"],
108
+ files: {
109
+ "src/screenshots/01-hero.html": (name) => `<!DOCTYPE html>
110
+ <html lang="en">
111
+ <head>
112
+ <meta charset="UTF-8" />
113
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
114
+ <link rel="stylesheet" href="../dist/styles.css" />
115
+ <style>
116
+ body {
117
+ margin: 0;
118
+ width: 440px;
119
+ height: 956px;
120
+ overflow: hidden;
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
125
+ font-family: -apple-system, system-ui, sans-serif;
126
+ color: white;
127
+ }
128
+ .content {
129
+ text-align: center;
130
+ padding: 40px;
131
+ }
132
+ h1 { font-size: 36px; font-weight: 800; margin-bottom: 16px; }
133
+ p { font-size: 18px; opacity: 0.9; }
134
+ </style>
135
+ </head>
136
+ <body>
137
+ <div class="content">
138
+ <h1>${name}</h1>
139
+ <p>A compelling tagline for your app store listing</p>
140
+ </div>
141
+ </body>
142
+ </html>`,
143
+ },
144
+ },
145
+
146
+ icon: {
147
+ label: "App Icon",
148
+ collection: (platforms) => {
149
+ const exportSizes = [];
150
+
151
+ if (platforms.includes("ios")) {
152
+ exportSizes.push({
153
+ platform: "Apple",
154
+ sizes: [
155
+ { name: "1024", label: "1024px", width: 1024, height: 1024 },
156
+ { name: "180", label: "180px", width: 180, height: 180 },
157
+ { name: "120", label: "120px", width: 120, height: 120 },
158
+ { name: "87", label: "87px", width: 87, height: 87 },
159
+ { name: "80", label: "80px", width: 80, height: 80 },
160
+ { name: "60", label: "60px", width: 60, height: 60 },
161
+ { name: "40", label: "40px", width: 40, height: 40 },
162
+ ],
163
+ });
164
+ }
165
+
166
+ if (platforms.includes("android")) {
167
+ exportSizes.push({
168
+ platform: "Android",
169
+ sizes: [
170
+ { name: "512", label: "Play Store", width: 512, height: 512 },
171
+ { name: "192", label: "xxxhdpi", width: 192, height: 192 },
172
+ { name: "144", label: "xxhdpi", width: 144, height: 144 },
173
+ { name: "96", label: "xhdpi", width: 96, height: 96 },
174
+ { name: "72", label: "hdpi", width: 72, height: 72 },
175
+ { name: "48", label: "mdpi", width: 48, height: 48 },
176
+ ],
177
+ });
178
+ }
179
+
180
+ if (platforms.includes("web")) {
181
+ exportSizes.push({
182
+ platform: "Web",
183
+ sizes: [
184
+ { name: "512", label: "512px", width: 512, height: 512 },
185
+ { name: "192", label: "192px", width: 192, height: 192 },
186
+ { name: "180", label: "Apple Touch", width: 180, height: 180 },
187
+ { name: "32", label: "Favicon 32", width: 32, height: 32 },
188
+ { name: "16", label: "Favicon 16", width: 16, height: 16 },
189
+ ],
190
+ });
191
+ }
192
+
193
+ return {
194
+ id: "icon",
195
+ label: "App Icon",
196
+ tags: ["icon", ...platforms],
197
+ sourceSize: { width: 1024, height: 1024 },
198
+ borderRadius: 224,
199
+ templates: [
200
+ { src: "src/icon.html", name: "icon", label: "App Icon" },
201
+ ],
202
+ exportSizes,
203
+ };
204
+ },
205
+ dirs: [],
206
+ files: {
207
+ "src/icon.html": (name) => `<!DOCTYPE html>
208
+ <html lang="en">
209
+ <head>
210
+ <meta charset="UTF-8" />
211
+ <style>
212
+ body {
213
+ margin: 0;
214
+ width: 1024px;
215
+ height: 1024px;
216
+ overflow: hidden;
217
+ display: flex;
218
+ align-items: center;
219
+ justify-content: center;
220
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
221
+ font-family: -apple-system, system-ui, sans-serif;
222
+ }
223
+ .icon-text {
224
+ font-size: 400px;
225
+ font-weight: 900;
226
+ color: white;
227
+ }
228
+ </style>
229
+ </head>
230
+ <body>
231
+ <div class="icon-text">${name.charAt(0).toUpperCase()}</div>
232
+ </body>
233
+ </html>`,
234
+ },
235
+ },
236
+
237
+ logo: {
238
+ label: "Logo",
239
+ collection: () => ({
240
+ id: "logo",
241
+ label: "Logo",
242
+ tags: ["logo", "branding"],
243
+ sourceSize: { width: 800, height: 800 },
244
+ borderRadius: 0,
245
+ templates: [
246
+ { src: "src/logo.html", name: "logo", label: "Logo" },
247
+ ],
248
+ exportSizes: [
249
+ {
250
+ platform: "General",
251
+ sizes: [
252
+ { name: "800", label: "800px", width: 800, height: 800 },
253
+ { name: "400", label: "400px", width: 400, height: 400 },
254
+ { name: "200", label: "200px", width: 200, height: 200 },
255
+ { name: "64", label: "64px", width: 64, height: 64 },
256
+ ],
257
+ },
258
+ ],
259
+ }),
260
+ dirs: [],
261
+ files: {
262
+ "src/logo.html": (name) => `<!DOCTYPE html>
263
+ <html lang="en">
264
+ <head>
265
+ <meta charset="UTF-8" />
266
+ <style>
267
+ body {
268
+ margin: 0;
269
+ width: 800px;
270
+ height: 800px;
271
+ overflow: hidden;
272
+ display: flex;
273
+ align-items: center;
274
+ justify-content: center;
275
+ background: transparent;
276
+ font-family: -apple-system, system-ui, sans-serif;
277
+ }
278
+ .logo {
279
+ width: 600px;
280
+ height: 600px;
281
+ border-radius: 120px;
282
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
283
+ display: flex;
284
+ align-items: center;
285
+ justify-content: center;
286
+ }
287
+ .logo-text {
288
+ font-size: 280px;
289
+ font-weight: 900;
290
+ color: white;
291
+ }
292
+ </style>
293
+ </head>
294
+ <body>
295
+ <div class="logo">
296
+ <div class="logo-text">${name.charAt(0).toUpperCase()}</div>
297
+ </div>
298
+ </body>
299
+ </html>`,
300
+ },
301
+ },
302
+
303
+ wordmark: {
304
+ label: "Wordmark",
305
+ collection: () => ({
306
+ id: "wordmark",
307
+ label: "Wordmark",
308
+ tags: ["wordmark", "branding"],
309
+ sourceSize: { width: 1200, height: 400 },
310
+ borderRadius: 0,
311
+ templates: [
312
+ { src: "src/wordmark.html", name: "wordmark", label: "Wordmark" },
313
+ ],
314
+ exportSizes: [
315
+ {
316
+ platform: "General",
317
+ sizes: [
318
+ { name: "1200x400", label: "Large", width: 1200, height: 400 },
319
+ { name: "600x200", label: "Medium", width: 600, height: 200 },
320
+ { name: "300x100", label: "Small", width: 300, height: 100 },
321
+ ],
322
+ },
323
+ ],
324
+ }),
325
+ dirs: [],
326
+ files: {
327
+ "src/wordmark.html": (name) => `<!DOCTYPE html>
328
+ <html lang="en">
329
+ <head>
330
+ <meta charset="UTF-8" />
331
+ <style>
332
+ body {
333
+ margin: 0;
334
+ width: 1200px;
335
+ height: 400px;
336
+ overflow: hidden;
337
+ display: flex;
338
+ align-items: center;
339
+ justify-content: center;
340
+ background: transparent;
341
+ font-family: -apple-system, system-ui, sans-serif;
342
+ }
343
+ .wordmark {
344
+ font-size: 120px;
345
+ font-weight: 900;
346
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
347
+ -webkit-background-clip: text;
348
+ -webkit-text-fill-color: transparent;
349
+ background-clip: text;
350
+ }
351
+ </style>
352
+ </head>
353
+ <body>
354
+ <div class="wordmark">${name}</div>
355
+ </body>
356
+ </html>`,
357
+ },
358
+ },
359
+
360
+ "feature-graphic": {
361
+ label: "Feature Graphic (Google Play)",
362
+ collection: () => ({
363
+ id: "feature-graphic",
364
+ label: "Feature Graphic",
365
+ tags: ["feature-graphic", "android"],
366
+ sourceSize: { width: 1024, height: 500 },
367
+ borderRadius: 0,
368
+ templates: [
369
+ { src: "src/feature-graphic.html", name: "feature-graphic", label: "Feature Graphic" },
370
+ ],
371
+ exportSizes: [
372
+ {
373
+ platform: "Google Play",
374
+ sizes: [
375
+ { name: "1024x500", label: "Feature Graphic", width: 1024, height: 500 },
376
+ ],
377
+ },
378
+ ],
379
+ }),
380
+ dirs: [],
381
+ files: {
382
+ "src/feature-graphic.html": (name) => `<!DOCTYPE html>
383
+ <html lang="en">
384
+ <head>
385
+ <meta charset="UTF-8" />
386
+ <style>
387
+ body {
388
+ margin: 0;
389
+ width: 1024px;
390
+ height: 500px;
391
+ overflow: hidden;
392
+ display: flex;
393
+ align-items: center;
394
+ justify-content: center;
395
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
396
+ font-family: -apple-system, system-ui, sans-serif;
397
+ color: white;
398
+ }
399
+ .content { text-align: center; }
400
+ h1 { font-size: 64px; font-weight: 900; margin: 0 0 16px; }
401
+ p { font-size: 24px; opacity: 0.9; margin: 0; }
402
+ </style>
403
+ </head>
404
+ <body>
405
+ <div class="content">
406
+ <h1>${name}</h1>
407
+ <p>Your app tagline goes here</p>
408
+ </div>
409
+ </body>
410
+ </html>`,
411
+ },
412
+ },
413
+ };
414
+
415
+ // ── Main interactive flow ───────────────────────────────────────────────────
416
+
417
+ export async function scaffoldProject(dir) {
418
+ const manifestPath = join(dir, "manifest.json");
419
+
420
+ if (existsSync(manifestPath)) {
421
+ console.error("Error: manifest.json already exists in this directory.");
422
+ process.exit(1);
423
+ }
424
+
425
+ const rl = createRL();
426
+
427
+ console.log("\n Welcome to open-assets!\n");
428
+ console.log(" This will set up your asset project with a manifest.json");
429
+ console.log(" and starter HTML templates.\n");
430
+
431
+ // 1. Project name
432
+ const defaultName = basename(dir) || "My App";
433
+ const projectName = await ask(rl, "Project name", defaultName);
434
+
435
+ // 2. Select asset types
436
+ const assetTypes = await askCheckbox(rl, "Which assets do you need?", [
437
+ { label: "App Store Screenshots", value: "screenshots" },
438
+ { label: "App Icon", value: "icon" },
439
+ { label: "Logo", value: "logo" },
440
+ { label: "Wordmark", value: "wordmark" },
441
+ { label: "Feature Graphic (Google Play)", value: "feature-graphic" },
442
+ ]);
443
+
444
+ if (assetTypes.length === 0) {
445
+ console.log("\n No asset types selected. Aborting.\n");
446
+ rl.close();
447
+ return;
448
+ }
449
+
450
+ // 3. Platform targeting (for screenshots and icon)
451
+ let platforms = [];
452
+ const needsPlatforms = assetTypes.includes("screenshots") || assetTypes.includes("icon");
453
+
454
+ if (needsPlatforms) {
455
+ platforms = await askCheckbox(rl, "Which platforms are you targeting?", [
456
+ { label: "iOS / App Store", value: "ios" },
457
+ { label: "Android / Google Play", value: "android" },
458
+ { label: "Web", value: "web" },
459
+ ]);
460
+
461
+ if (platforms.length === 0) {
462
+ platforms = ["ios"]; // sensible default
463
+ console.log(" Defaulting to iOS.\n");
464
+ }
465
+ }
466
+
467
+ // 4. Confirm
468
+ console.log(`\n Project: ${projectName}`);
469
+ console.log(` Assets: ${assetTypes.map((t) => ASSET_TYPES[t].label).join(", ")}`);
470
+ if (platforms.length > 0) {
471
+ console.log(` Platforms: ${platforms.join(", ")}`);
472
+ }
473
+
474
+ const confirmed = await askConfirm(rl, "\nProceed?");
475
+ rl.close();
476
+
477
+ if (!confirmed) {
478
+ console.log("\n Aborted.\n");
479
+ return;
480
+ }
481
+
482
+ console.log();
483
+
484
+ // ── Generate files ──────────────────────────────────────────────────────
485
+
486
+ // Create base directories
487
+ mkdirSync(join(dir, "src"), { recursive: true });
488
+ mkdirSync(join(dir, "dist"), { recursive: true });
489
+ mkdirSync(join(dir, "public"), { recursive: true });
490
+
491
+ // Build collections and create files for each asset type
492
+ const collections = [];
493
+
494
+ for (const typeKey of assetTypes) {
495
+ const assetType = ASSET_TYPES[typeKey];
496
+
497
+ // Create sub-directories
498
+ for (const subDir of assetType.dirs) {
499
+ mkdirSync(join(dir, subDir), { recursive: true });
500
+ }
501
+
502
+ // Create HTML template files
503
+ for (const [relPath, templateFn] of Object.entries(assetType.files)) {
504
+ const filePath = join(dir, relPath);
505
+ mkdirSync(join(dir, relPath, ".."), { recursive: true });
506
+ writeFileSync(filePath, templateFn(projectName));
507
+ console.log(` Created ${relPath}`);
508
+ }
509
+
510
+ // Build collection config
511
+ const collection = assetType.collection(platforms);
512
+ collections.push(collection);
513
+ }
514
+
515
+ // Collect all unique tags across collections into a top-level registry
516
+ const tagSet = new Set();
517
+ for (const col of collections) {
518
+ for (const t of col.tags || []) tagSet.add(t);
519
+ }
520
+ const tags = [...tagSet].map((id) => ({ id, label: id.charAt(0).toUpperCase() + id.slice(1) }));
521
+
522
+ // Build manifest
523
+ const manifest = {
524
+ version: 1,
525
+ name: `${projectName} Assets`,
526
+ publicDir: "public",
527
+ command: "npx open-assets render --all",
528
+ tags,
529
+ collections,
530
+ };
531
+
532
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
533
+ console.log(" Created manifest.json");
534
+
535
+ // Write .gitignore
536
+ writeFileSync(join(dir, ".gitignore"), "dist/\nexports/\nmanifest.lock\nnode_modules/\n");
537
+ console.log(" Created .gitignore");
538
+
539
+ console.log();
540
+ console.log(" Done! Next steps:");
541
+ console.log(" 1. Edit the HTML files in src/ to design your assets");
542
+ console.log(" 2. Run: open-assets dev");
543
+ console.log(" 3. Open the browser to preview and export");
544
+ console.log();
545
+ }
@@ -0,0 +1,65 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "fs";
2
+ import { createHash } from "crypto";
3
+ import { resolve, join } from "path";
4
+
5
+ const LOCK_FILENAME = "manifest.lock";
6
+
7
+ /**
8
+ * Read the lockfile from disk, or return an empty structure.
9
+ */
10
+ export function readLockfile(projectDir) {
11
+ const lockPath = join(projectDir, LOCK_FILENAME);
12
+ if (!existsSync(lockPath)) {
13
+ return { version: 1, assets: {} };
14
+ }
15
+ try {
16
+ return JSON.parse(readFileSync(lockPath, "utf-8"));
17
+ } catch {
18
+ return { version: 1, assets: {} };
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Write the lockfile to disk as formatted JSON.
24
+ */
25
+ export function writeLockfile(projectDir, lockData) {
26
+ const lockPath = join(projectDir, LOCK_FILENAME);
27
+ writeFileSync(lockPath, JSON.stringify(lockData, null, 2) + "\n");
28
+ }
29
+
30
+ /**
31
+ * Compute SHA256 checksum of a file's contents.
32
+ * Returns "sha256:<hex>" or null if file doesn't exist.
33
+ */
34
+ export function computeChecksum(filePath) {
35
+ if (!existsSync(filePath)) return null;
36
+ const content = readFileSync(filePath);
37
+ const hash = createHash("sha256").update(content).digest("hex");
38
+ return `sha256:${hash}`;
39
+ }
40
+
41
+ /**
42
+ * Check if an asset variant is up-to-date based on its stored checksum.
43
+ * Returns true if the stored checksum matches and the output file exists.
44
+ */
45
+ export function isUpToDate(lockData, assetKey, variantKey, currentChecksum, outputPath) {
46
+ const entry = lockData.assets?.[assetKey]?.[variantKey];
47
+ if (!entry) return false;
48
+ if (entry.sourceChecksum !== currentChecksum) return false;
49
+ if (!existsSync(resolve(outputPath))) return false;
50
+ return true;
51
+ }
52
+
53
+ /**
54
+ * Record an export in the lockfile data (in memory — call writeLockfile to persist).
55
+ */
56
+ export function recordExport(lockData, assetKey, variantKey, checksum, outputPath) {
57
+ if (!lockData.assets[assetKey]) {
58
+ lockData.assets[assetKey] = {};
59
+ }
60
+ lockData.assets[assetKey][variantKey] = {
61
+ sourceChecksum: checksum,
62
+ outputPath,
63
+ exportedAt: new Date().toISOString(),
64
+ };
65
+ }
@@ -0,0 +1,81 @@
1
+ import { launch } from "puppeteer";
2
+ import { resolve } from "path";
3
+ import { writeFileSync, mkdirSync } from "fs";
4
+
5
+ let browser = null;
6
+
7
+ async function getBrowser() {
8
+ if (!browser || !browser.connected) {
9
+ browser = await launch({ headless: true });
10
+ }
11
+ return browser;
12
+ }
13
+
14
+ export async function closeBrowser() {
15
+ if (browser) {
16
+ await browser.close();
17
+ browser = null;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Render an HTML file to a PNG buffer at the given dimensions.
23
+ * Scales from sourceSize to target size via CSS zoom.
24
+ */
25
+ export async function renderScreenshot(
26
+ projectDir,
27
+ htmlFile,
28
+ width,
29
+ height,
30
+ sourceW,
31
+ sourceH
32
+ ) {
33
+ const b = await getBrowser();
34
+ const page = await b.newPage();
35
+
36
+ const scale = Math.max(width / sourceW, height / sourceH);
37
+
38
+ await page.setViewport({ width, height, deviceScaleFactor: 1 });
39
+
40
+ const filePath = resolve(projectDir, htmlFile);
41
+ await page.goto(`file://${filePath}`, { waitUntil: "networkidle0" });
42
+ await new Promise((r) => setTimeout(r, 500));
43
+
44
+ if (scale !== 1) {
45
+ await page.evaluate((z) => {
46
+ document.documentElement.style.zoom = z;
47
+ }, scale);
48
+ await new Promise((r) => setTimeout(r, 200));
49
+ }
50
+
51
+ const buffer = await page.screenshot({
52
+ type: "png",
53
+ clip: { x: 0, y: 0, width, height },
54
+ });
55
+
56
+ await page.close();
57
+ return buffer;
58
+ }
59
+
60
+ /**
61
+ * Run xcode output: render the icon template at 1024x1024 and write to the asset catalog.
62
+ */
63
+ export async function runXcodeOutput(projectDir, collection, outputConfig) {
64
+ const template = collection.templates[0];
65
+ if (!template) {
66
+ throw new Error(`Collection "${collection.id}" has no templates for Xcode output`);
67
+ }
68
+
69
+ const outputDir = resolve(projectDir, outputConfig.path);
70
+ mkdirSync(outputDir, { recursive: true });
71
+ const outputPath = resolve(outputDir, "AppIcon.png");
72
+
73
+ const sw = collection.sourceSize.width;
74
+ const sh = collection.sourceSize.height;
75
+
76
+ const buffer = await renderScreenshot(projectDir, template.src, sw, sh, sw, sh);
77
+ writeFileSync(outputPath, buffer);
78
+
79
+ console.log(`Updated: ${outputPath}`);
80
+ return outputPath;
81
+ }