@open-assets/open-assets 0.1.11 → 0.2.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/init.mjs CHANGED
@@ -29,34 +29,172 @@ function askConfirm(rl, question, defaultYes = true) {
29
29
  }
30
30
 
31
31
  function askCheckbox(rl, question, options) {
32
+ // Non-TTY fallback: numbered list, comma-separated input
33
+ if (!process.stdin.isTTY) {
34
+ return new Promise((resolve) => {
35
+ console.log(`\n ${question}\n`);
36
+ options.forEach((opt, i) => {
37
+ console.log(` ${i + 1}) ${opt.label}`);
38
+ });
39
+ console.log();
40
+ rl.question(" Enter numbers (comma-separated): ", (answer) => {
41
+ const indices = answer.trim().split(",").map((s) => parseInt(s.trim(), 10) - 1);
42
+ resolve(
43
+ options
44
+ .filter((_, i) => indices.includes(i))
45
+ .map((o) => o.value)
46
+ );
47
+ });
48
+ });
49
+ }
50
+
32
51
  return new Promise((resolve) => {
52
+ const selected = new Set();
53
+ let cursor = 0;
54
+
55
+ function render() {
56
+ // Move cursor up to overwrite previous render (if not first render)
57
+ if (render._rendered) {
58
+ process.stdout.write(`\x1b[${options.length + 1}A`);
59
+ }
60
+ render._rendered = true;
61
+
62
+ for (let i = 0; i < options.length; i++) {
63
+ const check = selected.has(i) ? "\x1b[36m●\x1b[0m" : "○";
64
+ const label = i === cursor ? `\x1b[36m${options[i].label}\x1b[0m` : options[i].label;
65
+ const pointer = i === cursor ? "\x1b[36m❯\x1b[0m" : " ";
66
+ process.stdout.write(`\x1b[2K ${pointer} ${check} ${label}\n`);
67
+ }
68
+ process.stdout.write(`\x1b[2K \x1b[2m(↑/↓ navigate, space select, enter confirm)\x1b[0m`);
69
+ }
70
+
33
71
  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
- });
72
+ render();
73
+
74
+ const { stdin } = process;
75
+ const wasRaw = stdin.isRaw;
76
+ stdin.setRawMode(true);
77
+ stdin.resume();
78
+
79
+ function onData(key) {
80
+ // Ctrl+C
81
+ if (key[0] === 3) {
82
+ stdin.setRawMode(wasRaw);
83
+ stdin.removeListener("data", onData);
84
+ process.stdout.write("\n");
85
+ process.exit(0);
86
+ }
87
+
88
+ // Enter
89
+ if (key[0] === 13) {
90
+ stdin.setRawMode(wasRaw);
91
+ stdin.removeListener("data", onData);
92
+ process.stdout.write("\n");
93
+ resolve(
94
+ options
95
+ .filter((_, i) => selected.has(i))
96
+ .map((o) => o.value)
97
+ );
98
+ return;
99
+ }
100
+
101
+ // Space – toggle
102
+ if (key[0] === 32) {
103
+ if (selected.has(cursor)) selected.delete(cursor);
104
+ else selected.add(cursor);
105
+ render();
106
+ return;
107
+ }
108
+
109
+ // Arrow keys (escape sequences: 27, 91, 65/66)
110
+ if (key[0] === 27 && key[1] === 91) {
111
+ if (key[2] === 65) {
112
+ // Up
113
+ cursor = (cursor - 1 + options.length) % options.length;
114
+ render();
115
+ } else if (key[2] === 66) {
116
+ // Down
117
+ cursor = (cursor + 1) % options.length;
118
+ render();
119
+ }
120
+ }
121
+ }
122
+
123
+ stdin.on("data", onData);
45
124
  });
46
125
  }
47
126
 
