@open-assets/open-assets 0.1.11 → 0.1.12

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/add.mjs ADDED
@@ -0,0 +1,476 @@
1
+ import { writeFileSync, mkdirSync } from "fs";
2
+ import { join, dirname } from "path";
3
+ import { createInterface } from "readline";
4
+ import { loadConfig, saveConfig } from "./manifest.mjs";
5
+
6
+ // ── Prompt helpers ──────────────────────────────────────────────────────────
7
+
8
+ function createRL() {
9
+ return createInterface({ input: process.stdin, output: process.stdout });
10
+ }
11
+
12
+ function ask(rl, question, defaultValue) {
13
+ const suffix = defaultValue ? ` (${defaultValue})` : "";
14
+ return new Promise((resolve) => {
15
+ rl.question(` ${question}${suffix}: `, (answer) => {
16
+ resolve(answer.trim() || defaultValue || "");
17
+ });
18
+ });
19
+ }
20
+
21
+ function askSelect(rl, question, options) {
22
+ return new Promise((resolve) => {
23
+ console.log(`\n ${question}\n`);
24
+ options.forEach((opt, i) => {
25
+ console.log(` ${i + 1}) ${opt.label}`);
26
+ });
27
+ console.log();
28
+ rl.question(" Enter number: ", (answer) => {
29
+ const idx = parseInt(answer.trim(), 10) - 1;
30
+ if (idx >= 0 && idx < options.length) resolve(options[idx]);
31
+ else resolve(options[0]);
32
+ });
33
+ });
34
+ }
35
+
36
+ // ── HTML / SVG template generators ──────────────────────────────────────────
37
+
38
+ function generateHTML(name, width, height) {
39
+ return `<!DOCTYPE html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="UTF-8" />
43
+ <style>
44
+ body {
45
+ margin: 0;
46
+ width: ${width}px;
47
+ height: ${height}px;
48
+ overflow: hidden;
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
53
+ font-family: -apple-system, system-ui, sans-serif;
54
+ color: white;
55
+ }
56
+ .content {
57
+ text-align: center;
58
+ padding: 40px;
59
+ }
60
+ h1 { font-size: ${Math.round(width * 0.08)}px; font-weight: 800; margin-bottom: 16px; }
61
+ p { font-size: ${Math.round(width * 0.04)}px; opacity: 0.9; }
62
+ </style>
63
+ </head>
64
+ <body>
65
+ <div class="content">
66
+ <h1>${name}</h1>
67
+ <p>Edit this template to get started</p>
68
+ </div>
69
+ </body>
70
+ </html>`;
71
+ }
72
+
73
+ function generateSVG(name, width, height) {
74
+ const fontSize = Math.round(Math.min(width, height) * 0.08);
75
+ const subFontSize = Math.round(fontSize * 0.5);
76
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
77
+ <defs>
78
+ <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
79
+ <stop offset="0%" stop-color="#667eea" />
80
+ <stop offset="100%" stop-color="#764ba2" />
81
+ </linearGradient>
82
+ </defs>
83
+ <rect width="${width}" height="${height}" fill="url(#bg)" />
84
+ <text x="${width / 2}" y="${height / 2 - subFontSize * 0.5}" text-anchor="middle" dominant-baseline="central"
85
+ fill="white" font-family="-apple-system, system-ui, sans-serif"
86
+ font-size="${fontSize}" font-weight="800">${name}</text>
87
+ <text x="${width / 2}" y="${height / 2 + fontSize * 0.7}" text-anchor="middle" dominant-baseline="central"
88
+ fill="white" opacity="0.9" font-family="-apple-system, system-ui, sans-serif"
89
+ font-size="${subFontSize}">Edit this template to get started</text>
90
+ </svg>`;
91
+ }
92
+
93
+ // ── Collection presets ──────────────────────────────────────────────────────
94
+
95
+ const COLLECTION_PRESETS = [
96
+ {
97
+ value: "screenshots",
98
+ label: "App Store Screenshots (440x956, HTML)",
99
+ format: "html",
100
+ id: "screenshots",
101
+ collectionLabel: "Screenshots",
102
+ sourceSize: { width: 440, height: 956 },
103
+ borderRadius: 4,
104
+ tags: ["screenshots"],
105
+ templateDir: "src/screenshots",
106
+ templateName: "01-hero",
107
+ templateLabel: "Hero",
108
+ export: [
109
+ { name: "iphone-6.9", label: 'iPhone 6.9"', size: { width: 1320, height: 2868 } },
110
+ { name: "iphone-6.7", label: 'iPhone 6.7"', size: { width: 1290, height: 2796 } },
111
+ { name: "iphone-6.5", label: 'iPhone 6.5"', size: { width: 1284, height: 2778 } },
112
+ { name: "ipad-13", label: 'iPad 13"', size: { width: 2064, height: 2752 } },
113
+ ],
114
+ },
115
+ {
116
+ value: "icon",
117
+ label: "App Icon (1024x1024, HTML)",
118
+ format: "html",
119
+ id: "icon",
120
+ collectionLabel: "App Icon",
121
+ sourceSize: { width: 1024, height: 1024 },
122
+ borderRadius: 224,
123
+ tags: ["icon"],
124
+ templateDir: "src",
125
+ templateName: "icon",
126
+ templateLabel: "App Icon",
127
+ export: [
128
+ { name: "1024", label: "1024px", size: { width: 1024, height: 1024 } },
129
+ { name: "180", label: "180px", size: { width: 180, height: 180 } },
130
+ { name: "120", label: "120px", size: { width: 120, height: 120 } },
131
+ { name: "512", label: "512px", size: { width: 512, height: 512 } },
132
+ { name: "192", label: "192px", size: { width: 192, height: 192 } },
133
+ { name: "32", label: "Favicon 32", size: { width: 32, height: 32 } },
134
+ { name: "16", label: "Favicon 16", size: { width: 16, height: 16 } },
135
+ ],
136
+ },
137
+ {
138
+ value: "logo",
139
+ label: "Logo (800x800, SVG)",
140
+ format: "svg",
141
+ id: "logo",
142
+ collectionLabel: "Logo",
143
+ sourceSize: { width: 800, height: 800 },
144
+ borderRadius: 0,
145
+ tags: ["logo", "branding"],
146
+ templateDir: "src",
147
+ templateName: "logo",
148
+ templateLabel: "Logo",
149
+ export: [
150
+ { name: "800", label: "800px", size: { width: 800, height: 800 } },
151
+ { name: "400", label: "400px", size: { width: 400, height: 400 } },
152
+ { name: "200", label: "200px", size: { width: 200, height: 200 } },
153
+ ],
154
+ outputs: [{ type: "copy-source", format: "svg" }],
155
+ },
156
+ {
157
+ value: "wordmark",
158
+ label: "Wordmark (1200x400, SVG)",
159
+ format: "svg",
160
+ id: "wordmark",
161
+ collectionLabel: "Wordmark",
162
+ sourceSize: { width: 1200, height: 400 },
163
+ borderRadius: 0,
164
+ tags: ["wordmark", "branding"],
165
+ templateDir: "src",
166
+ templateName: "wordmark",
167
+ templateLabel: "Wordmark",
168
+ export: [
169
+ { name: "1200x400", label: "Large", size: { width: 1200, height: 400 } },
170
+ { name: "600x200", label: "Medium", size: { width: 600, height: 200 } },
171
+ { name: "300x100", label: "Small", size: { width: 300, height: 100 } },
172
+ ],
173
+ outputs: [{ type: "copy-source", format: "svg" }],
174
+ },
175
+ {
176
+ value: "feature-graphic",
177
+ label: "Feature Graphic / Google Play (1024x500, HTML)",
178
+ format: "html",
179
+ id: "feature-graphic",
180
+ collectionLabel: "Feature Graphic",
181
+ sourceSize: { width: 1024, height: 500 },
182
+ borderRadius: 0,
183
+ tags: ["feature-graphic", "android"],
184
+ templateDir: "src",
185
+ templateName: "feature-graphic",
186
+ templateLabel: "Feature Graphic",
187
+ export: [
188
+ { name: "1024x500", label: "Feature Graphic", size: { width: 1024, height: 500 } },
189
+ ],
190
+ },
191
+ {
192
+ value: "og-image",
193
+ label: "OG Image / Social (1200x630, HTML)",
194
+ format: "html",
195
+ id: "og-image",
196
+ collectionLabel: "OG Image",
197
+ sourceSize: { width: 1200, height: 630 },
198
+ borderRadius: 0,
199
+ tags: ["og-image", "web"],
200
+ templateDir: "src",
201
+ templateName: "og-image",
202
+ templateLabel: "OG Image",
203
+ export: [
204
+ { name: "1200x630", label: "OG Image", size: { width: 1200, height: 630 } },
205
+ { name: "1200x675", label: "Twitter Card", size: { width: 1200, height: 675 } },
206
+ ],
207
+ },
208
+ {
209
+ value: "custom",
210
+ label: "Custom (set your own dimensions and format)",
211
+ format: null,
212
+ },
213
+ ];
214
+
215
+ // ── add collection ──────────────────────────────────────────────────────────
216
+
217
+ export async function addCollection(projectDir, configName) {
218
+ const manifest = loadConfig(projectDir, configName);
219
+ const rl = createRL();
220
+
221
+ console.log("\n Add a new collection\n");
222
+
223
+ // 1. Choose from preset or custom
224
+ const preset = await askSelect(rl, "Choose a collection type:", COLLECTION_PRESETS);
225
+
226
+ let id, label, sourceSize, borderRadius, format, tags, exportList, outputs, templateDir, templateName, templateLabel;
227
+
228
+ if (preset.value === "custom") {
229
+ // Custom collection
230
+ id = await ask(rl, "Collection ID (e.g. banners)", "");
231
+ if (!id) { console.log("\n Aborted.\n"); rl.close(); return; }
232
+
233
+ // Check for duplicate early so user doesn't have to fill out all fields
234
+ if (manifest.collections.some((c) => c.id === id)) {
235
+ console.error(`\n Error: Collection "${id}" already exists in the config.\n`);
236
+ rl.close();
237
+ process.exit(1);
238
+ }
239
+
240
+ label = await ask(rl, "Display label", id.charAt(0).toUpperCase() + id.slice(1));
241
+
242
+ const widthStr = await ask(rl, "Source width (px)", "800");
243
+ const heightStr = await ask(rl, "Source height (px)", "800");
244
+ sourceSize = { width: parseInt(widthStr, 10), height: parseInt(heightStr, 10) };
245
+
246
+ const radiusStr = await ask(rl, "Border radius (px)", "0");
247
+ borderRadius = parseInt(radiusStr, 10);
248
+
249
+ const formatChoice = await askSelect(rl, "Template format:", [
250
+ { label: "HTML", value: "html" },
251
+ { label: "SVG", value: "svg" },
252
+ ]);
253
+ format = formatChoice.value;
254
+
255
+ tags = [id];
256
+ exportList = [
257
+ { name: `${sourceSize.width}x${sourceSize.height}`, label: "Source Size", size: { width: sourceSize.width, height: sourceSize.height } },
258
+ ];
259
+ outputs = format === "svg" ? [{ type: "copy-source", format: "svg" }] : undefined;
260
+ templateDir = `src/${id}` === `src/${id}` && id.includes("/") ? `src` : (sourceSize.width === sourceSize.height ? "src" : `src/${id}`);
261
+ templateDir = `src`;
262
+ templateName = id;
263
+ templateLabel = label;
264
+ } else {
265
+ // Preset
266
+ id = preset.id;
267
+ label = preset.collectionLabel;
268
+ sourceSize = preset.sourceSize;
269
+ borderRadius = preset.borderRadius;
270
+ format = preset.format;
271
+ tags = preset.tags;
272
+ exportList = preset.export;
273
+ outputs = preset.outputs;
274
+ templateDir = preset.templateDir;
275
+ templateName = preset.templateName;
276
+ templateLabel = preset.templateLabel;
277
+ }
278
+
279
+ rl.close();
280
+
281
+ // Check for duplicate collection ID
282
+ if (manifest.collections.some((c) => c.id === id)) {
283
+ console.error(`\n Error: Collection "${id}" already exists in the config.\n`);
284
+ process.exit(1);
285
+ }
286
+
287
+ // Create template file
288
+ const ext = format === "svg" ? "svg" : "html";
289
+ const templateSrc = `${templateDir}/${templateName}.${ext}`;
290
+ const templatePath = join(projectDir, templateSrc);
291
+
292
+ mkdirSync(dirname(templatePath), { recursive: true });
293
+
294
+ const content = format === "svg"
295
+ ? generateSVG(templateLabel, sourceSize.width, sourceSize.height)
296
+ : generateHTML(templateLabel, sourceSize.width, sourceSize.height);
297
+
298
+ writeFileSync(templatePath, content);
299
+ console.log(`\n Created ${templateSrc}`);
300
+
301
+ // Build collection
302
+ const collection = {
303
+ id,
304
+ label,
305
+ tags,
306
+ sourceSize,
307
+ borderRadius,
308
+ templates: [
309
+ { src: templateSrc, name: templateName, label: templateLabel },
310
+ ],
311
+ export: exportList,
312
+ };
313
+ if (outputs) collection.outputs = outputs;
314
+
315
+ manifest.collections.push(collection);
316
+
317
+ // Update global tags
318
+ if (!manifest.tags) manifest.tags = [];
319
+ for (const tag of tags) {
320
+ if (!manifest.tags.some((t) => t.id === tag)) {
321
+ manifest.tags.push({ id: tag, label: tag.charAt(0).toUpperCase() + tag.slice(1) });
322
+ }
323
+ }
324
+
325
+ saveConfig(projectDir, configName, manifest);
326
+ console.log(` Added collection "${id}" to ${configName}`);
327
+ console.log();
328
+ }
329
+
330
+ // ── add template ────────────────────────────────────────────────────────────
331
+
332
+ export async function addTemplate(projectDir, configName) {
333
+ const manifest = loadConfig(projectDir, configName);
334
+ const rl = createRL();
335
+
336
+ console.log("\n Add a new template to an existing collection\n");
337
+
338
+ if (manifest.collections.length === 0) {
339
+ console.error(" Error: No collections found. Run `open-assets add collection` first.\n");
340
+ rl.close();
341
+ process.exit(1);
342
+ }
343
+
344
+ // 1. Pick collection
345
+ const colChoice = await askSelect(
346
+ rl,
347
+ "Which collection?",
348
+ manifest.collections.map((c) => ({ label: `${c.label} (${c.id})`, value: c.id }))
349
+ );
350
+ const col = manifest.collections.find((c) => c.id === colChoice.value);
351
+
352
+ // 2. Template name
353
+ const name = await ask(rl, "Template name (e.g. 02-features)", "");
354
+ if (!name) { console.log("\n Aborted.\n"); rl.close(); return; }
355
+
356
+ // Check for duplicate
357
+ if (col.templates.some((t) => t.name === name)) {
358
+ console.error(`\n Error: Template "${name}" already exists in collection "${col.id}".\n`);
359
+ rl.close();
360
+ process.exit(1);
361
+ }
362
+
363
+ const label = await ask(rl, "Display label", name);
364
+
365
+ // 3. Choose format
366
+ const existingFormat = col.templates[0]?.src?.endsWith(".svg") ? "svg" : "html";
367
+ const formatChoice = await askSelect(rl, "Template format:", [
368
+ { label: "HTML", value: "html" },
369
+ { label: "SVG", value: "svg" },
370
+ ].sort((a, b) => (a.value === existingFormat ? -1 : 1)));
371
+ const format = formatChoice.value;
372
+
373
+ rl.close();
374
+
375
+ // Determine template directory from existing templates
376
+ const existingSrc = col.templates[0]?.src || `src/${col.id}`;
377
+ const templateDir = dirname(existingSrc);
378
+
379
+ const ext = format === "svg" ? "svg" : "html";
380
+ const templateSrc = `${templateDir}/${name}.${ext}`;
381
+ const templatePath = join(projectDir, templateSrc);
382
+
383
+ mkdirSync(dirname(templatePath), { recursive: true });
384
+
385
+ const content = format === "svg"
386
+ ? generateSVG(label, col.sourceSize.width, col.sourceSize.height)
387
+ : generateHTML(label, col.sourceSize.width, col.sourceSize.height);
388
+
389
+ writeFileSync(templatePath, content);
390
+ console.log(`\n Created ${templateSrc}`);
391
+
392
+ col.templates.push({ src: templateSrc, name, label });
393
+
394
+ saveConfig(projectDir, configName, manifest);
395
+ console.log(` Added template "${name}" to collection "${col.id}"`);
396
+ console.log();
397
+ }
398
+
399
+ // ── add size ────────────────────────────────────────────────────────────────
400
+
401
+ export async function addSize(projectDir, configName) {
402
+ const manifest = loadConfig(projectDir, configName);
403
+ const rl = createRL();
404
+
405
+ console.log("\n Add a new export size to a collection\n");
406
+
407
+ if (manifest.collections.length === 0) {
408
+ console.error(" Error: No collections found. Run `open-assets add collection` first.\n");
409
+ rl.close();
410
+ process.exit(1);
411
+ }
412
+
413
+ // 1. Pick collection
414
+ const colChoice = await askSelect(
415
+ rl,
416
+ "Which collection?",
417
+ manifest.collections.map((c) => ({ label: `${c.label} (${c.id})`, value: c.id }))
418
+ );
419
+ const col = manifest.collections.find((c) => c.id === colChoice.value);
420
+
421
+ // 2. Choose from common presets or custom
422
+ const sizePresets = [
423
+ { label: 'iPhone 6.9" (1320x2868)', value: { name: "iphone-6.9", label: 'iPhone 6.9"', size: { width: 1320, height: 2868 } } },
424
+ { label: 'iPhone 6.7" (1290x2796)', value: { name: "iphone-6.7", label: 'iPhone 6.7"', size: { width: 1290, height: 2796 } } },
425
+ { label: 'iPhone 6.5" (1284x2778)', value: { name: "iphone-6.5", label: 'iPhone 6.5"', size: { width: 1284, height: 2778 } } },
426
+ { label: 'iPhone 6.1" (1179x2556)', value: { name: "iphone-6.1", label: 'iPhone 6.1"', size: { width: 1179, height: 2556 } } },
427
+ { label: 'iPad 13" (2064x2752)', value: { name: "ipad-13", label: 'iPad 13"', size: { width: 2064, height: 2752 } } },
428
+ { label: 'iPad 12.9" (2048x2732)', value: { name: "ipad-12.9", label: 'iPad 12.9"', size: { width: 2048, height: 2732 } } },
429
+ { label: "Google Play Phone (1080x1920)", value: { name: "phone", label: "Phone", size: { width: 1080, height: 1920 } } },
430
+ { label: 'Google Play 7" Tablet (1200x1920)', value: { name: "tablet-7", label: '7" Tablet', size: { width: 1200, height: 1920 } } },
431
+ { label: "Mac App Store Retina (2880x1800)", value: { name: "mac-retina", label: "Mac Retina", size: { width: 2880, height: 1800 } } },
432
+ { label: "OG Image (1200x630)", value: { name: "1200x630", label: "OG Image", size: { width: 1200, height: 630 } } },
433
+ { label: "Twitter Card (1200x675)", value: { name: "1200x675", label: "Twitter Card", size: { width: 1200, height: 675 } } },
434
+ { label: "Product Hunt Gallery (1270x760)", value: { name: "1270x760", label: "Product Hunt", size: { width: 1270, height: 760 } } },
435
+ { label: "Custom dimensions", value: "custom" },
436
+ ];
437
+
438
+ const sizeChoice = await askSelect(rl, "Choose a size:", sizePresets);
439
+
440
+ let newSize;
441
+
442
+ if (sizeChoice.value === "custom") {
443
+ const name = await ask(rl, "Size name (e.g. tablet-10)", "");
444
+ if (!name) { console.log("\n Aborted.\n"); rl.close(); return; }
445
+
446
+ const label = await ask(rl, "Display label", name);
447
+ const widthStr = await ask(rl, "Width (px)", "");
448
+ const heightStr = await ask(rl, "Height (px)", "");
449
+
450
+ if (!widthStr || !heightStr) {
451
+ console.log("\n Width and height are required. Aborted.\n");
452
+ rl.close();
453
+ return;
454
+ }
455
+
456
+ newSize = { name, label, size: { width: parseInt(widthStr, 10), height: parseInt(heightStr, 10) } };
457
+ } else {
458
+ newSize = sizeChoice.value;
459
+ }
460
+
461
+ rl.close();
462
+
463
+ // Check for duplicate size name in this collection
464
+ const allSizes = col.export || [];
465
+ if (allSizes.some((s) => s.name === newSize.name)) {
466
+ console.error(`\n Error: Size "${newSize.name}" already exists in collection "${col.id}".\n`);
467
+ process.exit(1);
468
+ }
469
+
470
+ if (!col.export) col.export = [];
471
+ col.export.push(newSize);
472
+
473
+ saveConfig(projectDir, configName, manifest);
474
+ console.log(`\n Added size "${newSize.name}" (${newSize.size.width}x${newSize.size.height}) to collection "${col.id}"`);
475
+ console.log();
476
+ }