@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/README.md +110 -120
- package/bin/open-assets.mjs +165 -319
- package/lib/add.mjs +476 -0
- package/lib/init.mjs +215 -107
- package/lib/list.mjs +64 -0
- package/lib/lockfile.mjs +53 -2
- package/lib/manifest.mjs +132 -0
- package/lib/render.mjs +219 -0
- package/lib/renderer.mjs +57 -7
- package/lib/server.mjs +58 -27
- package/lib/validate.mjs +93 -0
- package/package.json +7 -2
- package/public/img/banner/1280/banner.png +0 -0
- package/public/img/examples/cat-iq-test-icon.png +0 -0
- package/public/img/examples/cat-iq-test.png +0 -0
- package/public/img/examples/no-context-icon.png +0 -0
- package/public/img/examples/no-context.png +0 -0
- package/public/img/examples/only-recipes-icon.png +0 -0
- package/public/img/examples/only-recipes.png +0 -0
- package/public/img/icon/16/icon.png +0 -0
- package/public/img/icon/180/icon.png +0 -0
- package/public/img/icon/192/icon.png +0 -0
- package/public/img/icon/32/icon.png +0 -0
- package/public/img/icon/512/icon.png +0 -0
- package/public/viewer.html +59 -56
- package/skills/open-assets/SKILL.md +121 -76
package/lib/manifest.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get all export sizes from a collection (entries without a type field).
|
|
6
|
+
*/
|
|
7
|
+
export function flatSizes(collection) {
|
|
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);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load and parse a config file from disk.
|
|
20
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
21
|
+
* @param {string} [configName="assets.json"] - Config filename
|
|
22
|
+
* @returns {object} Parsed config
|
|
23
|
+
* @throws {Error} If the file is missing or contains invalid JSON
|
|
24
|
+
*/
|
|
25
|
+
export function loadConfig(projectDir, configName = "assets.json") {
|
|
26
|
+
const configPath = resolve(projectDir, configName);
|
|
27
|
+
if (!existsSync(configPath)) {
|
|
28
|
+
throw new Error(`${configName} not found in ${projectDir}`);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(configPath, "utf-8"));
|
|
32
|
+
} catch (err) {
|
|
33
|
+
throw new Error(`Invalid JSON in ${configName}: ${err.message}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Save a config to disk.
|
|
39
|
+
* @param {string} projectDir - Absolute path to the project directory
|
|
40
|
+
* @param {string} configName - Config filename
|
|
41
|
+
* @param {object} config - The config object to save
|
|
42
|
+
*/
|
|
43
|
+
export function saveConfig(projectDir, configName, config) {
|
|
44
|
+
const configPath = resolve(projectDir, configName);
|
|
45
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Filter collections by ID and/or tag.
|
|
50
|
+
* @param {object[]} collections
|
|
51
|
+
* @param {{ collection?: string, tag?: string }} filters
|
|
52
|
+
* @returns {{ collections: object[], error?: string }}
|
|
53
|
+
*/
|
|
54
|
+
export function filterCollections(collections, filters = {}) {
|
|
55
|
+
let result = collections;
|
|
56
|
+
if (filters.collection) {
|
|
57
|
+
result = result.filter((c) => c.id === filters.collection);
|
|
58
|
+
}
|
|
59
|
+
if (filters.tag) {
|
|
60
|
+
result = result.filter((c) => (c.tags || []).includes(filters.tag));
|
|
61
|
+
}
|
|
62
|
+
if (result.length === 0) {
|
|
63
|
+
const filter = filters.collection
|
|
64
|
+
? `collection "${filters.collection}"`
|
|
65
|
+
: `tag "${filters.tag}"`;
|
|
66
|
+
return { collections: [], error: `No collections matched ${filter} in config` };
|
|
67
|
+
}
|
|
68
|
+
return { collections: result };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Filter templates by name or label.
|
|
73
|
+
* @param {object[]} templates
|
|
74
|
+
* @param {string} [templateName] - If provided, filter to matching name/label
|
|
75
|
+
* @returns {object[]}
|
|
76
|
+
*/
|
|
77
|
+
export function filterTemplates(templates, templateName) {
|
|
78
|
+
if (!templateName) return templates;
|
|
79
|
+
return templates.filter(
|
|
80
|
+
(t) => t.name === templateName || t.label === templateName
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Determine which sizes to render for a collection given CLI options.
|
|
86
|
+
* @param {object} collection - The collection from the config
|
|
87
|
+
* @param {{ width?: string|number, height?: string|number, size?: string, force?: boolean, platform?: string }} opts
|
|
88
|
+
* @returns {{ sizes: object[], warning?: string }}
|
|
89
|
+
*/
|
|
90
|
+
export function resolveRenderSizes(collection, opts = {}) {
|
|
91
|
+
const sourceW = collection.sourceSize.width;
|
|
92
|
+
const sourceH = collection.sourceSize.height;
|
|
93
|
+
|
|
94
|
+
if (opts.width && opts.height) {
|
|
95
|
+
const w = typeof opts.width === "number" ? opts.width : parseInt(opts.width);
|
|
96
|
+
const h = typeof opts.height === "number" ? opts.height : parseInt(opts.height);
|
|
97
|
+
return {
|
|
98
|
+
sizes: [{
|
|
99
|
+
name: `${w}x${h}`,
|
|
100
|
+
label: `${w}x${h}`,
|
|
101
|
+
size: { width: w, height: h },
|
|
102
|
+
}],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (opts.size) {
|
|
107
|
+
const found = flatSizes(collection).find(
|
|
108
|
+
(s) => s.name === opts.size || s.label === opts.size
|
|
109
|
+
);
|
|
110
|
+
if (found) {
|
|
111
|
+
return { sizes: [found] };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
sizes: [],
|
|
115
|
+
warning: `size "${opts.size}" not found in collection "${collection.id}", skipping`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (opts.force) {
|
|
120
|
+
const sizes = flatSizes(collection);
|
|
121
|
+
return { sizes };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Default: render at source size
|
|
125
|
+
return {
|
|
126
|
+
sizes: [{
|
|
127
|
+
name: `${sourceW}x${sourceH}`,
|
|
128
|
+
label: `${sourceW}x${sourceH}`,
|
|
129
|
+
size: { width: sourceW, height: sourceH },
|
|
130
|
+
}],
|
|
131
|
+
};
|
|
132
|
+
}
|
package/lib/render.mjs
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { resolve, join } from "path";
|
|
2
|
+
import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
|
|
3
|
+
import { filterCollections, filterTemplates, resolveRenderSizes, flatOutputs } 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.force] - Export at all sizes and re-render (ignores cache)
|
|
31
|
+
* @param {string} [options.output] - Output directory path
|
|
32
|
+
* @param {boolean} [options.quiet] - Suppress logs
|
|
33
|
+
* @param {RenderDeps} deps - Injected dependencies
|
|
34
|
+
* @param {(msg: string) => void} [log] - Optional log function
|
|
35
|
+
* @returns {Promise<{ results: object[], skipped: number, elapsed: string, error?: string }>}
|
|
36
|
+
*/
|
|
37
|
+
export async function renderAssets(projectDir, manifest, options, deps, log) {
|
|
38
|
+
const noop = () => {};
|
|
39
|
+
const _log = log || noop;
|
|
40
|
+
|
|
41
|
+
// Filter collections
|
|
42
|
+
const { collections, error } = filterCollections(manifest.collections, {
|
|
43
|
+
collection: options.collection,
|
|
44
|
+
tag: options.tag,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (error) {
|
|
48
|
+
return { results: [], skipped: 0, elapsed: "0.0s", error };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const outputDir = resolve(options.output || "./exports");
|
|
52
|
+
mkdirSync(outputDir, { recursive: true });
|
|
53
|
+
|
|
54
|
+
const lockData = deps.readLockfile(projectDir);
|
|
55
|
+
const results = [];
|
|
56
|
+
let skipped = 0;
|
|
57
|
+
const startTime = Date.now();
|
|
58
|
+
|
|
59
|
+
for (const col of collections) {
|
|
60
|
+
const sourceW = col.sourceSize.width;
|
|
61
|
+
const sourceH = col.sourceSize.height;
|
|
62
|
+
|
|
63
|
+
const templates = filterTemplates(col.templates, options.template);
|
|
64
|
+
|
|
65
|
+
const { sizes: sizesToRender, warning } = resolveRenderSizes(col, {
|
|
66
|
+
width: options.width,
|
|
67
|
+
height: options.height,
|
|
68
|
+
size: options.size,
|
|
69
|
+
force: options.force,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (warning) {
|
|
73
|
+
_log(` Warning: ${warning}`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (sizesToRender.length === 0) {
|
|
78
|
+
_log(` No export sizes found for collection "${col.id}"`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const singleTemplate = templates.length === 1;
|
|
83
|
+
const multipleSizes = sizesToRender.length > 1 || options.force;
|
|
84
|
+
// Single-template collections use flat layout with entry.name as filename
|
|
85
|
+
// Multi-template collections use subdirs per size
|
|
86
|
+
const useSubDirs = multipleSizes && !singleTemplate;
|
|
87
|
+
|
|
88
|
+
for (const entry of sizesToRender) {
|
|
89
|
+
const w = entry.size.width;
|
|
90
|
+
const h = entry.size.height;
|
|
91
|
+
|
|
92
|
+
const sizeDir = useSubDirs
|
|
93
|
+
? join(outputDir, col.id, entry.name)
|
|
94
|
+
: join(outputDir, col.id);
|
|
95
|
+
mkdirSync(sizeDir, { recursive: true });
|
|
96
|
+
|
|
97
|
+
if (sizesToRender.length > 1) {
|
|
98
|
+
_log(`\n ${entry.label} (${w}x${h})`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const template of templates) {
|
|
102
|
+
// Determine output format from outFile extension or default to png
|
|
103
|
+
const outExt = entry.outFile
|
|
104
|
+
? entry.outFile.split(".").pop().toLowerCase()
|
|
105
|
+
: "png";
|
|
106
|
+
const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
|
|
107
|
+
const defaultExt = format === "jpeg" ? "jpg" : "png";
|
|
108
|
+
|
|
109
|
+
// Single-template: use entry.name as filename (e.g. logo-1024.png)
|
|
110
|
+
// Multi-template: use template.name as filename (e.g. 01-hero.png)
|
|
111
|
+
const fileName = singleTemplate && multipleSizes
|
|
112
|
+
? `${entry.name}.${defaultExt}`
|
|
113
|
+
: `${template.name}.${defaultExt}`;
|
|
114
|
+
|
|
115
|
+
const outPath = entry.outFile
|
|
116
|
+
? resolve(projectDir, entry.outFile.replace(/\{template\}/g, template.name))
|
|
117
|
+
: join(sizeDir, fileName);
|
|
118
|
+
|
|
119
|
+
const assetKey = `${col.id}/${template.name}`;
|
|
120
|
+
const variantKey = entry.name;
|
|
121
|
+
const srcPath = resolve(projectDir, template.src);
|
|
122
|
+
const checksum = deps.computeChecksum(srcPath);
|
|
123
|
+
|
|
124
|
+
// Skip if unchanged
|
|
125
|
+
if (
|
|
126
|
+
!options.force &&
|
|
127
|
+
checksum &&
|
|
128
|
+
deps.isUpToDate(lockData, assetKey, variantKey, checksum, outPath)
|
|
129
|
+
) {
|
|
130
|
+
_log(
|
|
131
|
+
` Skipping ${template.name} at ${w}x${h} (unchanged)`
|
|
132
|
+
);
|
|
133
|
+
skipped++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_log(
|
|
138
|
+
` Rendering ${template.name} at ${w}x${h}...`
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const isSvg = template.src.endsWith(".svg");
|
|
142
|
+
if (isSvg && w === sourceW && h === sourceH) {
|
|
143
|
+
copyFileSync(srcPath, outPath.replace(`.${defaultExt}`, ".svg"));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Ensure output directory exists when using outFile
|
|
147
|
+
if (entry.outFile) {
|
|
148
|
+
mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const buffer = await deps.renderScreenshot(
|
|
152
|
+
projectDir,
|
|
153
|
+
template.src,
|
|
154
|
+
w,
|
|
155
|
+
h,
|
|
156
|
+
sourceW,
|
|
157
|
+
sourceH,
|
|
158
|
+
{ format, background: col.background }
|
|
159
|
+
);
|
|
160
|
+
writeFileSync(outPath, buffer);
|
|
161
|
+
results.push({
|
|
162
|
+
collection: col.id,
|
|
163
|
+
template: template.name,
|
|
164
|
+
path: outPath,
|
|
165
|
+
width: w,
|
|
166
|
+
height: h,
|
|
167
|
+
size: buffer.length,
|
|
168
|
+
});
|
|
169
|
+
if (checksum) {
|
|
170
|
+
deps.recordExport(lockData, assetKey, variantKey, checksum, outPath);
|
|
171
|
+
}
|
|
172
|
+
_log(` → ${outPath}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Handle outputs (xcode, copy-source, etc.)
|
|
177
|
+
const outputs = flatOutputs(col);
|
|
178
|
+
if (outputs.length > 0 && options.force) {
|
|
179
|
+
for (const output of outputs) {
|
|
180
|
+
if (output.type === "xcode") {
|
|
181
|
+
_log(`\n Exporting to Xcode: ${output.path}`);
|
|
182
|
+
const path = await deps.runXcodeOutput(projectDir, col, output);
|
|
183
|
+
results.push({ collection: col.id, type: "xcode", path });
|
|
184
|
+
} else if (output.type === "copy-source") {
|
|
185
|
+
const format = output.format || "svg";
|
|
186
|
+
const copyDir = join(outputDir, col.id);
|
|
187
|
+
mkdirSync(copyDir, { recursive: true });
|
|
188
|
+
const matchingTemplates = templates.filter((t) => t.src.endsWith(`.${format}`));
|
|
189
|
+
for (const template of matchingTemplates) {
|
|
190
|
+
const srcPath = resolve(projectDir, template.src);
|
|
191
|
+
// Use entry name when single match, template name when multiple
|
|
192
|
+
const baseName = output.name && matchingTemplates.length === 1
|
|
193
|
+
? output.name
|
|
194
|
+
: template.name;
|
|
195
|
+
const destPath = join(copyDir, `${baseName}.${format}`);
|
|
196
|
+
if (existsSync(srcPath)) {
|
|
197
|
+
copyFileSync(srcPath, destPath);
|
|
198
|
+
results.push({
|
|
199
|
+
collection: col.id,
|
|
200
|
+
template: template.name,
|
|
201
|
+
type: `copy-${format}`,
|
|
202
|
+
path: destPath,
|
|
203
|
+
});
|
|
204
|
+
_log(` → ${destPath}`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Persist lockfile
|
|
213
|
+
deps.writeLockfile(projectDir, lockData);
|
|
214
|
+
|
|
215
|
+
await deps.closeBrowser();
|
|
216
|
+
|
|
217
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
218
|
+
return { results, skipped, elapsed: `${elapsed}s` };
|
|
219
|
+
}
|
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
|
|
|
@@ -19,8 +19,15 @@ export async function closeBrowser() {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Render an HTML file to
|
|
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, background?: string }} [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,27 @@ export async function renderScreenshot(
|
|
|
48
56
|
await new Promise((r) => setTimeout(r, 200));
|
|
49
57
|
}
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
59
|
+
// Handle background: transparent by default for PNG, or use specified color
|
|
60
|
+
const bg = options.background;
|
|
61
|
+
if (bg) {
|
|
62
|
+
await page.evaluate((color) => {
|
|
63
|
+
document.documentElement.style.backgroundColor = color;
|
|
64
|
+
}, bg);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const format = options.format || "png";
|
|
68
|
+
const screenshotOpts = {
|
|
69
|
+
type: format,
|
|
53
70
|
clip: { x: 0, y: 0, width, height },
|
|
54
|
-
}
|
|
71
|
+
};
|
|
72
|
+
if (format === "png" && !bg) {
|
|
73
|
+
screenshotOpts.omitBackground = true;
|
|
74
|
+
}
|
|
75
|
+
if (format === "jpeg" && options.quality) {
|
|
76
|
+
screenshotOpts.quality = options.quality;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const buffer = await page.screenshot(screenshotOpts);
|
|
55
80
|
|
|
56
81
|
await page.close();
|
|
57
82
|
return buffer;
|
|
@@ -68,14 +93,39 @@ export async function runXcodeOutput(projectDir, collection, outputConfig) {
|
|
|
68
93
|
|
|
69
94
|
const outputDir = resolve(projectDir, outputConfig.path);
|
|
70
95
|
mkdirSync(outputDir, { recursive: true });
|
|
71
|
-
const outputPath = resolve(outputDir, "AppIcon.png");
|
|
72
96
|
|
|
73
97
|
const sw = collection.sourceSize.width;
|
|
74
98
|
const sh = collection.sourceSize.height;
|
|
99
|
+
const filename = "AppIcon.png";
|
|
100
|
+
const outputPath = resolve(outputDir, filename);
|
|
101
|
+
|
|
102
|
+
// Remove stale images that don't match the current output filename
|
|
103
|
+
for (const entry of readdirSync(outputDir)) {
|
|
104
|
+
if (entry !== filename && /\.(png|jpg|jpeg)$/i.test(entry)) {
|
|
105
|
+
unlinkSync(resolve(outputDir, entry));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
75
108
|
|
|
76
109
|
const buffer = await renderScreenshot(projectDir, template.src, sw, sh, sw, sh);
|
|
77
110
|
writeFileSync(outputPath, buffer);
|
|
78
111
|
|
|
112
|
+
const contentsJson = {
|
|
113
|
+
images: [
|
|
114
|
+
{
|
|
115
|
+
filename,
|
|
116
|
+
idiom: "universal",
|
|
117
|
+
platform: "ios",
|
|
118
|
+
size: `${sw}x${sh}`,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
info: {
|
|
122
|
+
author: "xcode",
|
|
123
|
+
version: 1,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const contentsPath = resolve(outputDir, "Contents.json");
|
|
127
|
+
writeFileSync(contentsPath, JSON.stringify(contentsJson, null, 2) + "\n");
|
|
128
|
+
|
|
79
129
|
console.log(`Updated: ${outputPath}`);
|
|
80
130
|
return outputPath;
|
|
81
131
|
}
|
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
|
-
|
|
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, "
|
|
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);
|
|
@@ -136,7 +146,7 @@ export function startServer(projectDir, options = {}) {
|
|
|
136
146
|
// API: render screenshot (unified for all collection types)
|
|
137
147
|
if (req.method === "POST" && pathname === "/api/render-screenshot") {
|
|
138
148
|
try {
|
|
139
|
-
const { file, width, height, sourceW, sourceH } = JSON.parse(
|
|
149
|
+
const { file, width, height, sourceW, sourceH, background } = JSON.parse(
|
|
140
150
|
await readBody(req)
|
|
141
151
|
);
|
|
142
152
|
if (!file || !width || !height) {
|
|
@@ -148,7 +158,7 @@ export function startServer(projectDir, options = {}) {
|
|
|
148
158
|
const sw = sourceW || width;
|
|
149
159
|
const sh = sourceH || height;
|
|
150
160
|
log(`Rendering ${file} at ${width}x${height} (source ${sw}x${sh})...`);
|
|
151
|
-
const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh);
|
|
161
|
+
const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh, { background });
|
|
152
162
|
res.writeHead(200, {
|
|
153
163
|
"Content-Type": "image/png",
|
|
154
164
|
"Content-Length": buffer.length,
|
|
@@ -171,7 +181,8 @@ export function startServer(projectDir, options = {}) {
|
|
|
171
181
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
172
182
|
return res.end(JSON.stringify({ ok: false, error: "Collection not found" }));
|
|
173
183
|
}
|
|
174
|
-
const
|
|
184
|
+
const outputs = (col.export || []).filter((e) => e.type);
|
|
185
|
+
const output = outputs[outputIndex || 0];
|
|
175
186
|
if (!output) {
|
|
176
187
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
177
188
|
return res.end(JSON.stringify({ ok: false, error: "Output not found" }));
|
|
@@ -235,30 +246,50 @@ export function startServer(projectDir, options = {}) {
|
|
|
235
246
|
res.end();
|
|
236
247
|
});
|
|
237
248
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
249
|
+
const maxRetries = 10;
|
|
250
|
+
let currentPort = port;
|
|
251
|
+
let resolved = false;
|
|
252
|
+
|
|
253
|
+
const actualPort = await new Promise((resolvePort) => {
|
|
254
|
+
function tryListen() {
|
|
255
|
+
server.once("error", (err) => {
|
|
256
|
+
if (err.code === "EADDRINUSE") {
|
|
257
|
+
if (currentPort - port >= maxRetries) {
|
|
258
|
+
console.error(`Error: Ports ${port}–${currentPort} are all in use.`);
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
currentPort++;
|
|
262
|
+
server.close(() => tryListen());
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
throw err;
|
|
266
|
+
});
|
|
252
267
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
268
|
+
server.listen(currentPort, host, () => {
|
|
269
|
+
if (resolved) return;
|
|
270
|
+
resolved = true;
|
|
271
|
+
const displayHost = host === "0.0.0.0" ? "localhost" : host;
|
|
272
|
+
if (currentPort !== port) {
|
|
273
|
+
log();
|
|
274
|
+
log(` Port ${port} is in use, using ${currentPort} instead.`);
|
|
275
|
+
}
|
|
276
|
+
log();
|
|
277
|
+
log(` Open Assets is running on http://${displayHost}:${currentPort}`);
|
|
278
|
+
log();
|
|
279
|
+
log(` Project: ${projectDir}`);
|
|
280
|
+
if (staticDirs.length > 0) {
|
|
281
|
+
log(` Static: ${staticDirs.join(", ")}`);
|
|
282
|
+
}
|
|
283
|
+
log();
|
|
284
|
+
resolvePort(currentPort);
|
|
285
|
+
});
|
|
258
286
|
}
|
|
259
|
-
|
|
287
|
+
|
|
288
|
+
tryListen();
|
|
260
289
|
});
|
|
261
290
|
|
|
291
|
+
return { port: actualPort };
|
|
292
|
+
|
|
262
293
|
function cleanup() {
|
|
263
294
|
for (const w of watchers) w.close();
|
|
264
295
|
for (const client of sseClients) client.end();
|
package/lib/validate.mjs
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { flatSizes, flatOutputs } 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
|
+
const missingTemplates = [];
|
|
16
|
+
const emptyCollections = [];
|
|
17
|
+
let errors = 0;
|
|
18
|
+
|
|
19
|
+
const ok = (msg) => checks.push({ ok: true, message: msg });
|
|
20
|
+
const fail = (msg) => {
|
|
21
|
+
checks.push({ ok: false, message: msg });
|
|
22
|
+
errors++;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Check top-level fields
|
|
26
|
+
if (manifest.name) {
|
|
27
|
+
ok(`name: "${manifest.name}"`);
|
|
28
|
+
} else {
|
|
29
|
+
fail("Missing top-level 'name' field");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (Array.isArray(manifest.collections) && manifest.collections.length > 0) {
|
|
33
|
+
ok(`${manifest.collections.length} collection(s) defined`);
|
|
34
|
+
} else {
|
|
35
|
+
fail("Missing or empty 'collections' array");
|
|
36
|
+
return { checks, errors, missingTemplates, emptyCollections };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check each collection
|
|
40
|
+
const ids = new Set();
|
|
41
|
+
for (const col of manifest.collections) {
|
|
42
|
+
if (!col.id) {
|
|
43
|
+
fail("Collection missing 'id' field");
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ids.has(col.id)) {
|
|
47
|
+
fail(`Duplicate collection id: "${col.id}"`);
|
|
48
|
+
}
|
|
49
|
+
ids.add(col.id);
|
|
50
|
+
|
|
51
|
+
if (!col.sourceSize || !col.sourceSize.width || !col.sourceSize.height) {
|
|
52
|
+
fail(`Collection "${col.id}" missing or invalid 'sourceSize'`);
|
|
53
|
+
} else {
|
|
54
|
+
ok(`Collection "${col.id}" sourceSize: ${col.sourceSize.width}\u00d7${col.sourceSize.height}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!col.templates || col.templates.length === 0) {
|
|
58
|
+
fail(`Collection "${col.id}" has no templates`);
|
|
59
|
+
emptyCollections.push({ collectionId: col.id });
|
|
60
|
+
} else {
|
|
61
|
+
for (const template of col.templates) {
|
|
62
|
+
if (!template.src) {
|
|
63
|
+
fail(`Collection "${col.id}" has a template without 'src'`);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const filePath = resolve(projectDir, template.src);
|
|
67
|
+
if (existsSync(filePath)) {
|
|
68
|
+
ok(`${template.src} exists`);
|
|
69
|
+
} else {
|
|
70
|
+
fail(`${template.src} not found`);
|
|
71
|
+
missingTemplates.push({
|
|
72
|
+
collectionId: col.id,
|
|
73
|
+
templateSrc: template.src,
|
|
74
|
+
templateName: template.name || template.label,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sizes = flatSizes(col);
|
|
81
|
+
if (sizes.length > 0) {
|
|
82
|
+
ok(`Collection "${col.id}" has ${sizes.length} export size(s)`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Validate outputs (export entries with a type field)
|
|
86
|
+
const outputs = flatOutputs(col);
|
|
87
|
+
for (const output of outputs) {
|
|
88
|
+
ok(`Output: ${output.type}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { checks, errors, missingTemplates, emptyCollections };
|
|
93
|
+
}
|