48
127
  function askSelect(rl, question, options) {
128
+ // Non-TTY fallback: numbered list
129
+ if (!process.stdin.isTTY) {
130
+ return new Promise((resolve) => {
131
+ console.log(`\n ${question}\n`);
132
+ options.forEach((opt, i) => {
133
+ console.log(` ${i + 1}) ${opt.label}`);
134
+ });
135
+ console.log();
136
+ rl.question(" Enter number: ", (answer) => {
137
+ const idx = parseInt(answer.trim(), 10) - 1;
138
+ if (idx >= 0 && idx < options.length) resolve(options[idx].value);
139
+ else resolve(options[0].value);
140
+ });
141
+ });
142
+ }
143
+
49
144
  return new Promise((resolve) => {
145
+ let cursor = 0;
146
+
147
+ function render() {
148
+ if (render._rendered) {
149
+ process.stdout.write(`\x1b[${options.length + 1}A`);
150
+ }
151
+ render._rendered = true;
152
+
153
+ for (let i = 0; i < options.length; i++) {
154
+ const dot = i === cursor ? "\x1b[36m●\x1b[0m" : "○";
155
+ const label = i === cursor ? `\x1b[36m${options[i].label}\x1b[0m` : options[i].label;
156
+ const pointer = i === cursor ? "\x1b[36m❯\x1b[0m" : " ";
157
+ process.stdout.write(`\x1b[2K ${pointer} ${dot} ${label}\n`);
158
+ }
159
+ process.stdout.write(`\x1b[2K \x1b[2m(↑/↓ navigate, enter confirm)\x1b[0m`);
160
+ }
161
+
50
162
  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
- });
163
+ render();
164
+
165
+ const { stdin } = process;
166
+ const wasRaw = stdin.isRaw;
167
+ stdin.setRawMode(true);
168
+ stdin.resume();
169
+
170
+ function onData(key) {
171
+ if (key[0] === 3) {
172
+ stdin.setRawMode(wasRaw);
173
+ stdin.removeListener("data", onData);
174
+ process.stdout.write("\n");
175
+ process.exit(0);
176
+ }
177
+
178
+ if (key[0] === 13) {
179
+ stdin.setRawMode(wasRaw);
180
+ stdin.removeListener("data", onData);
181
+ process.stdout.write("\n");
182
+ resolve(options[cursor].value);
183
+ return;
184
+ }
185
+
186
+ if (key[0] === 27 && key[1] === 91) {
187
+ if (key[2] === 65) {
188
+ cursor = (cursor - 1 + options.length) % options.length;
189
+ render();
190
+ } else if (key[2] === 66) {
191
+ cursor = (cursor + 1) % options.length;
192
+ render();
193
+ }
194
+ }
195
+ }
196
+
197
+ stdin.on("data", onData);
60
198
  });
61
199
  }
62
200
 
@@ -66,29 +204,23 @@ const ASSET_TYPES = {
66
204
  screenshots: {
67
205
  label: "App Store Screenshots",
68
206
  collection: (platforms) => {
69
- const exportSizes = [];
207
+ const exportList = [];
70
208
 
71
209
  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
- });
210
+ exportList.push(
211
+ { name: "iphone-6.9", label: 'iPhone 6.9"', size: { width: 1320, height: 2868 } },
212
+ { name: "iphone-6.7", label: 'iPhone 6.7"', size: { width: 1290, height: 2796 } },
213
+ { name: "iphone-6.5", label: 'iPhone 6.5"', size: { width: 1284, height: 2778 } },
214
+ { name: "ipad-13", label: 'iPad 13"', size: { width: 2064, height: 2752 } },
215
+ );
81
216
  }
82
217
 
83
218
  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
- });
219
+ exportList.push(
220
+ { name: "phone", label: "Phone", size: { width: 1080, height: 1920 } },
221
+ { name: "tablet-7", label: '7" Tablet', size: { width: 1200, height: 1920 } },
222
+ { name: "tablet-10", label: '10" Tablet', size: { width: 1600, height: 2560 } },
223
+ );
92
224
  }
93
225
 
94
226
  return {
@@ -100,7 +232,7 @@ const ASSET_TYPES = {
100
232
  templates: [
101
233
  { src: "src/screenshots/01-hero.html", name: "01-hero", label: "Hero" },
102
234
  ],
103
- exportSizes,
235
+ export: exportList,
104
236
  customExport: { defaultWidth: 1320, defaultHeight: 2868 },
105
237
  };
106
238
  },
