@open-assets/open-assets 0.1.12 → 0.3.0

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/lockfile.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "fs";
2
2
  import { createHash } from "crypto";
3
- import { resolve, join } from "path";
3
+ import { resolve, join, dirname } from "path";
4
4
 
5
5
  const LOCK_FILENAME = "assets.lock";
6
6
 
@@ -27,25 +27,77 @@ export function writeLockfile(projectDir, lockData) {
27
27
  writeFileSync(lockPath, JSON.stringify(lockData, null, 2) + "\n");
28
28
  }
29
29
 
30
+ /**
31
+ * Extract local file dependencies referenced by an HTML file.
32
+ * Parses src="...", href="...", and url(...) references, filtering out
33
+ * remote URLs and returning only paths that exist on disk.
34
+ * Returns a sorted, deduplicated array of absolute paths.
35
+ */
36
+ export function extractLocalDeps(filePath) {
37
+ if (!existsSync(filePath)) return [];
38
+ const content = readFileSync(filePath, "utf-8");
39
+ const dir = dirname(filePath);
40
+
41
+ const refs = new Set();
42
+ const srcHrefPattern = /(?:src|href)=["']([^"']+)["']/gi;
43
+ const cssUrlPattern = /url\(["']?([^"')]+)["']?\)/gi;
44
+
45
+ for (const pattern of [srcHrefPattern, cssUrlPattern]) {
46
+ let match;
47
+ while ((match = pattern.exec(content)) !== null) {
48
+ refs.add(match[1]);
49
+ }
50
+ }
51
+
52
+ const localPaths = [];
53
+ for (const ref of refs) {
54
+ if (/^(https?:\/\/|data:|\/\/|#)/.test(ref)) continue;
55
+ if (!ref) continue;
56
+ const abs = resolve(dir, ref);
57
+ if (existsSync(abs)) {
58
+ localPaths.push(abs);
59
+ }
60
+ }
61
+
62
+ return [...new Set(localPaths)].sort();
63
+ }
64
+
30
65
  /**
31
66
  * Compute SHA256 checksum of a file's contents.
67
+ * For .html files, includes the content of all local dependencies
68
+ * (images, SVGs, etc.) referenced via src/href/url() attributes.
32
69
  * Returns "sha256:<hex>" or null if file doesn't exist.
33
70
  */
34
71
  export function computeChecksum(filePath) {
35
72
  if (!existsSync(filePath)) return null;
36
73
  const content = readFileSync(filePath);
74
+
75
+ if (filePath.endsWith(".html")) {
76
+ const deps = extractLocalDeps(filePath);
77
+ if (deps.length > 0) {
78
+ const hash = createHash("sha256");
79
+ hash.update(content);
80
+ for (const dep of deps) {
81
+ hash.update("\0");
82
+ hash.update(readFileSync(dep));
83
+ }
84
+ return `sha256:${hash.digest("hex")}`;
85
+ }
86
+ }
87
+
37
88
  const hash = createHash("sha256").update(content).digest("hex");
38
89
  return `sha256:${hash}`;
39
90
  }
40
91
 
41
92
  /**
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.
93
+ * Check if an asset variant is up-to-date based on its stored checksums.
94
+ * Returns true if both source and export config checksums match and the output file exists.
44
95
  */
45
- export function isUpToDate(lockData, assetKey, variantKey, currentChecksum, outputPath) {
96
+ export function isUpToDate(lockData, assetKey, variantKey, currentChecksum, outputPath, exportChecksum) {
46
97
  const entry = lockData.assets?.[assetKey]?.[variantKey];
47
98
  if (!entry) return false;
48
99
  if (entry.sourceChecksum !== currentChecksum) return false;
100
+ if (exportChecksum && entry.exportChecksum !== exportChecksum) return false;
49
101
  if (!existsSync(resolve(outputPath))) return false;
50
102
  return true;
51
103
  }
@@ -53,13 +105,36 @@ export function isUpToDate(lockData, assetKey, variantKey, currentChecksum, outp
53
105
  /**
54
106
  * Record an export in the lockfile data (in memory — call writeLockfile to persist).
55
107
  */
56
- export function recordExport(lockData, assetKey, variantKey, checksum, outputPath) {
108
+ export function recordExport(lockData, assetKey, variantKey, checksum, outputPath, exportChecksum) {
57
109
  if (!lockData.assets[assetKey]) {
58
110
  lockData.assets[assetKey] = {};
59
111
  }
60
- lockData.assets[assetKey][variantKey] = {
112
+ const entry = {
61
113
  sourceChecksum: checksum,
62
114
  outputPath,
63
115
  exportedAt: new Date().toISOString(),
64
116
  };
117
+ if (exportChecksum) {
118
+ entry.exportChecksum = exportChecksum;
119
+ }
120
+ lockData.assets[assetKey][variantKey] = entry;
121
+ }
122
+
123
+ /**
124
+ * Compute a checksum for the export configuration of a variant.
125
+ * Captures size dimensions, outFile path, and locale so that config changes trigger re-export.
126
+ */
127
+ export function computeExportChecksum(entry, locale) {
128
+ const config = {
129
+ width: entry.size.width,
130
+ height: entry.size.height,
131
+ };
132
+ if (entry.outFile) {
133
+ config.outFile = entry.outFile;
134
+ }
135
+ if (locale) {
136
+ config.locale = locale;
137
+ }
138
+ const hash = createHash("sha256").update(JSON.stringify(config)).digest("hex");
139
+ return `sha256:${hash}`;
65
140
  }
package/lib/manifest.mjs CHANGED
@@ -2,10 +2,17 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { resolve } from "path";
3
3
 
4
4
  /**
5
- * Get all export sizes from a collection.
5
+ * Get all export sizes from a collection (entries without a type field).
6
6
  */
7
7
  export function flatSizes(collection) {
8
- return collection.export || [];
8
+ return (collection.export || []).filter((e) => !e.type);
9
+ }
10
+
11
+ /**
12
+ * Get all output actions from a collection (entries with a type field).
13
+ */
14
+ export function flatOutputs(collection) {
15
+ return (collection.export || []).filter((e) => e.type);
9
16
  }
10
17
 
11
18
  /**
@@ -77,7 +84,7 @@ export function filterTemplates(templates, templateName) {
77
84
  /**
78
85
  * Determine which sizes to render for a collection given CLI options.
79
86
  * @param {object} collection - The collection from the config
80
- * @param {{ width?: string|number, height?: string|number, size?: string, all?: boolean, platform?: string }} opts
87
+ * @param {{ width?: string|number, height?: string|number, size?: string, force?: boolean, platform?: string }} opts
81
88
  * @returns {{ sizes: object[], warning?: string }}
82
89
  */
83
90
  export function resolveRenderSizes(collection, opts = {}) {
@@ -109,7 +116,7 @@ export function resolveRenderSizes(collection, opts = {}) {
109
116
  };
110
117
  }
111
118
 
112
- if (opts.all) {
119
+ if (opts.force) {
113
120
  const sizes = flatSizes(collection);
114
121
  return { sizes };
115
122
  }
package/lib/render.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { resolve, join } from "path";
2
2
  import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
3
- import { filterCollections, filterTemplates, resolveRenderSizes } from "./manifest.mjs";
3
+ import { filterCollections, filterTemplates, resolveRenderSizes, flatOutputs } from "./manifest.mjs";
4
+ import { computeExportChecksum } from "./lockfile.mjs";
5
+ import { loadLocalizations, resolveStrings, getLocales, getDirection } from "./localization.mjs";
4
6
 
5
7
  /**
6
8
  * @typedef {object} RenderDeps
@@ -10,8 +12,8 @@ import { filterCollections, filterTemplates, resolveRenderSizes } from "./manife
10
12
  * @property {(projectDir: string) => object} readLockfile
11
13
  * @property {(projectDir: string, data: object) => void} writeLockfile
12
14
  * @property {(filePath: string) => string|null} computeChecksum
13
- * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string) => boolean} isUpToDate
14
- * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string) => void} recordExport
15
+ * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string, exportChecksum?: string) => boolean} isUpToDate
16
+ * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string, exportChecksum?: string) => void} recordExport
15
17
  */
16
18
 
17
19
  /**
@@ -24,11 +26,11 @@ import { filterCollections, filterTemplates, resolveRenderSizes } from "./manife
24
26
  * @param {string} [options.tag] - Filter by tag
25
27
  * @param {string} [options.template] - Filter by template name/label
26
28
  * @param {string} [options.size] - Named export size
29
+ * @param {string} [options.locale] - Filter to a single locale
27
30
  * @param {string} [options.format] - Output format override (png or jpg)
28
31
  * @param {string|number} [options.width] - Custom width
29
32
  * @param {string|number} [options.height] - Custom height
30
- * @param {boolean} [options.all] - Export at all sizes
31
- * @param {boolean} [options.force] - Re-render even if unchanged
33
+ * @param {boolean} [options.force] - Export at all sizes and re-render (ignores cache)
32
34
  * @param {string} [options.output] - Output directory path
33
35
  * @param {boolean} [options.quiet] - Suppress logs
34
36
  * @param {RenderDeps} deps - Injected dependencies
@@ -67,7 +69,7 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
67
69
  width: options.width,
68
70
  height: options.height,
69
71
  size: options.size,
70
- all: options.all,
72
+ force: options.force,
71
73
  });
72
74
 
73
75
  if (warning) {
@@ -80,115 +82,176 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
80
82
  continue;
81
83
  }
82
84
 
83
- const useSubDirs = sizesToRender.length > 1 || options.all;
85
+ // Resolve locales for this collection
86
+ let locales = [null]; // null = no localization
87
+ let localizations = null;
84
88
 
85
- for (const entry of sizesToRender) {
86
- const w = entry.size.width;
87
- const h = entry.size.height;
89
+ if (col.localizations) {
90
+ localizations = loadLocalizations(projectDir, col.localizations);
91
+ locales = getLocales(localizations, col.locales);
92
+ if (options.locale) {
93
+ locales = locales.filter((l) => l === options.locale);
94
+ if (locales.length === 0) {
95
+ _log(` Warning: locale "${options.locale}" not found in collection "${col.id}", skipping`);
96
+ continue;
97
+ }
98
+ }
99
+ }
88
100
 
89
- const sizeDir = useSubDirs
90
- ? join(outputDir, col.id, entry.name)
91
- : join(outputDir, col.id);
92
- mkdirSync(sizeDir, { recursive: true });
101
+ const singleTemplate = templates.length === 1;
102
+ const multipleSizes = sizesToRender.length > 1 || options.force;
103
+ // Single-template collections use flat layout with entry.name as filename
104
+ // Multi-template collections use subdirs per size
105
+ const useSubDirs = multipleSizes && !singleTemplate;
106
+ const hasLocales = locales.length > 0 && locales[0] !== null;
93
107
 
94
- if (sizesToRender.length > 1) {
95
- _log(`\n ${entry.label} (${w}x${h})`);
96
- }
108
+ for (const locale of locales) {
109
+ const strings = locale ? resolveStrings(localizations, locale) : null;
110
+ const direction = locale ? getDirection(locale) : null;
111
+ const localization = locale ? { strings, locale, direction } : undefined;
97
112
 
98
- for (const template of templates) {
99
- // Determine output format from outFile extension or default to png
100
- const outExt = entry.outFile
101
- ? entry.outFile.split(".").pop().toLowerCase()
102
- : "png";
103
- const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
104
- const defaultExt = format === "jpeg" ? "jpg" : "png";
105
-
106
- const outPath = entry.outFile
107
- ? resolve(projectDir, entry.outFile.replace(/\{template\}/g, template.name))
108
- : join(sizeDir, `${template.name}.${defaultExt}`);
109
-
110
- const assetKey = `${col.id}/${template.name}`;
111
- const variantKey = entry.name;
112
- const srcPath = resolve(projectDir, template.src);
113
- const checksum = deps.computeChecksum(srcPath);
114
-
115
- // Skip if unchanged
116
- if (
117
- !options.force &&
118
- checksum &&
119
- deps.isUpToDate(lockData, assetKey, variantKey, checksum, outPath)
120
- ) {
121
- _log(
122
- ` Skipping ${template.name} at ${w}x${h} (unchanged)`
123
- );
124
- skipped++;
125
- continue;
126
- }
113
+ if (locale) {
114
+ _log(`\n Locale: ${locale} (${direction})`);
115
+ }
127
116
 
128
- _log(
129
- ` Rendering ${template.name} at ${w}x${h}...`
130
- );
117
+ for (const entry of sizesToRender) {
118
+ const w = entry.size.width;
119
+ const h = entry.size.height;
131
120
 
132
- const isSvg = template.src.endsWith(".svg");
133
- if (isSvg && w === sourceW && h === sourceH) {
134
- copyFileSync(srcPath, outPath.replace(`.${defaultExt}`, ".svg"));
121
+ // Build output directory: insert locale level when localizations are active
122
+ let sizeDir;
123
+ if (hasLocales) {
124
+ sizeDir = useSubDirs
125
+ ? join(outputDir, col.id, locale, entry.name)
126
+ : join(outputDir, col.id, locale);
127
+ } else {
128
+ sizeDir = useSubDirs
129
+ ? join(outputDir, col.id, entry.name)
130
+ : join(outputDir, col.id);
135
131
  }
132
+ mkdirSync(sizeDir, { recursive: true });
136
133
 
137
- // Ensure output directory exists when using outFile
138
- if (entry.outFile) {
139
- mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
134
+ if (sizesToRender.length > 1) {
135
+ _log(`\n ${entry.label} (${w}x${h})`);
140
136
  }
141
137
 
142
- const buffer = await deps.renderScreenshot(
143
- projectDir,
144
- template.src,
145
- w,
146
- h,
147
- sourceW,
148
- sourceH,
149
- { format }
150
- );
151
- writeFileSync(outPath, buffer);
152
- results.push({
153
- collection: col.id,
154
- template: template.name,
155
- path: outPath,
156
- width: w,
157
- height: h,
158
- size: buffer.length,
159
- });
160
- if (checksum) {
161
- deps.recordExport(lockData, assetKey, variantKey, checksum, outPath);
138
+ for (const template of templates) {
139
+ // Determine output format from outFile extension or default to png
140
+ const outExt = entry.outFile
141
+ ? entry.outFile.split(".").pop().toLowerCase()
142
+ : "png";
143
+ const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
144
+ const defaultExt = format === "jpeg" ? "jpg" : "png";
145
+
146
+ // Single-template: use entry.name as filename (e.g. logo-1024.png)
147
+ // Multi-template: use template.name as filename (e.g. 01-hero.png)
148
+ const fileName = singleTemplate && multipleSizes
149
+ ? `${entry.name}.${defaultExt}`
150
+ : `${template.name}.${defaultExt}`;
151
+
152
+ let outPath;
153
+ if (entry.outFile) {
154
+ let resolved = entry.outFile.replace(/\{template\}/g, template.name);
155
+ if (locale) {
156
+ resolved = resolved.replace(/\{locale\}/g, locale);
157
+ }
158
+ outPath = resolve(projectDir, resolved);
159
+ } else {
160
+ outPath = join(sizeDir, fileName);
161
+ }
162
+
163
+ // Asset key and variant key include locale for cache isolation
164
+ const assetKey = locale
165
+ ? `${col.id}/${locale}/${template.name}`
166
+ : `${col.id}/${template.name}`;
167
+ const variantKey = entry.name;
168
+ const srcPath = resolve(projectDir, template.src);
169
+ const checksum = deps.computeChecksum(srcPath);
170
+ const exportCksum = computeExportChecksum(entry, locale);
171
+
172
+ // Skip if unchanged
173
+ if (
174
+ !options.force &&
175
+ checksum &&
176
+ deps.isUpToDate(lockData, assetKey, variantKey, checksum, outPath, exportCksum)
177
+ ) {
178
+ _log(
179
+ ` Skipping ${template.name}${locale ? ` [${locale}]` : ""} at ${w}x${h} (unchanged)`
180
+ );
181
+ skipped++;
182
+ continue;
183
+ }
184
+
185
+ _log(
186
+ ` Rendering ${template.name}${locale ? ` [${locale}]` : ""} at ${w}x${h}...`
187
+ );
188
+
189
+ const isSvg = template.src.endsWith(".svg");
190
+ if (isSvg && w === sourceW && h === sourceH) {
191
+ copyFileSync(srcPath, outPath.replace(`.${defaultExt}`, ".svg"));
192
+ }
193
+
194
+ // Ensure output directory exists when using outFile
195
+ if (entry.outFile) {
196
+ mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
197
+ }
198
+
199
+ const buffer = await deps.renderScreenshot(
200
+ projectDir,
201
+ template.src,
202
+ w,
203
+ h,
204
+ sourceW,
205
+ sourceH,
206
+ { format, background: col.background, localization }
207
+ );
208
+ writeFileSync(outPath, buffer);
209
+ results.push({
210
+ collection: col.id,
211
+ template: template.name,
212
+ locale: locale || undefined,
213
+ path: outPath,
214
+ width: w,
215
+ height: h,
216
+ size: buffer.length,
217
+ });
218
+ if (checksum) {
219
+ deps.recordExport(lockData, assetKey, variantKey, checksum, outPath, exportCksum);
220
+ }
221
+ _log(` → ${outPath}`);
162
222
  }
163
- _log(` → ${outPath}`);
164
223
  }
165
224
  }
166
225
 
167
226
  // Handle outputs (xcode, copy-source, etc.)
168
- if (col.outputs && options.all) {
169
- for (const output of col.outputs) {
227
+ const outputs = flatOutputs(col);
228
+ if (outputs.length > 0 && options.force) {
229
+ for (const output of outputs) {
170
230
  if (output.type === "xcode") {
171
231
  _log(`\n Exporting to Xcode: ${output.path}`);
172
232
  const path = await deps.runXcodeOutput(projectDir, col, output);
173
233
  results.push({ collection: col.id, type: "xcode", path });
174
234
  } else if (output.type === "copy-source") {
175
235
  const format = output.format || "svg";
176
- const copyDir = join(outputDir, col.id, format);
236
+ const copyDir = join(outputDir, col.id);
177
237
  mkdirSync(copyDir, { recursive: true });
178
- for (const template of templates) {
179
- if (template.src.endsWith(`.${format}`)) {
180
- const srcPath = resolve(projectDir, template.src);
181
- const destPath = join(copyDir, `${template.name}.${format}`);
182
- if (existsSync(srcPath)) {
183
- copyFileSync(srcPath, destPath);
184
- results.push({
185
- collection: col.id,
186
- template: template.name,
187
- type: `copy-${format}`,
188
- path: destPath,
189
- });
190
- _log(` → ${destPath}`);
191
- }
238
+ const matchingTemplates = templates.filter((t) => t.src.endsWith(`.${format}`));
239
+ for (const template of matchingTemplates) {
240
+ const srcPath = resolve(projectDir, template.src);
241
+ // Use entry name when single match, template name when multiple
242
+ const baseName = output.name && matchingTemplates.length === 1
243
+ ? output.name
244
+ : template.name;
245
+ const destPath = join(copyDir, `${baseName}.${format}`);
246
+ if (existsSync(srcPath)) {
247
+ copyFileSync(srcPath, destPath);
248
+ results.push({
249
+ collection: col.id,
250
+ template: template.name,
251
+ type: `copy-${format}`,
252
+ path: destPath,
253
+ });
254
+ _log(` → ${destPath}`);
192
255
  }
193
256
  }
194
257
  }
package/lib/renderer.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { launch } from "puppeteer";
2
2
  import { resolve } from "path";
3
- import { writeFileSync, mkdirSync } from "fs";
3
+ import { writeFileSync, mkdirSync, readdirSync, unlinkSync } from "fs";
4
4
 
5
5
  let browser = null;
6
6
 
@@ -27,7 +27,7 @@ export async function closeBrowser() {
27
27
  * @param {number} height
28
28
  * @param {number} sourceW
29
29
  * @param {number} sourceH
30
- * @param {{ format?: "png"|"jpeg", quality?: number }} [options]
30
+ * @param {{ format?: "png"|"jpeg", quality?: number, background?: string, localization?: { strings: object, locale: string, direction: string } }} [options]
31
31
  */
32
32
  export async function renderScreenshot(
33
33
  projectDir,
@@ -49,6 +49,51 @@ export async function renderScreenshot(
49
49
  await page.goto(`file://${filePath}`, { waitUntil: "networkidle0" });
50
50
  await new Promise((r) => setTimeout(r, 500));
51
51
 
52
+ // Inject localized strings and set lang/dir before rendering
53
+ if (options.localization) {
54
+ const { strings, locale, direction } = options.localization;
55
+ await page.evaluate((strings, locale, dir) => {
56
+ // Set lang on root for font selection; do NOT set dir on root
57
+ // because it flips the entire CSS layout (flexbox, margins, etc.)
58
+ // which breaks fixed-pixel marketing layouts.
59
+ document.documentElement.lang = locale;
60
+
61
+ const formatter = new Intl.NumberFormat(locale);
62
+
63
+ function processValue(value) {
64
+ return value.replace(/\{\{n:(\d+(?:\.\d+)?)\}\}/g, (_, num) => {
65
+ return formatter.format(parseFloat(num));
66
+ });
67
+ }
68
+
69
+ const walker = document.createTreeWalker(
70
+ document.documentElement,
71
+ NodeFilter.SHOW_TEXT
72
+ );
73
+ while (walker.nextNode()) {
74
+ const node = walker.currentNode;
75
+ let text = node.textContent;
76
+ let changed = false;
77
+ text = text.replace(/\{\{([a-zA-Z_][\w.]*)\}\}/g, (match, key) => {
78
+ if (strings[key] !== undefined) {
79
+ changed = true;
80
+ return processValue(strings[key]);
81
+ }
82
+ return match;
83
+ });
84
+ if (changed) {
85
+ node.textContent = text;
86
+ // Apply text direction on the parent element so RTL text
87
+ // renders correctly without flipping the overall page layout.
88
+ if (dir === "rtl") {
89
+ node.parentElement.dir = dir;
90
+ }
91
+ }
92
+ }
93
+ }, strings, locale, direction);
94
+ await new Promise((r) => setTimeout(r, 200));
95
+ }
96
+
52
97
  if (scale !== 1) {
53
98
  await page.evaluate((z) => {
54
99
  document.documentElement.style.zoom = z;
@@ -56,11 +101,22 @@ export async function renderScreenshot(
56
101
  await new Promise((r) => setTimeout(r, 200));
57
102
  }
58
103
 
104
+ // Handle background: transparent by default for PNG, or use specified color
105
+ const bg = options.background;
106
+ if (bg) {
107
+ await page.evaluate((color) => {
108
+ document.documentElement.style.backgroundColor = color;
109
+ }, bg);
110
+ }
111
+
59
112
  const format = options.format || "png";
60
113
  const screenshotOpts = {
61
114
  type: format,
62
115
  clip: { x: 0, y: 0, width, height },
63
116
  };
117
+ if (format === "png" && !bg) {
118
+ screenshotOpts.omitBackground = true;
119
+ }
64
120
  if (format === "jpeg" && options.quality) {
65
121
  screenshotOpts.quality = options.quality;
66
122
  }
@@ -82,14 +138,39 @@ export async function runXcodeOutput(projectDir, collection, outputConfig) {
82
138
 
83
139
  const outputDir = resolve(projectDir, outputConfig.path);
84
140
  mkdirSync(outputDir, { recursive: true });
85
- const outputPath = resolve(outputDir, "AppIcon.png");
86
141
 
87
142
  const sw = collection.sourceSize.width;
88
143
  const sh = collection.sourceSize.height;
144
+ const filename = "AppIcon.png";
145
+ const outputPath = resolve(outputDir, filename);
146
+
147
+ // Remove stale images that don't match the current output filename
148
+ for (const entry of readdirSync(outputDir)) {
149
+ if (entry !== filename && /\.(png|jpg|jpeg)$/i.test(entry)) {
150
+ unlinkSync(resolve(outputDir, entry));
151
+ }
152
+ }
89
153
 
90
154
  const buffer = await renderScreenshot(projectDir, template.src, sw, sh, sw, sh);
91
155
  writeFileSync(outputPath, buffer);
92
156
 
157
+ const contentsJson = {
158
+ images: [
159
+ {
160
+ filename,
161
+ idiom: "universal",
162
+ platform: "ios",
163
+ size: `${sw}x${sh}`,
164
+ },
165
+ ],
166
+ info: {
167
+ author: "xcode",
168
+ version: 1,
169
+ },
170
+ };
171
+ const contentsPath = resolve(outputDir, "Contents.json");
172
+ writeFileSync(contentsPath, JSON.stringify(contentsJson, null, 2) + "\n");
173
+
93
174
  console.log(`Updated: ${outputPath}`);
94
175
  return outputPath;
95
176
  }