@open-assets/open-assets 0.1.10 → 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/render.mjs ADDED
@@ -0,0 +1,206 @@
1
+ import { resolve, join } from "path";
2
+ import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
3
+ import { filterCollections, filterTemplates, resolveRenderSizes } from "./manifest.mjs";
4
+
5
+ /**
6
+ * @typedef {object} RenderDeps
7
+ * @property {(projectDir: string, htmlFile: string, w: number, h: number, sw: number, sh: number) => Promise<Buffer>} renderScreenshot
8
+ * @property {(projectDir: string, collection: object, outputConfig: object) => Promise<string>} runXcodeOutput
9
+ * @property {() => Promise<void>} closeBrowser
10
+ * @property {(projectDir: string) => object} readLockfile
11
+ * @property {(projectDir: string, data: object) => void} writeLockfile
12
+ * @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
+ */
16
+
17
+ /**
18
+ * Render assets according to the manifest and options.
19
+ *
20
+ * @param {string} projectDir - Absolute path to the project directory
21
+ * @param {object} manifest - Parsed manifest object
22
+ * @param {object} options - Render options from CLI
23
+ * @param {string} [options.collection] - Filter by collection ID
24
+ * @param {string} [options.tag] - Filter by tag
25
+ * @param {string} [options.template] - Filter by template name/label
26
+ * @param {string} [options.size] - Named export size
27
+ * @param {string} [options.format] - Output format override (png or jpg)
28
+ * @param {string|number} [options.width] - Custom width
29
+ * @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
32
+ * @param {string} [options.output] - Output directory path
33
+ * @param {boolean} [options.quiet] - Suppress logs
34
+ * @param {RenderDeps} deps - Injected dependencies
35
+ * @param {(msg: string) => void} [log] - Optional log function
36
+ * @returns {Promise<{ results: object[], skipped: number, elapsed: string, error?: string }>}
37
+ */
38
+ export async function renderAssets(projectDir, manifest, options, deps, log) {
39
+ const noop = () => {};
40
+ const _log = log || noop;
41
+
42
+ // Filter collections
43
+ const { collections, error } = filterCollections(manifest.collections, {
44
+ collection: options.collection,
45
+ tag: options.tag,
46
+ });
47
+
48
+ if (error) {
49
+ return { results: [], skipped: 0, elapsed: "0.0s", error };
50
+ }
51
+
52
+ const outputDir = resolve(options.output || "./exports");
53
+ mkdirSync(outputDir, { recursive: true });
54
+
55
+ const lockData = deps.readLockfile(projectDir);
56
+ const results = [];
57
+ let skipped = 0;
58
+ const startTime = Date.now();
59
+
60
+ for (const col of collections) {
61
+ const sourceW = col.sourceSize.width;
62
+ const sourceH = col.sourceSize.height;
63
+
64
+ const templates = filterTemplates(col.templates, options.template);
65
+
66
+ const { sizes: sizesToRender, warning } = resolveRenderSizes(col, {
67
+ width: options.width,
68
+ height: options.height,
69
+ size: options.size,
70
+ all: options.all,
71
+ });
72
+
73
+ if (warning) {
74
+ _log(` Warning: ${warning}`);
75
+ continue;
76
+ }
77
+
78
+ if (sizesToRender.length === 0) {
79
+ _log(` No export sizes found for collection "${col.id}"`);
80
+ continue;
81
+ }
82
+
83
+ const useSubDirs = sizesToRender.length > 1 || options.all;
84
+
85
+ for (const entry of sizesToRender) {
86
+ const w = entry.size.width;
87
+ const h = entry.size.height;
88
+
89
+ const sizeDir = useSubDirs
90
+ ? join(outputDir, col.id, entry.name)
91
+ : join(outputDir, col.id);
92
+ mkdirSync(sizeDir, { recursive: true });
93
+
94
+ if (sizesToRender.length > 1) {
95
+ _log(`\n ${entry.label} (${w}x${h})`);
96
+ }
97
+
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
+ }
127
+
128
+ _log(
129
+ ` Rendering ${template.name} at ${w}x${h}...`
130
+ );
131
+
132
+ const isSvg = template.src.endsWith(".svg");
133
+ if (isSvg && w === sourceW && h === sourceH) {
134
+ copyFileSync(srcPath, outPath.replace(`.${defaultExt}`, ".svg"));
135
+ }
136
+
137
+ // Ensure output directory exists when using outFile
138
+ if (entry.outFile) {
139
+ mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
140
+ }
141
+
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);
162
+ }
163
+ _log(` → ${outPath}`);
164
+ }
165
+ }
166
+
167
+ // Handle outputs (xcode, copy-source, etc.)
168
+ if (col.outputs && options.all) {
169
+ for (const output of col.outputs) {
170
+ if (output.type === "xcode") {
171
+ _log(`\n Exporting to Xcode: ${output.path}`);
172
+ const path = await deps.runXcodeOutput(projectDir, col, output);
173
+ results.push({ collection: col.id, type: "xcode", path });
174
+ } else if (output.type === "copy-source") {
175
+ const format = output.format || "svg";
176
+ const copyDir = join(outputDir, col.id, format);
177
+ 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
+ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ // Persist lockfile
200
+ deps.writeLockfile(projectDir, lockData);
201
+
202
+ await deps.closeBrowser();
203
+
204
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
205
+ return { results, skipped, elapsed: `${elapsed}s` };
206
+ }
package/lib/renderer.mjs CHANGED
@@ -19,8 +19,15 @@ export async function closeBrowser() {
19
19
  }
20
20
 
21
21
  /**
22
- * Render an HTML file to a PNG buffer at the given dimensions.
22
+ * Render an HTML file to an image buffer at the given dimensions.
23
23
  * Scales from sourceSize to target size via CSS zoom.
24
+ * @param {string} projectDir
25
+ * @param {string} htmlFile
26
+ * @param {number} width
27
+ * @param {number} height
28
+ * @param {number} sourceW
29
+ * @param {number} sourceH
30
+ * @param {{ format?: "png"|"jpeg", quality?: number }} [options]
24
31
  */
25
32
  export async function renderScreenshot(
26
33
  projectDir,
@@ -28,7 +35,8 @@ export async function renderScreenshot(
28
35
  width,
29
36
  height,
30
37
  sourceW,
31
- sourceH
38
+ sourceH,
39
+ options = {}
32
40
  ) {
33
41
  const b = await getBrowser();
34
42
  const page = await b.newPage();
@@ -48,10 +56,16 @@ export async function renderScreenshot(
48
56
  await new Promise((r) => setTimeout(r, 200));
49
57
  }
50
58
 
51
- const buffer = await page.screenshot({
52
- type: "png",
59
+ const format = options.format || "png";
60
+ const screenshotOpts = {
61
+ type: format,
53
62
  clip: { x: 0, y: 0, width, height },
54
- });
63
+ };
64
+ if (format === "jpeg" && options.quality) {
65
+ screenshotOpts.quality = options.quality;
66
+ }
67
+
68
+ const buffer = await page.screenshot(screenshotOpts);
55
69
 
56
70
  await page.close();
57
71
  return buffer;
package/lib/server.mjs CHANGED
@@ -2,7 +2,6 @@ import { createServer } from "http";
2
2
  import { readFileSync, existsSync, statSync, watch } from "fs";
3
3
  import { resolve, join, extname } from "path";
4
4
  import { fileURLToPath } from "url";
5
- import { renderScreenshot, runXcodeOutput, closeBrowser } from "./renderer.mjs";
6
5
 
7
6
  const __dirname = resolve(fileURLToPath(import.meta.url), "..");
8
7
  const VIEWER_PATH = join(__dirname, "..", "public", "viewer.html");
@@ -28,7 +27,7 @@ const MIME_TYPES = {
28
27
  ".webm": "video/webm",
29
28
  };
30
29
 
31
- function getMime(filePath) {
30
+ export function getMime(filePath) {
32
31
  return MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream";
33
32
  }
34
33
 
@@ -52,11 +51,22 @@ function readBody(req) {
52
51
  });
53
52
  }
54
53
 
55
- export function startServer(projectDir, options = {}) {
54
+ /**
55
+ * @param {string} projectDir
56
+ * @param {object} [options]
57
+ * @param {{ renderScreenshot: Function, runXcodeOutput: Function, closeBrowser: Function }} [renderer]
58
+ * Optional renderer dependency injection. Falls back to importing ./renderer.mjs.
59
+ */
60
+ export async function startServer(projectDir, options = {}, renderer) {
61
+ if (!renderer) {
62
+ renderer = await import("./renderer.mjs");
63
+ }
64
+ const { renderScreenshot, runXcodeOutput, closeBrowser } = renderer;
65
+
56
66
  const port = options.port || 3200;
57
67
  const host = options.host || "localhost";
58
68
  const quiet = options.quiet || false;
59
- const manifestPath = options.manifestPath || resolve(projectDir, "manifest.json");
69
+ const manifestPath = options.manifestPath || resolve(projectDir, "assets.json");
60
70
  const staticDirs = (options.staticDirs || []).map((d) => resolve(d));
61
71
 
62
72
  const log = quiet ? () => {} : console.log.bind(console);
@@ -0,0 +1,90 @@
1
+ import { existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { flatSizes } from "./manifest.mjs";
4
+
5
+ /**
6
+ * Validate a config object and check that referenced files exist.
7
+ * Returns structured results instead of printing/exiting.
8
+ *
9
+ * @param {object} manifest - Parsed config object
10
+ * @param {string} projectDir - Absolute path to the project directory
11
+ * @returns {{ checks: Array<{ ok: boolean, message: string }>, errors: number }}
12
+ */
13
+ export function validateConfig(manifest, projectDir) {
14
+ const checks = [];
15
+ let errors = 0;
16
+
17
+ const ok = (msg) => checks.push({ ok: true, message: msg });
18
+ const fail = (msg) => {
19
+ checks.push({ ok: false, message: msg });
20
+ errors++;
21
+ };
22
+
23
+ // Check top-level fields
24
+ if (manifest.name) {
25
+ ok(`name: "${manifest.name}"`);
26
+ } else {
27
+ fail("Missing top-level 'name' field");
28
+ }
29
+
30
+ if (Array.isArray(manifest.collections) && manifest.collections.length > 0) {
31
+ ok(`${manifest.collections.length} collection(s) defined`);
32
+ } else {
33
+ fail("Missing or empty 'collections' array");
34
+ return { checks, errors };
35
+ }
36
+
37
+ // Check each collection
38
+ const ids = new Set();
39
+ for (const col of manifest.collections) {
40
+ if (!col.id) {
41
+ fail("Collection missing 'id' field");
42
+ continue;
43
+ }
44
+ if (ids.has(col.id)) {
45
+ fail(`Duplicate collection id: "${col.id}"`);
46
+ }
47
+ ids.add(col.id);
48
+
49
+ if (!col.sourceSize || !col.sourceSize.width || !col.sourceSize.height) {
50
+ fail(`Collection "${col.id}" missing or invalid 'sourceSize'`);
51
+ } else {
52
+ ok(`Collection "${col.id}" sourceSize: ${col.sourceSize.width}\u00d7${col.sourceSize.height}`);
53
+ }
54
+
55
+ if (!col.templates || col.templates.length === 0) {
56
+ fail(`Collection "${col.id}" has no templates`);
57
+ } else {
58
+ for (const template of col.templates) {
59
+ if (!template.src) {
60
+ fail(`Collection "${col.id}" has a template without 'src'`);
61
+ continue;
62
+ }
63
+ const filePath = resolve(projectDir, template.src);
64
+ if (existsSync(filePath)) {
65
+ ok(`${template.src} exists`);
66
+ } else {
67
+ fail(`${template.src} not found`);
68
+ }
69
+ }
70
+ }
71
+
72
+ const sizes = flatSizes(col);
73
+ if (sizes.length > 0) {
74
+ ok(`Collection "${col.id}" has ${sizes.length} export size(s)`);
75
+ }
76
+
77
+ // Validate outputs
78
+ if (col.outputs) {
79
+ for (const output of col.outputs) {
80
+ if (!output.type) {
81
+ fail(`Collection "${col.id}" has an output without 'type'`);
82
+ } else {
83
+ ok(`Output: ${output.type}`);
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ return { checks, errors };
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-assets/open-assets",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Dev server and export tool for app screenshots, icons, and logos. Like Storybook for marketing assets.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
10
10
  "bin/",
11
11
  "lib/",
12
12
  "public/",
13
+ "skills/",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],
@@ -37,10 +38,18 @@
37
38
  "publishConfig": {
38
39
  "access": "public"
39
40
  },
41
+ "scripts": {
42
+ "assets": "node bin/open-assets.mjs render --all -o ./public/img",
43
+ "test": "node --experimental-vm-modules node_modules/.bin/jest",
44
+ "release": "./scripts/release.sh"
45
+ },
40
46
  "homepage": "https://openassets.dev",
41
47
  "dependencies": {
42
48
  "commander": "^13.0.0",
43
49
  "open": "^10.0.0",
44
50
  "puppeteer": "^24.37.5"
51
+ },
52
+ "devDependencies": {
53
+ "jest": "^30.3.0"
45
54
  }
46
55
  }
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -4,6 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Open Assets</title>
7
+ <link rel="icon" type="image/png" sizes="32x32" href="/img/icon/32/icon.png" />
8
+ <link rel="icon" type="image/png" sizes="16x16" href="/img/icon/16/icon.png" />
7
9
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
8
10
  <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
9
11
  <style>
@@ -12,6 +14,7 @@
12
14
 
13
15
  .toolbar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; height: 48px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background: rgba(10,10,10,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255,255,255,0.06); }
14
16
  .toolbar-left { display: flex; align-items: center; gap: 12px; }
17
+ .toolbar-left img.toolbar-icon { width: 20px; height: 20px; border-radius: 4px; }
15
18
  .toolbar-left h1 { font-size: 14px; font-weight: 700; color: #fff; }
16
19
  .segmented { display: flex; background: rgba(255,255,255,0.06); border-radius: 6px; padding: 2px; gap: 1px; }
17
20
  .seg-btn { padding: 4px 14px; border-radius: 5px; border: none; background: transparent; color: #888; font-size: 12px; font-weight: 600; cursor: pointer; transition: background 0.15s, color 0.15s; font-family: inherit; text-decoration: none; }
@@ -82,6 +85,7 @@
82
85
  <body>
83
86
  <div class="toolbar">
84
87
  <div class="toolbar-left">
88
+ <img class="toolbar-icon" src="/img/icon/32/icon.png" alt="" />
85
89
  <h1 id="app-title">Loading...</h1>
86
90
  <div class="segmented" id="tab-bar"></div>
87
91
  </div>
@@ -174,7 +178,7 @@
174
178
  }
175
179
 
176
180
  function flatSizes(col) {
177
- return (col.exportSizes || []).flatMap(g => g.sizes || []);
181
+ return col.export || [];
178
182
  }
179
183
 
180
184
  // ---- Build collection tab bar ----
@@ -288,31 +292,30 @@
288
292
  }
289
293
  }
290
294
 
291
- // Export sizes grouped by platform
292
- for (const group of col.exportSizes || []) {
293
- if (group.platform) {
294
- const sectionLabel = document.createElement('div');
295
- sectionLabel.className = 'section-label';
296
- sectionLabel.textContent = group.platform;
297
- exportDropdown.appendChild(sectionLabel);
298
- }
295
+ // Export sizes
296
+ const sizes = col.export || [];
297
+ if (sizes.length > 0) {
298
+ const sectionLabel = document.createElement('div');
299
+ sectionLabel.className = 'section-label';
300
+ sectionLabel.textContent = 'Sizes';
301
+ exportDropdown.appendChild(sectionLabel);
302
+ }
299
303
 
300
- for (const size of group.sizes || []) {
301
- const btn = document.createElement('button');
302
- btn.innerHTML = `${size.label} <span class="dim">${size.width} &times; ${size.height}</span>`;
303
- btn.addEventListener('click', () => {
304
- if (hasMultipleTemplates) {
305
- exportZip(size.width, size.height, sw, sh, size.name);
306
- } else {
307
- exportSinglePNG(col.templates[0], size.width, size.height, sw, sh, size.name);
308
- }
309
- });
310
- exportDropdown.appendChild(btn);
311
- }
304
+ for (const entry of sizes) {
305
+ const btn = document.createElement('button');
306
+ btn.innerHTML = `${entry.label} <span class="dim">${entry.size.width} &times; ${entry.size.height}</span>`;
307
+ btn.addEventListener('click', () => {
308
+ if (hasMultipleTemplates) {
309
+ exportZip(entry.size.width, entry.size.height, sw, sh, entry.name);
310
+ } else {
311
+ exportSinglePNG(col.templates[0], entry.size.width, entry.size.height, sw, sh, entry.name);
312
+ }
313
+ });
314
+ exportDropdown.appendChild(btn);
312
315
  }
313
316
 
314
317
  // Custom size
315
- if (col.exportSizes && flatSizes(col).length > 0 || col.customExport) {
318
+ if (col.export && col.export.length > 0 || col.customExport) {
316
319
  exportDropdown.appendChild(document.createElement('hr'));
317
320
 
318
321
  const customLabel = document.createElement('div');
@@ -320,8 +323,8 @@
320
323
  customLabel.textContent = 'Custom Size';
321
324
  exportDropdown.appendChild(customLabel);
322
325
 
323
- const defaultW = col.customExport?.defaultWidth || flatSizes(col)[0]?.width || sw;
324
- const defaultH = col.customExport?.defaultHeight || flatSizes(col)[0]?.height || sh;
326
+ const defaultW = col.customExport?.defaultWidth || flatSizes(col)[0]?.size?.width || sw;
327
+ const defaultH = col.customExport?.defaultHeight || flatSizes(col)[0]?.size?.height || sh;
325
328
 
326
329
  // Check if square (icon-like) for simplified input
327
330
  const isSquare = sw === sh;
@@ -446,7 +449,7 @@
446
449
 
447
450
  // ---- Init ----
448
451
  async function init() {
449
- const res = await fetch('manifest.json');
452
+ const res = await fetch('assets.json');
450
453
  manifest = await res.json();
451
454
 
452
455
  // Set title