@@ -146,48 +278,39 @@ const ASSET_TYPES = {
146
278
  icon: {
147
279
  label: "App Icon",
148
280
  collection: (platforms) => {
149
- const exportSizes = [];
281
+ const exportList = [];
150
282
 
151
283
  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
- });
284
+ exportList.push(
285
+ { name: "1024", label: "1024px", size: { width: 1024, height: 1024 } },
286
+ { name: "180", label: "180px", size: { width: 180, height: 180 } },
287
+ { name: "120", label: "120px", size: { width: 120, height: 120 } },
288
+ { name: "87", label: "87px", size: { width: 87, height: 87 } },
289
+ { name: "80", label: "80px", size: { width: 80, height: 80 } },
290
+ { name: "60", label: "60px", size: { width: 60, height: 60 } },
291
+ { name: "40", label: "40px", size: { width: 40, height: 40 } },
292
+ );
164
293
  }
165
294
 
166
295
  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
- });
296
+ exportList.push(
297
+ { name: "512", label: "Play Store", size: { width: 512, height: 512 } },
298
+ { name: "192", label: "xxxhdpi", size: { width: 192, height: 192 } },
299
+ { name: "144", label: "xxhdpi", size: { width: 144, height: 144 } },
300
+ { name: "96", label: "xhdpi", size: { width: 96, height: 96 } },
301
+ { name: "72", label: "hdpi", size: { width: 72, height: 72 } },
302
+ { name: "48", label: "mdpi", size: { width: 48, height: 48 } },
303
+ );
178
304
  }
179
305
 
180
306
  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
- });
307
+ exportList.push(
308
+ { name: "512", label: "512px", size: { width: 512, height: 512 } },
309
+ { name: "192", label: "192px", size: { width: 192, height: 192 } },
310
+ { name: "180", label: "Apple Touch", size: { width: 180, height: 180 } },
311
+ { name: "32", label: "Favicon 32", size: { width: 32, height: 32 } },
312
+ { name: "16", label: "Favicon 16", size: { width: 16, height: 16 } },
313
+ );
191
314
  }
192
315
 
193
316
  return {
@@ -199,7 +322,7 @@ const ASSET_TYPES = {
199
322
  templates: [
200
323
  { src: "src/icon.html", name: "icon", label: "App Icon" },
201
324
  ],
202
- exportSizes,
325
+ export: exportList,
203
326
  };
204
327
  },
205
328
  dirs: [],
@@ -245,16 +368,11 @@ const ASSET_TYPES = {
245
368
  templates: [
246
369
  { src: "src/logo.html", name: "logo", label: "Logo" },
247
370
  ],
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
- },
371
+ export: [
372
+ { name: "800", label: "800px", size: { width: 800, height: 800 } },
373
+ { name: "400", label: "400px", size: { width: 400, height: 400 } },
374
+ { name: "200", label: "200px", size: { width: 200, height: 200 } },
375
+ { name: "64", label: "64px", size: { width: 64, height: 64 } },
258
376
  ],
259
377
  }),
260
378
  dirs: [],
@@ -311,15 +429,10 @@ const ASSET_TYPES = {
311
429
  templates: [
312
430
  { src: "src/wordmark.html", name: "wordmark", label: "Wordmark" },
313
431
  ],
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
- },
432
+ export: [
433
+ { name: "1200x400", label: "Large", size: { width: 1200, height: 400 } },
434
+ { name: "600x200", label: "Medium", size: { width: 600, height: 200 } },
435
+ { name: "300x100", label: "Small", size: { width: 300, height: 100 } },
323
436
  ],
324
437
  }),
325
438
  dirs: [],
@@ -368,13 +481,8 @@ const ASSET_TYPES = {
368
481
  templates: [
369
482
  { src: "src/feature-graphic.html", name: "feature-graphic", label: "Feature Graphic" },
370
483
  ],
371
- exportSizes: [
372
- {
373
- platform: "Google Play",
374
- sizes: [
375
- { name: "1024x500", label: "Feature Graphic", width: 1024, height: 500 },
376
- ],
377
- },
484
+ export: [
485
+ { name: "1024x500", label: "Feature Graphic", size: { width: 1024, height: 500 } },
378
486
  ],
379
487
  }),
380
488
  dirs: [],
