@open-assets/open-assets 0.1.2
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/LICENSE +21 -0
- package/README.md +564 -0
- package/bin/open-assets.mjs +476 -0
- package/lib/init.mjs +545 -0
- package/lib/lockfile.mjs +65 -0
- package/lib/renderer.mjs +81 -0
- package/lib/server.mjs +283 -0
- package/package.json +46 -0
- package/public/viewer.html +518 -0
package/lib/server.mjs
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { createServer } from "http";
|
|
2
|
+
import { readFileSync, existsSync, statSync, watch } from "fs";
|
|
3
|
+
import { resolve, join, extname } from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { renderScreenshot, runXcodeOutput, closeBrowser } from "./renderer.mjs";
|
|
6
|
+
|
|
7
|
+
const __dirname = resolve(fileURLToPath(import.meta.url), "..");
|
|
8
|
+
const VIEWER_PATH = join(__dirname, "..", "public", "viewer.html");
|
|
9
|
+
|
|
10
|
+
const MIME_TYPES = {
|
|
11
|
+
".html": "text/html",
|
|
12
|
+
".css": "text/css",
|
|
13
|
+
".js": "application/javascript",
|
|
14
|
+
".mjs": "application/javascript",
|
|
15
|
+
".json": "application/json",
|
|
16
|
+
".png": "image/png",
|
|
17
|
+
".jpg": "image/jpeg",
|
|
18
|
+
".jpeg": "image/jpeg",
|
|
19
|
+
".gif": "image/gif",
|
|
20
|
+
".svg": "image/svg+xml",
|
|
21
|
+
".ico": "image/x-icon",
|
|
22
|
+
".woff": "font/woff",
|
|
23
|
+
".woff2": "font/woff2",
|
|
24
|
+
".ttf": "font/ttf",
|
|
25
|
+
".otf": "font/otf",
|
|
26
|
+
".webp": "image/webp",
|
|
27
|
+
".mp4": "video/mp4",
|
|
28
|
+
".webm": "video/webm",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function getMime(filePath) {
|
|
32
|
+
return MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function serveFile(res, filePath) {
|
|
36
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
37
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
38
|
+
res.end("Not found");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const mime = getMime(filePath);
|
|
42
|
+
const content = readFileSync(filePath);
|
|
43
|
+
res.writeHead(200, { "Content-Type": mime });
|
|
44
|
+
res.end(content);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readBody(req) {
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let body = "";
|
|
50
|
+
req.on("data", (chunk) => (body += chunk));
|
|
51
|
+
req.on("end", () => resolve(body));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function startServer(projectDir, options = {}) {
|
|
56
|
+
const port = options.port || 3200;
|
|
57
|
+
const host = options.host || "localhost";
|
|
58
|
+
const quiet = options.quiet || false;
|
|
59
|
+
const manifestPath = options.manifestPath || resolve(projectDir, "manifest.json");
|
|
60
|
+
const staticDirs = (options.staticDirs || []).map((d) => resolve(d));
|
|
61
|
+
|
|
62
|
+
const log = quiet ? () => {} : console.log.bind(console);
|
|
63
|
+
|
|
64
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
65
|
+
|
|
66
|
+
// Auto-serve publicDir from manifest
|
|
67
|
+
if (manifest.publicDir) {
|
|
68
|
+
const publicDirPath = resolve(projectDir, manifest.publicDir);
|
|
69
|
+
if (existsSync(publicDirPath) && statSync(publicDirPath).isDirectory()) {
|
|
70
|
+
staticDirs.unshift(publicDirPath);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- Live reload via SSE ----
|
|
75
|
+
const sseClients = new Set();
|
|
76
|
+
const watchers = [];
|
|
77
|
+
|
|
78
|
+
function notifyClients(changedFile) {
|
|
79
|
+
const data = JSON.stringify({ file: changedFile });
|
|
80
|
+
for (const client of sseClients) {
|
|
81
|
+
client.write(`data: ${data}\n\n`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function watchDir(dir) {
|
|
86
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return;
|
|
87
|
+
try {
|
|
88
|
+
const watcher = watch(dir, { recursive: true }, (eventType, filename) => {
|
|
89
|
+
if (!filename) return;
|
|
90
|
+
const relPath = filename.replace(/\\/g, "/");
|
|
91
|
+
log(`[reload] ${relPath} changed`);
|
|
92
|
+
notifyClients(relPath);
|
|
93
|
+
});
|
|
94
|
+
watchers.push(watcher);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
// fs.watch recursive may not be supported on all platforms
|
|
97
|
+
console.warn(`Warning: could not watch ${dir}: ${err.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Watch public dir
|
|
102
|
+
for (const dir of staticDirs) {
|
|
103
|
+
watchDir(dir);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Watch asset files (HTML/SVG templates referenced in manifest)
|
|
107
|
+
const assetDirs = new Set();
|
|
108
|
+
for (const col of manifest.collections || []) {
|
|
109
|
+
for (const template of col.templates || []) {
|
|
110
|
+
const templatePath = resolve(projectDir, template.src);
|
|
111
|
+
const templateDir = resolve(templatePath, "..");
|
|
112
|
+
assetDirs.add(templateDir);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
for (const dir of assetDirs) {
|
|
116
|
+
if (!staticDirs.includes(dir)) {
|
|
117
|
+
watchDir(dir);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const server = createServer(async (req, res) => {
|
|
122
|
+
const url = new URL(req.url, `http://${host}:${port}`);
|
|
123
|
+
const pathname = url.pathname;
|
|
124
|
+
|
|
125
|
+
// CORS headers for API routes
|
|
126
|
+
if (pathname.startsWith("/api/")) {
|
|
127
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
128
|
+
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
129
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
130
|
+
if (req.method === "OPTIONS") {
|
|
131
|
+
res.writeHead(204);
|
|
132
|
+
return res.end();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// API: render screenshot (unified for all collection types)
|
|
137
|
+
if (req.method === "POST" && pathname === "/api/render-screenshot") {
|
|
138
|
+
try {
|
|
139
|
+
const { file, width, height, sourceW, sourceH } = JSON.parse(
|
|
140
|
+
await readBody(req)
|
|
141
|
+
);
|
|
142
|
+
if (!file || !width || !height) {
|
|
143
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
144
|
+
return res.end(
|
|
145
|
+
JSON.stringify({ ok: false, error: "Missing file, width, or height" })
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const sw = sourceW || width;
|
|
149
|
+
const sh = sourceH || height;
|
|
150
|
+
log(`Rendering ${file} at ${width}x${height} (source ${sw}x${sh})...`);
|
|
151
|
+
const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh);
|
|
152
|
+
res.writeHead(200, {
|
|
153
|
+
"Content-Type": "image/png",
|
|
154
|
+
"Content-Length": buffer.length,
|
|
155
|
+
});
|
|
156
|
+
res.end(buffer);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error(err);
|
|
159
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
160
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// API: run xcode output for a collection
|
|
166
|
+
if (req.method === "POST" && pathname === "/api/run-output") {
|
|
167
|
+
try {
|
|
168
|
+
const { collectionId, outputIndex } = JSON.parse(await readBody(req));
|
|
169
|
+
const col = manifest.collections.find((c) => c.id === collectionId);
|
|
170
|
+
if (!col) {
|
|
171
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
172
|
+
return res.end(JSON.stringify({ ok: false, error: "Collection not found" }));
|
|
173
|
+
}
|
|
174
|
+
const output = col.outputs?.[outputIndex || 0];
|
|
175
|
+
if (!output) {
|
|
176
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
177
|
+
return res.end(JSON.stringify({ ok: false, error: "Output not found" }));
|
|
178
|
+
}
|
|
179
|
+
if (output.type === "xcode") {
|
|
180
|
+
const path = await runXcodeOutput(projectDir, col, output);
|
|
181
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
182
|
+
res.end(JSON.stringify({ ok: true, path }));
|
|
183
|
+
} else {
|
|
184
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
185
|
+
res.end(JSON.stringify({ ok: false, error: `Unsupported output type: ${output.type}` }));
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error(err);
|
|
189
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
190
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// SSE: live reload stream
|
|
196
|
+
if (req.method === "GET" && pathname === "/api/live-reload") {
|
|
197
|
+
res.writeHead(200, {
|
|
198
|
+
"Content-Type": "text/event-stream",
|
|
199
|
+
"Cache-Control": "no-cache",
|
|
200
|
+
Connection: "keep-alive",
|
|
201
|
+
"Access-Control-Allow-Origin": "*",
|
|
202
|
+
});
|
|
203
|
+
res.write(":\n\n"); // heartbeat comment to establish connection
|
|
204
|
+
sseClients.add(res);
|
|
205
|
+
req.on("close", () => sseClients.delete(res));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// GET / → serve viewer
|
|
210
|
+
if (req.method === "GET" && pathname === "/") {
|
|
211
|
+
serveFile(res, VIEWER_PATH);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// GET static files → try additional static dirs first, then project dir
|
|
216
|
+
if (req.method === "GET") {
|
|
217
|
+
const safePath = pathname.replace(/\.\./g, "");
|
|
218
|
+
|
|
219
|
+
// Check additional static directories
|
|
220
|
+
for (const dir of staticDirs) {
|
|
221
|
+
const filePath = join(dir, safePath);
|
|
222
|
+
if (existsSync(filePath) && statSync(filePath).isFile()) {
|
|
223
|
+
serveFile(res, filePath);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Fall back to project directory
|
|
229
|
+
const filePath = join(projectDir, safePath);
|
|
230
|
+
serveFile(res, filePath);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
res.writeHead(404);
|
|
235
|
+
res.end();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
server.listen(port, host, () => {
|
|
239
|
+
log();
|
|
240
|
+
log(` open-assets dev server`);
|
|
241
|
+
log(` ───────────────────────`);
|
|
242
|
+
log(` Local: http://${host}:${port}`);
|
|
243
|
+
if (host === "0.0.0.0") {
|
|
244
|
+
log(` Network: http://<your-ip>:${port}`);
|
|
245
|
+
}
|
|
246
|
+
log(` Project: ${projectDir}`);
|
|
247
|
+
if (staticDirs.length > 0) {
|
|
248
|
+
log(` Static: ${staticDirs.join(", ")}`);
|
|
249
|
+
}
|
|
250
|
+
log();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
server.on("error", (err) => {
|
|
254
|
+
if (err.code === "EADDRINUSE") {
|
|
255
|
+
console.error(`Error: Port ${port} is already in use.`);
|
|
256
|
+
console.error(`Try a different port: open-assets dev --port ${port + 1}`);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
throw err;
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
function cleanup() {
|
|
263
|
+
for (const w of watchers) w.close();
|
|
264
|
+
for (const client of sseClients) client.end();
|
|
265
|
+
sseClients.clear();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
process.on("SIGINT", async () => {
|
|
269
|
+
cleanup();
|
|
270
|
+
await closeBrowser();
|
|
271
|
+
server.close();
|
|
272
|
+
process.exit(0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
process.on("SIGTERM", async () => {
|
|
276
|
+
cleanup();
|
|
277
|
+
await closeBrowser();
|
|
278
|
+
server.close();
|
|
279
|
+
process.exit(0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
return server;
|
|
283
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-assets/open-assets",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Dev server and export tool for app screenshots, icons, and logos. Like Storybook for marketing assets.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"open-assets": "./bin/open-assets.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/",
|
|
12
|
+
"public/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.0.0"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"screenshots",
|
|
21
|
+
"app-store",
|
|
22
|
+
"assets",
|
|
23
|
+
"icons",
|
|
24
|
+
"logos",
|
|
25
|
+
"marketing",
|
|
26
|
+
"storybook",
|
|
27
|
+
"puppeteer",
|
|
28
|
+
"open",
|
|
29
|
+
"export",
|
|
30
|
+
"xcode"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/ianmaccallum/open-assets"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://openassets.dev",
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"commander": "^13.0.0",
|
|
43
|
+
"open": "^10.0.0",
|
|
44
|
+
"puppeteer": "^24.37.5"
|
|
45
|
+
}
|
|
46
|
+
}
|