@@ -415,17 +523,17 @@ const ASSET_TYPES = {
415
523
  // ── Main interactive flow ───────────────────────────────────────────────────
416
524
 
417
525
  export async function scaffoldProject(dir) {
418
- const manifestPath = join(dir, "manifest.json");
526
+ const configPath = join(dir, "assets.json");
419
527
 
420
- if (existsSync(manifestPath)) {
421
- console.error("Error: manifest.json already exists in this directory.");
528
+ if (existsSync(configPath)) {
529
+ console.error("Error: assets.json already exists in this directory.");
422
530
  process.exit(1);
423
531
  }
424
532
 
425
533
  const rl = createRL();
426
534
 
427
535
  console.log("\n Welcome to open-assets!\n");
428
- console.log(" This will set up your asset project with a manifest.json");
536
+ console.log(" This will set up your asset project with an assets.json");
429
537
  console.log(" and starter HTML templates.\n");
430
538
 
431
539
  // 1. Project name
@@ -529,11 +637,11 @@ export async function scaffoldProject(dir) {
529
637
  collections,
530
638
  };
531
639
 
532
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
533
- console.log(" Created manifest.json");
640
+ writeFileSync(configPath, JSON.stringify(manifest, null, 2) + "\n");
641
+ console.log(" Created assets.json");
534
642
 
535
643
  // Write .gitignore
536
- writeFileSync(join(dir, ".gitignore"), "dist/\nexports/\nmanifest.lock\nnode_modules/\n");
644
+ writeFileSync(join(dir, ".gitignore"), "dist/\nexports/\nnode_modules/\n");
537
645
  console.log(" Created .gitignore");
538
646
 
539
647
  console.log();
package/lib/list.mjs ADDED
@@ -0,0 +1,64 @@
1
+ import { flatSizes, flatOutputs } from "./manifest.mjs";
2
+
3
+ /**
4
+ * Filter collections by tag. Returns filtered array.
5
+ * @param {object} manifest
6
+ * @param {{ tag?: string }} [options]
7
+ * @returns {{ collections: object[], error?: string }}
8
+ */
9
+ export function listCollections(manifest, options = {}) {
10
+ let cols = manifest.collections || [];
11
+ if (options.tag) {
12
+ cols = cols.filter((c) => (c.tags || []).includes(options.tag));
13
+ if (cols.length === 0) {
14
+ return { collections: [], error: `No collections found with tag "${options.tag}"` };
15
+ }
16
+ }
17
+ return { collections: cols };
18
+ }
19
+
20
+ /**
21
+ * Format a manifest's collections into a human-readable string.
22
+ * @param {object} manifest
23
+ * @param {object[]} collections
24
+ * @returns {string}
25
+ */
26
+ export function formatCollectionList(manifest, collections) {
27
+ const lines = [];
28
+
29
+ if (manifest.tags && manifest.tags.length > 0) {
30
+ lines.push(`Tags: ${manifest.tags.map((t) => t.id).join(", ")}`);
31
+ lines.push("");
32
+ }
33
+
34
+ lines.push(manifest.name);
35
+ lines.push("");
36
+
37
+ for (const col of collections) {
38
+ const templateCount = col.templates.length;
39
+ const sizeCount = flatSizes(col).length;
40
+ const tagStr =
41
+ col.tags && col.tags.length > 0 ? ` [${col.tags.join(", ")}]` : "";
42
+ lines.push(
43
+ `${col.label} (${col.id})${tagStr} \u2014 ${templateCount} template(s), ${sizeCount} size(s)`
44
+ );
45
+
46
+ for (const template of col.templates) {
47
+ lines.push(` \u2022 ${template.label || template.name} \u2192 ${template.src}`);
48
+ }
49
+
50
+ for (const entry of flatSizes(col)) {
51
+ lines.push(` \u21b3 ${entry.label} (${entry.size.width}\u00d7${entry.size.height})`);
52
+ }
53
+
54
+ for (const output of flatOutputs(col)) {
55
+ if (output.type === "xcode") {
56
+ lines.push(` \u21b3 Xcode: ${output.path}`);
57
+ } else if (output.type === "copy-source") {
58
+ lines.push(` \u21b3 Copy source: ${output.format}`);
59
+ }
60
+ }
61
+ }
62
+
63
+ return lines.join("\n");
64
+ }
package/lib/lockfile.mjs CHANGED
@@ -1,8 +1,8 @@
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
- const LOCK_FILENAME = "manifest.lock";
5
+ const LOCK_FILENAME = "assets.lock";
6
6
 
7
7
  /**
8
8
  * Read the lockfile from disk, or return an empty structure.
@@ -27,13 +27,64 @@ 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
  }