storeshot 0.1.0 → 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/CHANGELOG.md +28 -0
- package/CONTRIBUTING.md +6 -0
- package/README.md +36 -8
- package/THIRD_PARTY_LICENSES.txt +29 -0
- package/THIRD_PARTY_NOTICES.md +28 -3
- package/dist/chunk-I7JOE7XJ.js +299 -0
- package/dist/chunk-I7JOE7XJ.js.map +1 -0
- package/dist/cli.js +669 -342
- package/dist/cli.js.map +1 -1
- package/dist/node-renderer-FMVPIQAG.js +517 -0
- package/dist/node-renderer-FMVPIQAG.js.map +1 -0
- package/dist/ui/artwork/cc0-laurels/LICENSE +121 -0
- package/dist/ui/artwork/cc0-laurels/README.md +16 -0
- package/dist/ui/artwork/cc0-laurels/laurel-branch.svg +95 -0
- package/dist/ui/artwork/cc0-laurels/laurel-pair-wide.svg +88 -0
- package/dist/ui/artwork/cc0-laurels/laurel-wreath-classic.svg +45 -0
- package/dist/ui/artwork/cc0-laurels/laurel-wreath-natural.svg +75 -0
- package/dist/ui/assets/AssetsView-FCqaKK4x.js +1 -0
- package/dist/ui/assets/SetEditor-BxiRSHKv.js +440 -0
- package/dist/ui/assets/{index-DPDaBFld.js → index-BrRqpj-U.js} +2 -2
- package/dist/ui/assets/index-KYX154p7.css +2 -0
- package/dist/ui/assets/{shared-BiRg4I01.js → shared-stFaxZuu.js} +1 -1
- package/dist/ui/index.html +2 -2
- package/docs/cli.md +141 -0
- package/docs/images/storeshot-editor.png +0 -0
- package/docs/releasing.md +80 -122
- package/fonts/Geist-Variable.ttf +0 -0
- package/package.json +3 -1
- package/dist/ui/assets/AssetsView-D0lbK5rm.js +0 -1
- package/dist/ui/assets/SetEditor-BBUlgGT0.js +0 -440
- package/dist/ui/assets/index-DfZBqsD3.css +0 -2
package/dist/cli.js
CHANGED
|
@@ -1,258 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ASSET_CATEGORIES,
|
|
4
|
+
BUILT_IN_ARTWORK,
|
|
5
|
+
MOCKUP_BUNDLE_FILENAME,
|
|
6
|
+
SCREENSHOT_DEVICE_TYPES,
|
|
7
|
+
bundleAssetPaths,
|
|
8
|
+
emptyDeviceMockupCatalog,
|
|
9
|
+
isSafeBundlePath,
|
|
10
|
+
mergeDeviceMockupCatalogs,
|
|
11
|
+
parseMockupBundleManifest,
|
|
12
|
+
resolveMockupBundle,
|
|
13
|
+
resolvePackagePublicDirectory
|
|
14
|
+
} from "./chunk-I7JOE7XJ.js";
|
|
2
15
|
|
|
3
16
|
// src/cli.ts
|
|
4
|
-
import
|
|
17
|
+
import { constants as constants2 } from "fs";
|
|
18
|
+
import { access as access2, readFile as readFile4 } from "fs/promises";
|
|
19
|
+
import path4 from "path";
|
|
5
20
|
import { fileURLToPath } from "url";
|
|
6
21
|
import { Command, Option } from "commander";
|
|
7
22
|
import open from "open";
|
|
8
23
|
|
|
9
|
-
// src/server.ts
|
|
10
|
-
import { createReadStream } from "fs";
|
|
11
|
-
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
12
|
-
import { createServer } from "http";
|
|
13
|
-
import path2 from "path";
|
|
14
|
-
|
|
15
24
|
// src/project-store.ts
|
|
16
25
|
import { createHash, randomUUID } from "crypto";
|
|
17
26
|
import { constants } from "fs";
|
|
18
27
|
import { access, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "fs/promises";
|
|
19
28
|
import path from "path";
|
|
20
29
|
|
|
21
|
-
// src/device-mockups.ts
|
|
22
|
-
var MOCKUP_BUNDLE_FILENAME = "storeshot-mockups.json";
|
|
23
|
-
var MOCKUP_BUNDLE_FORMAT = "storeshot-mockup-bundle";
|
|
24
|
-
var identifierPattern = /^[a-z0-9][a-z0-9-]*$/;
|
|
25
|
-
var styles = /* @__PURE__ */ new Set([
|
|
26
|
-
"3d",
|
|
27
|
-
"colored",
|
|
28
|
-
"colored-3d",
|
|
29
|
-
"handheld",
|
|
30
|
-
"handheld-dim",
|
|
31
|
-
"handheld-silhouette",
|
|
32
|
-
"handheld-styles",
|
|
33
|
-
"standard",
|
|
34
|
-
"textured"
|
|
35
|
-
]);
|
|
36
|
-
var platforms = /* @__PURE__ */ new Set(["iphone", "ipad", "mac", "watch"]);
|
|
37
|
-
var frameExtensions = /* @__PURE__ */ new Set([".jpeg", ".jpg", ".png", ".svg", ".webp"]);
|
|
38
|
-
function emptyDeviceMockupCatalog() {
|
|
39
|
-
return { bundles: [], groups: [], mockups: [] };
|
|
40
|
-
}
|
|
41
|
-
function parseMockupBundleManifest(value) {
|
|
42
|
-
if (!isRecord(value)) throw new Error(`${MOCKUP_BUNDLE_FILENAME} must contain a JSON object`);
|
|
43
|
-
if (value.format !== MOCKUP_BUNDLE_FORMAT || value.version !== 1) {
|
|
44
|
-
throw new Error("Unsupported StoreShot mockup bundle format");
|
|
45
|
-
}
|
|
46
|
-
if (!Array.isArray(value.mockups) || value.mockups.length === 0) {
|
|
47
|
-
throw new Error("A mockup bundle must contain at least one mockup");
|
|
48
|
-
}
|
|
49
|
-
const id = readIdentifier(value.id, "Bundle id");
|
|
50
|
-
const mockups = value.mockups.map(parseMockupEntry);
|
|
51
|
-
const entryIds = /* @__PURE__ */ new Set();
|
|
52
|
-
for (const mockup of mockups) {
|
|
53
|
-
if (entryIds.has(mockup.id)) throw new Error(`Duplicate mockup id: ${mockup.id}`);
|
|
54
|
-
entryIds.add(mockup.id);
|
|
55
|
-
}
|
|
56
|
-
return {
|
|
57
|
-
format: MOCKUP_BUNDLE_FORMAT,
|
|
58
|
-
version: 1,
|
|
59
|
-
id,
|
|
60
|
-
name: readString(value.name, "Bundle name"),
|
|
61
|
-
author: readString(value.author, "Bundle author"),
|
|
62
|
-
license: parseLicense(value.license),
|
|
63
|
-
...value.source === void 0 ? {} : { source: parseSource(value.source) },
|
|
64
|
-
mockups
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
function resolveMockupBundle(manifest, baseUrl, origin) {
|
|
68
|
-
const bundlePrefix = `${manifest.id}/`;
|
|
69
|
-
const mockups = manifest.mockups.map((entry) => ({
|
|
70
|
-
...entry,
|
|
71
|
-
id: `${bundlePrefix}${entry.id}`,
|
|
72
|
-
bundleId: manifest.id,
|
|
73
|
-
groupId: `${bundlePrefix}${entry.groupId}`,
|
|
74
|
-
frameUrl: `${baseUrl}${encodeBundlePath(entry.frame)}`,
|
|
75
|
-
thumbnailUrl: `${baseUrl}${encodeBundlePath(entry.thumbnail ?? entry.frame)}`
|
|
76
|
-
}));
|
|
77
|
-
const groupEntries = /* @__PURE__ */ new Map();
|
|
78
|
-
for (const mockup of mockups) {
|
|
79
|
-
const entries = groupEntries.get(mockup.groupId) ?? [];
|
|
80
|
-
entries.push(mockup);
|
|
81
|
-
groupEntries.set(mockup.groupId, entries);
|
|
82
|
-
}
|
|
83
|
-
return {
|
|
84
|
-
bundles: [{
|
|
85
|
-
id: manifest.id,
|
|
86
|
-
name: manifest.name,
|
|
87
|
-
author: manifest.author,
|
|
88
|
-
license: manifest.license,
|
|
89
|
-
...manifest.source ? { source: manifest.source } : {},
|
|
90
|
-
mockupCount: mockups.length,
|
|
91
|
-
origin
|
|
92
|
-
}],
|
|
93
|
-
groups: [...groupEntries.entries()].map(([id, entries]) => ({
|
|
94
|
-
id,
|
|
95
|
-
bundleId: manifest.id,
|
|
96
|
-
name: manifest.mockups.find((entry) => `${bundlePrefix}${entry.groupId}` === id)?.groupName ?? entries[0].name,
|
|
97
|
-
platform: entries[0].platform,
|
|
98
|
-
style: entries[0].style,
|
|
99
|
-
thumbnailUrl: entries[0].thumbnailUrl,
|
|
100
|
-
count: entries.length
|
|
101
|
-
})),
|
|
102
|
-
mockups
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
function mergeDeviceMockupCatalogs(...catalogs) {
|
|
106
|
-
const result = emptyDeviceMockupCatalog();
|
|
107
|
-
const bundleIds = /* @__PURE__ */ new Set();
|
|
108
|
-
const groupIds = /* @__PURE__ */ new Set();
|
|
109
|
-
const mockupIds = /* @__PURE__ */ new Set();
|
|
110
|
-
for (const catalog of catalogs) {
|
|
111
|
-
for (const bundle of catalog.bundles) {
|
|
112
|
-
if (bundleIds.has(bundle.id)) continue;
|
|
113
|
-
bundleIds.add(bundle.id);
|
|
114
|
-
result.bundles.push(bundle);
|
|
115
|
-
}
|
|
116
|
-
for (const group of catalog.groups) {
|
|
117
|
-
if (groupIds.has(group.id)) continue;
|
|
118
|
-
groupIds.add(group.id);
|
|
119
|
-
result.groups.push(group);
|
|
120
|
-
}
|
|
121
|
-
for (const mockup of catalog.mockups) {
|
|
122
|
-
if (mockupIds.has(mockup.id)) continue;
|
|
123
|
-
mockupIds.add(mockup.id);
|
|
124
|
-
result.mockups.push(mockup);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return result;
|
|
128
|
-
}
|
|
129
|
-
function bundleAssetPaths(manifest) {
|
|
130
|
-
const paths = /* @__PURE__ */ new Set();
|
|
131
|
-
if (manifest.license.file) paths.add(manifest.license.file);
|
|
132
|
-
for (const mockup of manifest.mockups) {
|
|
133
|
-
paths.add(mockup.frame);
|
|
134
|
-
if (mockup.thumbnail) paths.add(mockup.thumbnail);
|
|
135
|
-
}
|
|
136
|
-
return [...paths];
|
|
137
|
-
}
|
|
138
|
-
function isSafeBundlePath(value) {
|
|
139
|
-
if (!value || value.startsWith("/") || value.includes("\\")) return false;
|
|
140
|
-
const parts = value.split("/");
|
|
141
|
-
return parts.every((part) => part.length > 0 && part !== "." && part !== "..");
|
|
142
|
-
}
|
|
143
|
-
function parseMockupEntry(value) {
|
|
144
|
-
if (!isRecord(value)) throw new Error("Mockup entry must be an object");
|
|
145
|
-
const platform = readString(value.platform, "Mockup platform");
|
|
146
|
-
const style = readString(value.style, "Mockup style");
|
|
147
|
-
if (!platforms.has(platform)) throw new Error(`Unsupported mockup platform: ${platform}`);
|
|
148
|
-
if (!styles.has(style)) throw new Error(`Unsupported mockup style: ${style}`);
|
|
149
|
-
const frame = readFramePath(value.frame, "Mockup frame");
|
|
150
|
-
const thumbnail = value.thumbnail === void 0 ? void 0 : readFramePath(value.thumbnail, "Mockup thumbnail");
|
|
151
|
-
return {
|
|
152
|
-
id: readIdentifier(value.id, "Mockup id"),
|
|
153
|
-
groupId: readIdentifier(value.groupId, "Mockup group id"),
|
|
154
|
-
groupName: readString(value.groupName, "Mockup group name"),
|
|
155
|
-
name: readString(value.name, "Mockup name"),
|
|
156
|
-
description: readString(value.description, "Mockup description"),
|
|
157
|
-
platform,
|
|
158
|
-
style,
|
|
159
|
-
frame,
|
|
160
|
-
...thumbnail ? { thumbnail } : {},
|
|
161
|
-
width: readDimension(value.width, "Mockup width"),
|
|
162
|
-
height: readDimension(value.height, "Mockup height"),
|
|
163
|
-
screen: parseScreen(value.screen)
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
function parseScreen(value) {
|
|
167
|
-
if (!isRecord(value)) throw new Error("Mockup screen must be an object");
|
|
168
|
-
if (value.kind === "rect") {
|
|
169
|
-
return {
|
|
170
|
-
kind: "rect",
|
|
171
|
-
x: readNumber(value.x, "Screen x"),
|
|
172
|
-
y: readNumber(value.y, "Screen y"),
|
|
173
|
-
width: readDimension(value.width, "Screen width"),
|
|
174
|
-
height: readDimension(value.height, "Screen height"),
|
|
175
|
-
cornerRadius: readNonNegative(value.cornerRadius, "Screen corner radius")
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
if (value.kind === "projective") {
|
|
179
|
-
if (!Array.isArray(value.transform) || value.transform.length !== 3) throw new Error("Projective transform must have three rows");
|
|
180
|
-
const transform = value.transform.map((row) => {
|
|
181
|
-
if (!Array.isArray(row) || row.length !== 3) throw new Error("Each projective transform row must have three numbers");
|
|
182
|
-
return row.map((entry) => readNumber(entry, "Projective transform value"));
|
|
183
|
-
});
|
|
184
|
-
if (!isRecord(value.sourceCornerRadius)) throw new Error("Projective source corner radius must be an object");
|
|
185
|
-
return {
|
|
186
|
-
kind: "projective",
|
|
187
|
-
transform,
|
|
188
|
-
sourceCornerRadius: {
|
|
189
|
-
x: readNonNegative(value.sourceCornerRadius.x, "Source corner radius x"),
|
|
190
|
-
y: readNonNegative(value.sourceCornerRadius.y, "Source corner radius y")
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
throw new Error("Unsupported mockup screen geometry");
|
|
195
|
-
}
|
|
196
|
-
function parseLicense(value) {
|
|
197
|
-
if (!isRecord(value)) throw new Error("Bundle license must be an object");
|
|
198
|
-
const file = value.file === void 0 ? void 0 : readBundlePath(value.file, "License file");
|
|
199
|
-
return {
|
|
200
|
-
name: readString(value.name, "License name"),
|
|
201
|
-
...value.url === void 0 ? {} : { url: readString(value.url, "License URL") },
|
|
202
|
-
...file ? { file } : {}
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
function parseSource(value) {
|
|
206
|
-
if (!isRecord(value)) throw new Error("Bundle source must be an object");
|
|
207
|
-
return {
|
|
208
|
-
...value.name === void 0 ? {} : { name: readString(value.name, "Source name") },
|
|
209
|
-
url: readString(value.url, "Source URL"),
|
|
210
|
-
...value.revision === void 0 ? {} : { revision: readString(value.revision, "Source revision") }
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
function readFramePath(value, name) {
|
|
214
|
-
const result = readBundlePath(value, name);
|
|
215
|
-
const extensionIndex = result.lastIndexOf(".");
|
|
216
|
-
const extension = extensionIndex >= 0 ? result.slice(extensionIndex).toLowerCase() : "";
|
|
217
|
-
if (!frameExtensions.has(extension)) throw new Error(`${name} has an unsupported file type`);
|
|
218
|
-
return result;
|
|
219
|
-
}
|
|
220
|
-
function readBundlePath(value, name) {
|
|
221
|
-
const result = readString(value, name);
|
|
222
|
-
if (!isSafeBundlePath(result)) throw new Error(`${name} must be a relative path inside the bundle`);
|
|
223
|
-
return result;
|
|
224
|
-
}
|
|
225
|
-
function readIdentifier(value, name) {
|
|
226
|
-
const result = readString(value, name);
|
|
227
|
-
if (!identifierPattern.test(result)) throw new Error(`${name} must use lowercase letters, numbers, and hyphens`);
|
|
228
|
-
return result;
|
|
229
|
-
}
|
|
230
|
-
function readString(value, name) {
|
|
231
|
-
const result = typeof value === "string" ? value.trim() : "";
|
|
232
|
-
if (!result) throw new Error(`${name} must be a non-empty string`);
|
|
233
|
-
return result;
|
|
234
|
-
}
|
|
235
|
-
function readNumber(value, name) {
|
|
236
|
-
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a number`);
|
|
237
|
-
return value;
|
|
238
|
-
}
|
|
239
|
-
function readDimension(value, name) {
|
|
240
|
-
const result = readNumber(value, name);
|
|
241
|
-
if (result <= 0 || result > 2e4) throw new Error(`${name} is outside the supported range`);
|
|
242
|
-
return result;
|
|
243
|
-
}
|
|
244
|
-
function readNonNegative(value, name) {
|
|
245
|
-
const result = readNumber(value, name);
|
|
246
|
-
if (result < 0 || result > 2e4) throw new Error(`${name} is outside the supported range`);
|
|
247
|
-
return result;
|
|
248
|
-
}
|
|
249
|
-
function encodeBundlePath(value) {
|
|
250
|
-
return value.split("/").map(encodeURIComponent).join("/");
|
|
251
|
-
}
|
|
252
|
-
function isRecord(value) {
|
|
253
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
30
|
// src/image-metadata.ts
|
|
257
31
|
function readImageMetadata(contents) {
|
|
258
32
|
return readPngMetadata(contents) ?? readJpegMetadata(contents) ?? readWebpMetadata(contents) ?? readSvgMetadata(contents);
|
|
@@ -385,10 +159,6 @@ function cloneScreenshotArea(area, { idFactory = defaultIdFactory, name = area.n
|
|
|
385
159
|
};
|
|
386
160
|
}
|
|
387
161
|
|
|
388
|
-
// src/shared.ts
|
|
389
|
-
var ASSET_CATEGORIES = ["screenshots", "brand", "logos", "other"];
|
|
390
|
-
var SCREENSHOT_DEVICE_TYPES = ["iphone", "ipad", "mac", "watch"];
|
|
391
|
-
|
|
392
162
|
// src/project-store.ts
|
|
393
163
|
var CONFIG_FILENAME = "storeshot.json";
|
|
394
164
|
var ASSETS_DIRECTORY = "assets";
|
|
@@ -444,10 +214,10 @@ var ProjectStore = class {
|
|
|
444
214
|
}
|
|
445
215
|
async readConfig() {
|
|
446
216
|
const value = JSON.parse(await this.readManagedFile(this.configPath, this.root, "utf8"));
|
|
447
|
-
return
|
|
217
|
+
return parseStoreShotConfig(value);
|
|
448
218
|
}
|
|
449
219
|
async writeConfig(config) {
|
|
450
|
-
const value =
|
|
220
|
+
const value = parseStoreShotConfig(config);
|
|
451
221
|
await this.runExclusive(this.configPath, async () => {
|
|
452
222
|
await this.assertSafeWriteTarget(this.configPath, this.root);
|
|
453
223
|
await writeJson(this.configPath, value);
|
|
@@ -523,7 +293,7 @@ var ProjectStore = class {
|
|
|
523
293
|
async listSets() {
|
|
524
294
|
const entries = await readdir(this.setsPath, { withFileTypes: true });
|
|
525
295
|
const sets = await Promise.all(
|
|
526
|
-
entries.filter((entry) => entry.isFile() && path.extname(entry.name) === ".json").map(async (entry) =>
|
|
296
|
+
entries.filter((entry) => entry.isFile() && path.extname(entry.name) === ".json").map(async (entry) => parseScreenshotSet(JSON.parse(await this.readManagedFile(path.join(this.setsPath, entry.name), this.setsPath, "utf8"))))
|
|
527
297
|
);
|
|
528
298
|
return sets.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
529
299
|
}
|
|
@@ -598,7 +368,7 @@ var ProjectStore = class {
|
|
|
598
368
|
if (id !== set.id) throw new Error("Set id cannot be changed");
|
|
599
369
|
const target = this.resolveSet(id);
|
|
600
370
|
return this.runExclusive(target, async () => {
|
|
601
|
-
const value =
|
|
371
|
+
const value = parseScreenshotSet({ ...set, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
602
372
|
await this.assertSafeWriteTarget(target, this.setsPath);
|
|
603
373
|
await writeJson(target, value);
|
|
604
374
|
return value;
|
|
@@ -608,8 +378,8 @@ var ProjectStore = class {
|
|
|
608
378
|
const metadata = parseSetMetadataInput(input);
|
|
609
379
|
const target = this.resolveSet(id);
|
|
610
380
|
return this.runExclusive(target, async () => {
|
|
611
|
-
const current =
|
|
612
|
-
const value =
|
|
381
|
+
const current = parseScreenshotSet(JSON.parse(await this.readManagedFile(target, this.setsPath, "utf8")));
|
|
382
|
+
const value = parseScreenshotSet({ ...current, ...metadata, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
613
383
|
await writeJson(target, value);
|
|
614
384
|
return value;
|
|
615
385
|
});
|
|
@@ -775,9 +545,9 @@ var ProjectStore = class {
|
|
|
775
545
|
const target = path.join(this.assetsPath, ASSET_METADATA_FILENAME);
|
|
776
546
|
try {
|
|
777
547
|
const value = JSON.parse(await this.readManagedFile(target, this.assetsPath, "utf8"));
|
|
778
|
-
if (!
|
|
548
|
+
if (!isRecord(value) || value.version !== 1 || !isRecord(value.assets)) return {};
|
|
779
549
|
return Object.fromEntries(Object.entries(value.assets).flatMap(([id, entry]) => {
|
|
780
|
-
if (!
|
|
550
|
+
if (!isRecord(entry) || !SCREENSHOT_DEVICE_TYPES.includes(entry.deviceType)) return [];
|
|
781
551
|
return [[id, { deviceType: entry.deviceType }]];
|
|
782
552
|
}));
|
|
783
553
|
} catch (error) {
|
|
@@ -808,7 +578,7 @@ var ProjectStore = class {
|
|
|
808
578
|
}
|
|
809
579
|
async readSet(id) {
|
|
810
580
|
const target = this.resolveSet(id);
|
|
811
|
-
return
|
|
581
|
+
return parseScreenshotSet(JSON.parse(await this.readManagedFile(target, this.setsPath, "utf8")));
|
|
812
582
|
}
|
|
813
583
|
async runExclusive(key, operation) {
|
|
814
584
|
const previous = this.operations.get(key) ?? Promise.resolve();
|
|
@@ -888,21 +658,24 @@ var DuplicateAssetError = class extends Error {
|
|
|
888
658
|
function hash(contents) {
|
|
889
659
|
return createHash("sha256").update(contents).digest("hex");
|
|
890
660
|
}
|
|
891
|
-
function
|
|
892
|
-
if (!
|
|
893
|
-
|
|
894
|
-
const
|
|
895
|
-
if (
|
|
896
|
-
|
|
661
|
+
function parseStoreShotConfig(value) {
|
|
662
|
+
if (!isRecord(value)) throw new Error("storeshot.json must contain a JSON object");
|
|
663
|
+
if (value.version !== 1) throw new Error("Unsupported storeshot.json version");
|
|
664
|
+
const appName = readString(value.appName, "appName");
|
|
665
|
+
if (!Array.isArray(value.platforms) || value.platforms.length === 0 || value.platforms.some((platform) => platform !== "ios" && platform !== "android")) {
|
|
666
|
+
throw new Error("platforms must contain only ios or android");
|
|
667
|
+
}
|
|
668
|
+
const platforms = value.platforms;
|
|
669
|
+
return { version: 1, appName, platforms: [...new Set(platforms)] };
|
|
897
670
|
}
|
|
898
671
|
function parseCreateSetInput(value) {
|
|
899
|
-
if (!
|
|
672
|
+
if (!isRecord(value)) throw new Error("Set details must be an object");
|
|
900
673
|
return {
|
|
901
|
-
name:
|
|
902
|
-
locale:
|
|
903
|
-
device:
|
|
904
|
-
width:
|
|
905
|
-
height:
|
|
674
|
+
name: readString(value.name, "Set name"),
|
|
675
|
+
locale: readString(value.locale, "Locale"),
|
|
676
|
+
device: readString(value.device, "Device"),
|
|
677
|
+
width: readDimension(value.width, "Canvas width"),
|
|
678
|
+
height: readDimension(value.height, "Canvas height")
|
|
906
679
|
};
|
|
907
680
|
}
|
|
908
681
|
function duplicateName(sourceName, existingNames) {
|
|
@@ -914,70 +687,73 @@ function duplicateName(sourceName, existingNames) {
|
|
|
914
687
|
return `${baseName} ${copyNumber}`;
|
|
915
688
|
}
|
|
916
689
|
function parseSetMetadataInput(value) {
|
|
917
|
-
if (!
|
|
690
|
+
if (!isRecord(value)) throw new Error("Set settings must be an object");
|
|
918
691
|
return {
|
|
919
|
-
name:
|
|
920
|
-
locale:
|
|
921
|
-
device:
|
|
692
|
+
name: readString(value.name, "Set name"),
|
|
693
|
+
locale: readString(value.locale, "Locale"),
|
|
694
|
+
device: readString(value.device, "Device")
|
|
922
695
|
};
|
|
923
696
|
}
|
|
924
|
-
function
|
|
925
|
-
if (!
|
|
926
|
-
|
|
697
|
+
function parseScreenshotSet(value) {
|
|
698
|
+
if (!isRecord(value)) throw new Error("Set file must contain an object");
|
|
699
|
+
if (value.version !== 1) throw new Error("Unsupported set file version");
|
|
700
|
+
const id = readString(value.id, "Set id");
|
|
927
701
|
if (!safeIdentifierPattern.test(id)) throw new Error("Set id is invalid");
|
|
928
|
-
if (!
|
|
702
|
+
if (!isRecord(value.canvas)) throw new Error("Set canvas is invalid");
|
|
929
703
|
if (!Array.isArray(value.areas) || value.areas.length === 0) throw new Error("A set needs at least one screenshot area");
|
|
930
704
|
return {
|
|
931
705
|
version: 1,
|
|
932
706
|
id,
|
|
933
|
-
name:
|
|
934
|
-
locale:
|
|
935
|
-
device:
|
|
707
|
+
name: readString(value.name, "Set name"),
|
|
708
|
+
locale: readString(value.locale, "Locale"),
|
|
709
|
+
device: readString(value.device, "Device"),
|
|
936
710
|
canvas: {
|
|
937
|
-
width:
|
|
938
|
-
height:
|
|
711
|
+
width: readDimension(value.canvas.width, "Canvas width"),
|
|
712
|
+
height: readDimension(value.canvas.height, "Canvas height")
|
|
939
713
|
},
|
|
940
714
|
areas: value.areas.map(parseArea),
|
|
941
|
-
createdAt:
|
|
942
|
-
updatedAt:
|
|
715
|
+
createdAt: readString(value.createdAt, "Created time"),
|
|
716
|
+
updatedAt: readString(value.updatedAt, "Updated time")
|
|
943
717
|
};
|
|
944
718
|
}
|
|
945
719
|
function parseArea(value) {
|
|
946
|
-
if (!
|
|
947
|
-
const background =
|
|
720
|
+
if (!isRecord(value)) throw new Error("Screenshot area is invalid");
|
|
721
|
+
const background = readString(value.background, "Area background");
|
|
948
722
|
if (!hexColorPattern.test(background)) throw new Error("Area background must be a hex color");
|
|
949
723
|
if (!Array.isArray(value.elements)) throw new Error("Area elements must be an array");
|
|
950
724
|
return {
|
|
951
|
-
id:
|
|
952
|
-
name:
|
|
725
|
+
id: readString(value.id, "Area id"),
|
|
726
|
+
name: readString(value.name, "Area name"),
|
|
953
727
|
background,
|
|
954
728
|
elements: value.elements.map(parseElement)
|
|
955
729
|
};
|
|
956
730
|
}
|
|
957
731
|
function parseImageSource(value, legacyAssetId) {
|
|
958
|
-
if (
|
|
959
|
-
if (value.kind === "builtin") return { kind: "builtin", id:
|
|
960
|
-
if (value.kind === "asset") return { kind: "asset", assetId:
|
|
732
|
+
if (isRecord(value)) {
|
|
733
|
+
if (value.kind === "builtin") return { kind: "builtin", id: readString(value.id, "Built-in artwork id") };
|
|
734
|
+
if (value.kind === "asset") return { kind: "asset", assetId: readString(value.assetId, "Asset id") };
|
|
961
735
|
throw new Error("Image source type is invalid");
|
|
962
736
|
}
|
|
963
|
-
if (legacyAssetId !== void 0) return { kind: "asset", assetId:
|
|
737
|
+
if (legacyAssetId !== void 0) return { kind: "asset", assetId: readString(legacyAssetId, "Asset id") };
|
|
964
738
|
throw new Error("Image source is invalid");
|
|
965
739
|
}
|
|
966
740
|
function parseElement(value) {
|
|
967
|
-
if (!
|
|
741
|
+
if (!isRecord(value)) throw new Error("Canvas element is invalid");
|
|
968
742
|
const base = {
|
|
969
|
-
id:
|
|
970
|
-
x:
|
|
971
|
-
y:
|
|
972
|
-
width:
|
|
973
|
-
height:
|
|
974
|
-
rotation:
|
|
975
|
-
opacity: readOptionalRange(value.opacity, 1, 0, 1, "Element opacity")
|
|
743
|
+
id: readString(value.id, "Element id"),
|
|
744
|
+
x: readNumber(value.x, "Element x"),
|
|
745
|
+
y: readNumber(value.y, "Element y"),
|
|
746
|
+
width: readDimension(value.width, "Element width"),
|
|
747
|
+
height: readDimension(value.height, "Element height"),
|
|
748
|
+
rotation: readNumber(value.rotation, "Element rotation"),
|
|
749
|
+
opacity: readOptionalRange(value.opacity, 1, 0, 1, "Element opacity"),
|
|
750
|
+
flipX: readOptionalBoolean(value.flipX, false, "Element horizontal flip"),
|
|
751
|
+
flipY: readOptionalBoolean(value.flipY, false, "Element vertical flip")
|
|
976
752
|
};
|
|
977
753
|
if (value.type === "image") {
|
|
978
754
|
if (value.fit !== "contain" && value.fit !== "cover") throw new Error("Image fit is invalid");
|
|
979
755
|
const source = parseImageSource(value.source, value.assetId);
|
|
980
|
-
const fill = value.fill === void 0 ? void 0 :
|
|
756
|
+
const fill = value.fill === void 0 ? void 0 : readString(value.fill, "Image fill");
|
|
981
757
|
if (fill !== void 0 && !hexColorPattern.test(fill)) throw new Error("Image fill must be a hex color");
|
|
982
758
|
return { ...base, type: "image", source, fit: value.fit, ...fill === void 0 ? {} : { fill } };
|
|
983
759
|
}
|
|
@@ -985,13 +761,13 @@ function parseElement(value) {
|
|
|
985
761
|
return {
|
|
986
762
|
...base,
|
|
987
763
|
type: "mockup",
|
|
988
|
-
mockupId:
|
|
989
|
-
assetId:
|
|
764
|
+
mockupId: readString(value.mockupId, "Device mockup id"),
|
|
765
|
+
assetId: readString(value.assetId, "Asset id")
|
|
990
766
|
};
|
|
991
767
|
}
|
|
992
768
|
if (value.type === "element") {
|
|
993
769
|
const source = parseImageSource(value.source);
|
|
994
|
-
const fill = value.fill === void 0 ? void 0 :
|
|
770
|
+
const fill = value.fill === void 0 ? void 0 : readString(value.fill, "Image fill");
|
|
995
771
|
if (fill !== void 0 && !hexColorPattern.test(fill)) throw new Error("Image fill must be a hex color");
|
|
996
772
|
return {
|
|
997
773
|
...base,
|
|
@@ -1009,15 +785,15 @@ function parseElement(value) {
|
|
|
1009
785
|
if (value.textAlign !== "left" && value.textAlign !== "center" && value.textAlign !== "right") {
|
|
1010
786
|
throw new Error("Text alignment is invalid");
|
|
1011
787
|
}
|
|
1012
|
-
const color =
|
|
788
|
+
const color = readString(value.color, "Text color");
|
|
1013
789
|
if (!hexColorPattern.test(color)) throw new Error("Text color must be a hex color");
|
|
1014
|
-
const lineHeight = value.lineHeight === void 0 ? void 0 :
|
|
790
|
+
const lineHeight = value.lineHeight === void 0 ? void 0 : readDimension(value.lineHeight, "Text line height");
|
|
1015
791
|
return {
|
|
1016
792
|
...base,
|
|
1017
793
|
type: "text",
|
|
1018
794
|
text: typeof value.text === "string" ? value.text : "",
|
|
1019
795
|
fontFamily: readOptionalString(value.fontFamily, "Geist Variable"),
|
|
1020
|
-
fontSize:
|
|
796
|
+
fontSize: readDimension(value.fontSize, "Font size"),
|
|
1021
797
|
fontWeight,
|
|
1022
798
|
...lineHeight === void 0 ? {} : { lineHeight },
|
|
1023
799
|
color,
|
|
@@ -1026,7 +802,7 @@ function parseElement(value) {
|
|
|
1026
802
|
}
|
|
1027
803
|
if (value.type === "shape") {
|
|
1028
804
|
if (value.shape !== "rectangle") throw new Error("Shape type is invalid");
|
|
1029
|
-
const fill =
|
|
805
|
+
const fill = readString(value.fill, "Shape fill");
|
|
1030
806
|
if (!hexColorPattern.test(fill)) throw new Error("Shape fill must be a hex color");
|
|
1031
807
|
return {
|
|
1032
808
|
...base,
|
|
@@ -1038,33 +814,38 @@ function parseElement(value) {
|
|
|
1038
814
|
}
|
|
1039
815
|
throw new Error("Unsupported canvas element type");
|
|
1040
816
|
}
|
|
1041
|
-
function
|
|
817
|
+
function readString(value, name) {
|
|
1042
818
|
const result = typeof value === "string" ? value.trim() : "";
|
|
1043
819
|
if (!result) throw new Error(`${name} must be a non-empty string`);
|
|
1044
820
|
return result;
|
|
1045
821
|
}
|
|
1046
|
-
function
|
|
822
|
+
function readNumber(value, name) {
|
|
1047
823
|
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a number`);
|
|
1048
824
|
return value;
|
|
1049
825
|
}
|
|
1050
826
|
function readOptionalRange(value, fallback, min, max, name) {
|
|
1051
827
|
if (value === void 0) return fallback;
|
|
1052
|
-
const result =
|
|
828
|
+
const result = readNumber(value, name);
|
|
1053
829
|
if (result < min || result > max) throw new Error(`${name} is outside the supported range`);
|
|
1054
830
|
return result;
|
|
1055
831
|
}
|
|
832
|
+
function readOptionalBoolean(value, fallback, name) {
|
|
833
|
+
if (value === void 0) return fallback;
|
|
834
|
+
if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`);
|
|
835
|
+
return value;
|
|
836
|
+
}
|
|
1056
837
|
function readOptionalString(value, fallback) {
|
|
1057
838
|
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
1058
839
|
}
|
|
1059
|
-
function
|
|
1060
|
-
const result =
|
|
840
|
+
function readDimension(value, name) {
|
|
841
|
+
const result = readNumber(value, name);
|
|
1061
842
|
if (result <= 0 || result > 2e4) throw new Error(`${name} is outside the supported range`);
|
|
1062
843
|
return result;
|
|
1063
844
|
}
|
|
1064
845
|
function isAssetCategory(value) {
|
|
1065
846
|
return ASSET_CATEGORIES.includes(value);
|
|
1066
847
|
}
|
|
1067
|
-
function
|
|
848
|
+
function isRecord(value) {
|
|
1068
849
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1069
850
|
}
|
|
1070
851
|
function slugify(value) {
|
|
@@ -1094,10 +875,15 @@ function assertValidImage(filename, contents) {
|
|
|
1094
875
|
}
|
|
1095
876
|
|
|
1096
877
|
// src/server.ts
|
|
878
|
+
import { createReadStream, watch } from "fs";
|
|
879
|
+
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
880
|
+
import { createServer } from "http";
|
|
881
|
+
import path2 from "path";
|
|
1097
882
|
var MAX_REQUEST_BYTES = 25 * 1024 * 1024;
|
|
1098
883
|
async function startServer(options) {
|
|
1099
884
|
const store = new ProjectStore(options.projectDirectory);
|
|
1100
885
|
await store.initialize();
|
|
886
|
+
const projectEvents = new ProjectEventHub(store.root);
|
|
1101
887
|
let vite;
|
|
1102
888
|
if (options.useVite) {
|
|
1103
889
|
const { createServer: createViteServer } = await import("vite");
|
|
@@ -1110,7 +896,7 @@ async function startServer(options) {
|
|
|
1110
896
|
const server = createServer(async (request, response) => {
|
|
1111
897
|
try {
|
|
1112
898
|
if ((request.url ?? "").startsWith("/api/")) assertAllowedBrowserRequest(request, options.host);
|
|
1113
|
-
if (await handleApiRequest(request, response, store)) return;
|
|
899
|
+
if (await handleApiRequest(request, response, store, projectEvents)) return;
|
|
1114
900
|
if (vite) {
|
|
1115
901
|
vite.middlewares(request, response);
|
|
1116
902
|
return;
|
|
@@ -1131,6 +917,7 @@ async function startServer(options) {
|
|
|
1131
917
|
store,
|
|
1132
918
|
port: server.address().port,
|
|
1133
919
|
close: async () => {
|
|
920
|
+
projectEvents.close();
|
|
1134
921
|
await vite?.close();
|
|
1135
922
|
await new Promise((resolve, reject) => {
|
|
1136
923
|
server.close((error) => error ? reject(error) : resolve());
|
|
@@ -1138,8 +925,12 @@ async function startServer(options) {
|
|
|
1138
925
|
}
|
|
1139
926
|
};
|
|
1140
927
|
}
|
|
1141
|
-
async function handleApiRequest(request, response, store) {
|
|
928
|
+
async function handleApiRequest(request, response, store, projectEvents) {
|
|
1142
929
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
930
|
+
if (request.method === "GET" && url.pathname === "/api/events") {
|
|
931
|
+
projectEvents.subscribe(request, response);
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
1143
934
|
if (request.method === "GET" && url.pathname === "/api/project") {
|
|
1144
935
|
sendJson(response, 200, await store.readProject());
|
|
1145
936
|
return true;
|
|
@@ -1418,41 +1209,577 @@ function contentTypeFor(filename) {
|
|
|
1418
1209
|
return "application/octet-stream";
|
|
1419
1210
|
}
|
|
1420
1211
|
}
|
|
1212
|
+
var ProjectEventHub = class {
|
|
1213
|
+
clients = /* @__PURE__ */ new Set();
|
|
1214
|
+
scopes = /* @__PURE__ */ new Set();
|
|
1215
|
+
watcher;
|
|
1216
|
+
heartbeat;
|
|
1217
|
+
broadcastTimer;
|
|
1218
|
+
revision = 0;
|
|
1219
|
+
constructor(root) {
|
|
1220
|
+
this.watcher = watch(root, { recursive: true, encoding: "utf8" }, (_event, filename) => {
|
|
1221
|
+
const relativePath = typeof filename === "string" ? filename.split(path2.sep).join("/") : "";
|
|
1222
|
+
const scope = changeScope(relativePath);
|
|
1223
|
+
if (!scope) return;
|
|
1224
|
+
this.scopes.add(scope);
|
|
1225
|
+
if (this.broadcastTimer) clearTimeout(this.broadcastTimer);
|
|
1226
|
+
this.broadcastTimer = setTimeout(() => this.broadcast(), 75);
|
|
1227
|
+
});
|
|
1228
|
+
this.watcher.on("error", (error) => console.error("StoreShot project watcher failed", error));
|
|
1229
|
+
this.heartbeat = setInterval(() => {
|
|
1230
|
+
for (const response of this.clients) response.write(": keep-alive\n\n");
|
|
1231
|
+
}, 15e3);
|
|
1232
|
+
this.heartbeat.unref();
|
|
1233
|
+
}
|
|
1234
|
+
subscribe(request, response) {
|
|
1235
|
+
response.writeHead(200, {
|
|
1236
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
1237
|
+
"Cache-Control": "no-cache, no-transform",
|
|
1238
|
+
Connection: "keep-alive",
|
|
1239
|
+
"X-Accel-Buffering": "no"
|
|
1240
|
+
});
|
|
1241
|
+
response.write(`event: ready
|
|
1242
|
+
data: ${JSON.stringify({ revision: this.revision })}
|
|
1243
|
+
|
|
1244
|
+
`);
|
|
1245
|
+
this.clients.add(response);
|
|
1246
|
+
request.once("close", () => this.clients.delete(response));
|
|
1247
|
+
}
|
|
1248
|
+
close() {
|
|
1249
|
+
if (this.broadcastTimer) clearTimeout(this.broadcastTimer);
|
|
1250
|
+
clearInterval(this.heartbeat);
|
|
1251
|
+
this.watcher.close();
|
|
1252
|
+
for (const response of this.clients) response.end();
|
|
1253
|
+
this.clients.clear();
|
|
1254
|
+
}
|
|
1255
|
+
broadcast() {
|
|
1256
|
+
this.broadcastTimer = void 0;
|
|
1257
|
+
if (this.scopes.size === 0) return;
|
|
1258
|
+
this.revision += 1;
|
|
1259
|
+
const data = JSON.stringify({ revision: this.revision, scopes: [...this.scopes].sort() });
|
|
1260
|
+
this.scopes.clear();
|
|
1261
|
+
for (const response of this.clients) response.write(`event: project
|
|
1262
|
+
data: ${data}
|
|
1263
|
+
|
|
1264
|
+
`);
|
|
1265
|
+
}
|
|
1266
|
+
};
|
|
1267
|
+
function changeScope(relativePath) {
|
|
1268
|
+
if (!relativePath) return "project";
|
|
1269
|
+
if (relativePath === "storeshot.json" || relativePath.startsWith(".storeshot.json.")) return "config";
|
|
1270
|
+
if (relativePath === "sets" || relativePath.startsWith("sets/")) return "sets";
|
|
1271
|
+
if (relativePath === "assets" || relativePath.startsWith("assets/")) return "assets";
|
|
1272
|
+
if (relativePath.startsWith("mockup-bundles/.imports/")) return void 0;
|
|
1273
|
+
if (relativePath === "mockup-bundles" || relativePath.startsWith("mockup-bundles/")) return "mockups";
|
|
1274
|
+
if (relativePath === "renders" || relativePath.startsWith("renders/")) return void 0;
|
|
1275
|
+
return "project";
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// src/validation.ts
|
|
1279
|
+
import { lstat as lstat2, readFile as readFile3, readdir as readdir2 } from "fs/promises";
|
|
1280
|
+
import path3 from "path";
|
|
1281
|
+
async function validateProject(directory, packageRoot2) {
|
|
1282
|
+
const root = path3.resolve(directory);
|
|
1283
|
+
const issues = [];
|
|
1284
|
+
const store = new ProjectStore(root);
|
|
1285
|
+
let config;
|
|
1286
|
+
let assets;
|
|
1287
|
+
let mockupCatalog;
|
|
1288
|
+
const sets = [];
|
|
1289
|
+
if (!await checkDirectory(root, ".", issues)) return validationResult(root, issues, sets);
|
|
1290
|
+
const configPath = path3.join(root, CONFIG_FILENAME);
|
|
1291
|
+
try {
|
|
1292
|
+
config = parseStoreShotConfig(JSON.parse(await readRegularFile(configPath, root)));
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
issues.push(issue("error", "config.invalid", CONFIG_FILENAME, messageFor(error)));
|
|
1295
|
+
}
|
|
1296
|
+
const assetsReady = await checkDirectory(path3.join(root, ASSETS_DIRECTORY), ASSETS_DIRECTORY, issues);
|
|
1297
|
+
const setsReady = await checkDirectory(path3.join(root, SETS_DIRECTORY), SETS_DIRECTORY, issues);
|
|
1298
|
+
const mockupsReady = await checkDirectory(path3.join(root, MOCKUP_BUNDLES_DIRECTORY), MOCKUP_BUNDLES_DIRECTORY, issues);
|
|
1299
|
+
if (setsReady) {
|
|
1300
|
+
const entries = await readdir2(store.setsPath, { withFileTypes: true });
|
|
1301
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
1302
|
+
if (!entry.name.endsWith(".json")) continue;
|
|
1303
|
+
const relativePath = `${SETS_DIRECTORY}/${entry.name}`;
|
|
1304
|
+
if (!entry.isFile() || entry.isSymbolicLink()) {
|
|
1305
|
+
issues.push(issue("error", "set.notRegularFile", relativePath, "Set documents must be regular JSON files"));
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
try {
|
|
1309
|
+
const set = parseScreenshotSet(JSON.parse(await readRegularFile(path3.join(store.setsPath, entry.name), store.setsPath)));
|
|
1310
|
+
sets.push(set);
|
|
1311
|
+
if (entry.name !== `${set.id}.json`) {
|
|
1312
|
+
issues.push(issue("error", "set.filenameMismatch", relativePath, `Set id ${set.id} must be stored in ${set.id}.json`));
|
|
1313
|
+
}
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
issues.push(issue("error", "set.invalid", relativePath, messageFor(error)));
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
if (assetsReady) {
|
|
1320
|
+
try {
|
|
1321
|
+
assets = await store.listAssets();
|
|
1322
|
+
for (const asset of Object.values(assets).flat()) {
|
|
1323
|
+
if (!asset.width || !asset.height) {
|
|
1324
|
+
issues.push(issue("error", "asset.invalidImage", `${ASSETS_DIRECTORY}/${asset.id}`, "Asset is not a valid supported image"));
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
} catch (error) {
|
|
1328
|
+
issues.push(issue("error", "assets.invalid", ASSETS_DIRECTORY, messageFor(error)));
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (mockupsReady) {
|
|
1332
|
+
try {
|
|
1333
|
+
const [builtInCatalog, projectCatalog] = await Promise.all([
|
|
1334
|
+
loadBuiltInMockupCatalog(packageRoot2),
|
|
1335
|
+
store.listMockupCatalog()
|
|
1336
|
+
]);
|
|
1337
|
+
mockupCatalog = mergeDeviceMockupCatalogs(builtInCatalog, projectCatalog);
|
|
1338
|
+
} catch (error) {
|
|
1339
|
+
issues.push(issue("error", "mockups.invalid", MOCKUP_BUNDLES_DIRECTORY, messageFor(error)));
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
validateSetSemantics(sets, assets, mockupCatalog, issues);
|
|
1343
|
+
return validationResult(root, issues, sets, config, assets, mockupCatalog);
|
|
1344
|
+
}
|
|
1345
|
+
async function loadBuiltInMockupCatalog(packageRoot2) {
|
|
1346
|
+
const publicDirectory = await resolvePackagePublicDirectory(packageRoot2);
|
|
1347
|
+
const bundleDirectory = path3.join(publicDirectory, "mockup-bundles/frameup-free");
|
|
1348
|
+
const manifest = parseMockupBundleManifest(JSON.parse(await readRegularFile(
|
|
1349
|
+
path3.join(bundleDirectory, MOCKUP_BUNDLE_FILENAME),
|
|
1350
|
+
bundleDirectory
|
|
1351
|
+
)));
|
|
1352
|
+
return resolveMockupBundle(manifest, "/mockup-bundles/frameup-free/", "built-in");
|
|
1353
|
+
}
|
|
1354
|
+
function validateSetSemantics(sets, assets, mockupCatalog, issues) {
|
|
1355
|
+
const setIds = /* @__PURE__ */ new Set();
|
|
1356
|
+
const assetIds = new Set(Object.values(assets ?? {}).flat().map((asset) => asset.id));
|
|
1357
|
+
const mockupIds = new Set(mockupCatalog?.mockups.map((mockup) => mockup.id) ?? []);
|
|
1358
|
+
const artworkIds = new Set(BUILT_IN_ARTWORK.map((artwork) => artwork.id));
|
|
1359
|
+
for (const set of sets) {
|
|
1360
|
+
const setPath = `${SETS_DIRECTORY}/${set.id}.json`;
|
|
1361
|
+
if (setIds.has(set.id)) issues.push(issue("error", "set.duplicateId", setPath, `Duplicate set id: ${set.id}`));
|
|
1362
|
+
setIds.add(set.id);
|
|
1363
|
+
try {
|
|
1364
|
+
Intl.getCanonicalLocales(set.locale);
|
|
1365
|
+
} catch {
|
|
1366
|
+
issues.push(issue("warning", "set.locale", setPath, `${set.locale} is not a recognized locale identifier`));
|
|
1367
|
+
}
|
|
1368
|
+
if (!isIsoTimestamp(set.createdAt)) issues.push(issue("warning", "set.createdAt", setPath, "createdAt should be an ISO 8601 timestamp"));
|
|
1369
|
+
if (!isIsoTimestamp(set.updatedAt)) issues.push(issue("warning", "set.updatedAt", setPath, "updatedAt should be an ISO 8601 timestamp"));
|
|
1370
|
+
if (set.areas.length > 10) issues.push(issue("warning", "set.areaCount", setPath, "Apple App Store sets normally contain at most 10 screenshots"));
|
|
1371
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1372
|
+
for (const area of set.areas) {
|
|
1373
|
+
if (ids.has(area.id)) issues.push(issue("error", "set.duplicateObjectId", setPath, `Duplicate area or element id: ${area.id}`));
|
|
1374
|
+
ids.add(area.id);
|
|
1375
|
+
for (const element of area.elements) {
|
|
1376
|
+
if (ids.has(element.id)) issues.push(issue("error", "set.duplicateObjectId", setPath, `Duplicate area or element id: ${element.id}`));
|
|
1377
|
+
ids.add(element.id);
|
|
1378
|
+
if (element.type === "mockup") {
|
|
1379
|
+
if (!assetIds.has(element.assetId)) issues.push(issue("error", "reference.asset", setPath, `Element ${element.id} references missing asset ${element.assetId}`));
|
|
1380
|
+
if (!mockupIds.has(element.mockupId)) issues.push(issue("error", "reference.mockup", setPath, `Element ${element.id} references missing mockup ${element.mockupId}`));
|
|
1381
|
+
} else if (element.type === "image" && element.source.kind === "asset" && !assetIds.has(element.source.assetId)) {
|
|
1382
|
+
issues.push(issue("error", "reference.asset", setPath, `Element ${element.id} references missing asset ${element.source.assetId}`));
|
|
1383
|
+
} else if (element.type === "image" && element.source.kind === "builtin" && !artworkIds.has(element.source.id)) {
|
|
1384
|
+
issues.push(issue("error", "reference.artwork", setPath, `Element ${element.id} references missing built-in artwork ${element.source.id}`));
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
async function checkDirectory(directory, displayPath, issues) {
|
|
1391
|
+
try {
|
|
1392
|
+
const metadata = await lstat2(directory);
|
|
1393
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("must be a regular directory");
|
|
1394
|
+
return true;
|
|
1395
|
+
} catch (error) {
|
|
1396
|
+
issues.push(issue("error", "project.directory", displayPath, messageFor(error)));
|
|
1397
|
+
return false;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
async function readRegularFile(filename, boundary) {
|
|
1401
|
+
const metadata = await lstat2(filename);
|
|
1402
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("must be a regular file");
|
|
1403
|
+
const relative = path3.relative(boundary, filename);
|
|
1404
|
+
if (relative.startsWith("..") || path3.isAbsolute(relative)) throw new Error("is outside the project boundary");
|
|
1405
|
+
return readFile3(filename, "utf8");
|
|
1406
|
+
}
|
|
1407
|
+
function validationResult(directory, issues, sets, config, assets, mockupCatalog) {
|
|
1408
|
+
const errors = issues.filter((entry) => entry.severity === "error");
|
|
1409
|
+
const warnings = issues.filter((entry) => entry.severity === "warning");
|
|
1410
|
+
const allAssets = Object.values(assets ?? {}).flat();
|
|
1411
|
+
return {
|
|
1412
|
+
report: {
|
|
1413
|
+
ok: errors.length === 0,
|
|
1414
|
+
directory,
|
|
1415
|
+
errors,
|
|
1416
|
+
warnings,
|
|
1417
|
+
summary: {
|
|
1418
|
+
assets: allAssets.length,
|
|
1419
|
+
sets: sets.length,
|
|
1420
|
+
screenshots: assets?.screenshots.length ?? 0,
|
|
1421
|
+
areas: sets.reduce((total, set) => total + set.areas.length, 0)
|
|
1422
|
+
}
|
|
1423
|
+
},
|
|
1424
|
+
...config ? { config } : {},
|
|
1425
|
+
...assets ? { assets } : {},
|
|
1426
|
+
sets,
|
|
1427
|
+
...mockupCatalog ? { mockupCatalog } : {}
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
function issue(severity, code, issuePath, message) {
|
|
1431
|
+
return { severity, code, path: issuePath, message };
|
|
1432
|
+
}
|
|
1433
|
+
function messageFor(error) {
|
|
1434
|
+
return error instanceof Error ? error.message : String(error);
|
|
1435
|
+
}
|
|
1436
|
+
function isIsoTimestamp(value) {
|
|
1437
|
+
const date = new Date(value);
|
|
1438
|
+
return !Number.isNaN(date.getTime()) && date.toISOString() === value;
|
|
1439
|
+
}
|
|
1421
1440
|
|
|
1422
1441
|
// src/cli.ts
|
|
1423
1442
|
var sourceFile = fileURLToPath(import.meta.url);
|
|
1424
|
-
var packageRoot =
|
|
1425
|
-
var isSourceExecution =
|
|
1426
|
-
var
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
const
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1443
|
+
var packageRoot = path4.resolve(path4.dirname(sourceFile), "..");
|
|
1444
|
+
var isSourceExecution = path4.extname(sourceFile) === ".ts";
|
|
1445
|
+
var DEVICE_PRESETS = {
|
|
1446
|
+
iphone: { device: "iPhone", width: 1320, height: 2868 },
|
|
1447
|
+
ipad: { device: "iPad", width: 2064, height: 2752 },
|
|
1448
|
+
watch: { device: "Apple Watch", width: 422, height: 514 },
|
|
1449
|
+
mac: { device: "Mac", width: 2880, height: 1800 }
|
|
1450
|
+
};
|
|
1451
|
+
function createCli() {
|
|
1452
|
+
const program2 = new Command().name("storeshot").description("Manage app store screenshots locally").version("0.1.0").option("-C, --project <directory>", "project directory", ".").option("--json", "write machine-readable JSON").showSuggestionAfterError().addHelpText("after", `
|
|
1453
|
+
Agent workflow:
|
|
1454
|
+
storeshot -C ./screenshots status --json
|
|
1455
|
+
storeshot -C ./screenshots validate --json
|
|
1456
|
+
storeshot -C ./screenshots set show <id>
|
|
1457
|
+
storeshot -C ./screenshots render --set <id> --scale 0.25 --json
|
|
1458
|
+
|
|
1459
|
+
Complex canvas edits can be written as complete set JSON documents with
|
|
1460
|
+
"set write", or edited directly in sets/ and checked with "validate".`);
|
|
1461
|
+
program2.command("init").description("Initialize a StoreShot project").argument("[directory]", "project directory").option("--app-name <name>", "application name").option("--platform <platform...>", "ios, android, or both").action(async (directory, options) => {
|
|
1462
|
+
const target = path4.resolve(directory ?? projectDirectory(program2));
|
|
1463
|
+
const configPath = path4.join(target, CONFIG_FILENAME);
|
|
1464
|
+
const created = !await fileExists(configPath);
|
|
1465
|
+
const store = new ProjectStore(target);
|
|
1466
|
+
await store.initialize();
|
|
1467
|
+
const current = await store.readConfig();
|
|
1468
|
+
const platforms = options.platform ? parsePlatforms(options.platform) : current.platforms;
|
|
1469
|
+
const config = options.appName || options.platform ? await store.writeConfig({ ...current, appName: options.appName ?? current.appName, platforms }) : current;
|
|
1470
|
+
emit(program2, `Initialized ${store.root}
|
|
1471
|
+
Config: ${store.configPath}`, { ok: true, created, directory: store.root, config });
|
|
1472
|
+
});
|
|
1473
|
+
program2.command("status").alias("inspect").description("Summarize the project for a human or agent").action(async () => {
|
|
1474
|
+
const store = await openProject(program2);
|
|
1475
|
+
const project = await store.readProject();
|
|
1476
|
+
const assets = Object.values(project.assets).flat();
|
|
1477
|
+
const value = {
|
|
1478
|
+
ok: true,
|
|
1479
|
+
project: {
|
|
1480
|
+
directory: project.directory,
|
|
1481
|
+
configPath: store.configPath,
|
|
1482
|
+
appName: project.config.appName,
|
|
1483
|
+
platforms: project.config.platforms
|
|
1484
|
+
},
|
|
1485
|
+
summary: {
|
|
1486
|
+
assets: assets.length,
|
|
1487
|
+
screenshots: project.assets.screenshots.length,
|
|
1488
|
+
sets: project.sets.length,
|
|
1489
|
+
areas: project.sets.reduce((total, set) => total + set.areas.length, 0)
|
|
1490
|
+
},
|
|
1491
|
+
sets: project.sets.map((set) => ({
|
|
1492
|
+
id: set.id,
|
|
1493
|
+
name: set.name,
|
|
1494
|
+
locale: set.locale,
|
|
1495
|
+
device: set.device,
|
|
1496
|
+
canvas: set.canvas,
|
|
1497
|
+
areas: set.areas.length,
|
|
1498
|
+
path: path4.join(store.setsPath, `${set.id}.json`)
|
|
1499
|
+
}))
|
|
1500
|
+
};
|
|
1501
|
+
const lines = [
|
|
1502
|
+
`${project.config.appName} \u2014 ${project.directory}`,
|
|
1503
|
+
`${value.summary.sets} sets, ${value.summary.areas} screenshots, ${value.summary.assets} assets`,
|
|
1504
|
+
...value.sets.map((set) => ` ${set.id} ${set.locale} \xB7 ${set.device} \xB7 ${set.areas} screenshots`)
|
|
1505
|
+
];
|
|
1506
|
+
emit(program2, lines.join("\n"), value);
|
|
1507
|
+
});
|
|
1508
|
+
program2.command("validate").description("Validate project files and cross-references").option("--strict", "treat warnings as failures").action(async (options) => {
|
|
1509
|
+
const validation = await validateProject(projectDirectory(program2), packageRoot);
|
|
1510
|
+
const failed = !validation.report.ok || Boolean(options.strict && validation.report.warnings.length > 0);
|
|
1511
|
+
emit(program2, formatValidation(validation.report), { ...validation.report, ok: !failed });
|
|
1512
|
+
if (failed) process.exitCode = 1;
|
|
1513
|
+
});
|
|
1514
|
+
program2.command("render").description("Render screenshot sets to PNG files").option("--set <id>", "render one set (repeatable)", collect, []).option("--area <id-or-number>", "render one area by id, exact name, or 1-based number").option("-o, --output <directory>", "output directory (default: <project>/renders)").option("--scale <scale>", "render scale from 0 to 1", "1").option("--clean", "remove each selected set's existing output directory first").action(async (options) => {
|
|
1515
|
+
const directory = projectDirectory(program2);
|
|
1516
|
+
const validation = await validateProject(directory, packageRoot);
|
|
1517
|
+
if (!validation.report.ok || !validation.assets) {
|
|
1518
|
+
emit(program2, formatValidation(validation.report), { ...validation.report, ok: false });
|
|
1519
|
+
process.exitCode = 1;
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const selectedSets = options.set.length === 0 ? validation.sets : options.set.map((id) => {
|
|
1523
|
+
const set = validation.sets.find((candidate) => candidate.id === id);
|
|
1524
|
+
if (!set) throw new Error(`Unknown set id: ${id}`);
|
|
1525
|
+
return set;
|
|
1526
|
+
});
|
|
1527
|
+
if (selectedSets.length === 0) throw new Error("The project has no screenshot sets to render");
|
|
1528
|
+
const scale = parseNumber(options.scale, "Scale");
|
|
1529
|
+
const store = await openProject(program2);
|
|
1530
|
+
const { renderScreenshotSets } = await import("./node-renderer-FMVPIQAG.js");
|
|
1531
|
+
const result = await renderScreenshotSets({
|
|
1532
|
+
clean: Boolean(options.clean),
|
|
1533
|
+
outputDirectory: path4.resolve(options.output ?? path4.join(directory, "renders")),
|
|
1534
|
+
packageRoot,
|
|
1535
|
+
scale,
|
|
1536
|
+
sets: selectedSets,
|
|
1537
|
+
store,
|
|
1538
|
+
...options.area ? { area: options.area } : {}
|
|
1539
|
+
});
|
|
1540
|
+
emit(
|
|
1541
|
+
program2,
|
|
1542
|
+
[`Rendered ${result.files.length} PNG file${result.files.length === 1 ? "" : "s"} to ${result.outputDirectory}`, ...result.files.map((file) => ` ${file.path}`)].join("\n"),
|
|
1543
|
+
{ ok: true, render: result, warnings: validation.report.warnings }
|
|
1544
|
+
);
|
|
1545
|
+
});
|
|
1546
|
+
configureConfigCommands(program2);
|
|
1547
|
+
configureSetCommands(program2);
|
|
1548
|
+
configureAssetCommands(program2);
|
|
1549
|
+
configureDevCommand(program2);
|
|
1550
|
+
return program2;
|
|
1551
|
+
}
|
|
1552
|
+
function configureConfigCommands(program2) {
|
|
1553
|
+
const config = program2.command("config").description("Inspect or update storeshot.json");
|
|
1554
|
+
config.command("get").description("Print the project configuration").action(async () => {
|
|
1555
|
+
const store = await openProject(program2);
|
|
1556
|
+
const value = await store.readConfig();
|
|
1557
|
+
emitDocument(program2, value, { ok: true, config: value });
|
|
1558
|
+
});
|
|
1559
|
+
config.command("set").description("Update project configuration").option("--app-name <name>", "application name").option("--platform <platform...>", "ios, android, or both").action(async (options) => {
|
|
1560
|
+
if (!options.appName && !options.platform) throw new Error("Provide --app-name or --platform");
|
|
1561
|
+
const store = await openProject(program2);
|
|
1562
|
+
const current = await store.readConfig();
|
|
1563
|
+
const value = await store.writeConfig({
|
|
1564
|
+
...current,
|
|
1565
|
+
appName: options.appName ?? current.appName,
|
|
1566
|
+
platforms: options.platform ? parsePlatforms(options.platform) : current.platforms
|
|
1567
|
+
});
|
|
1568
|
+
emit(program2, `Updated ${store.configPath}`, { ok: true, config: value });
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
function configureSetCommands(program2) {
|
|
1572
|
+
const set = program2.command("set").alias("sets").description("Create, inspect, edit, duplicate, or delete screenshot sets");
|
|
1573
|
+
set.command("list").description("List screenshot sets").action(async () => {
|
|
1574
|
+
const store = await openProject(program2);
|
|
1575
|
+
const sets = await store.listSets();
|
|
1576
|
+
emit(
|
|
1577
|
+
program2,
|
|
1578
|
+
sets.length === 0 ? "No screenshot sets" : sets.map((value) => `${value.id} ${value.locale} ${value.device} ${value.name}`).join("\n"),
|
|
1579
|
+
{ ok: true, sets }
|
|
1580
|
+
);
|
|
1581
|
+
});
|
|
1582
|
+
set.command("show").description("Print one complete set document").argument("<id>", "set id").action(async (id) => {
|
|
1583
|
+
const store = await openProject(program2);
|
|
1584
|
+
const value = await store.readSet(id);
|
|
1585
|
+
emitDocument(program2, value, { ok: true, set: value, path: path4.join(store.setsPath, `${id}.json`) });
|
|
1439
1586
|
});
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1587
|
+
set.command("create").description("Create a screenshot set").requiredOption("--name <name>", "set name").requiredOption("--locale <locale>", "locale identifier").addOption(new Option("--preset <preset>", "iphone, ipad, watch, or mac").choices(Object.keys(DEVICE_PRESETS)).default("iphone")).option("--device <name>", "device label").option("--width <pixels>", "custom canvas width").option("--height <pixels>", "custom canvas height").action(async (options) => {
|
|
1588
|
+
if (options.width && !options.height || !options.width && options.height) throw new Error("Provide both --width and --height");
|
|
1589
|
+
const preset = DEVICE_PRESETS[options.preset];
|
|
1590
|
+
const store = await openProject(program2);
|
|
1591
|
+
const value = await store.createSet({
|
|
1592
|
+
name: options.name,
|
|
1593
|
+
locale: options.locale,
|
|
1594
|
+
device: options.device ?? preset.device,
|
|
1595
|
+
width: options.width ? parseInteger(options.width, "Canvas width") : preset.width,
|
|
1596
|
+
height: options.height ? parseInteger(options.height, "Canvas height") : preset.height
|
|
1597
|
+
});
|
|
1598
|
+
emit(program2, `Created ${value.id} at ${path4.join(store.setsPath, `${value.id}.json`)}`, { ok: true, set: value });
|
|
1599
|
+
});
|
|
1600
|
+
set.command("update").description("Update common set metadata").argument("<id>", "set id").option("--name <name>", "set name").option("--locale <locale>", "locale identifier").option("--device <name>", "device label").action(async (id, options) => {
|
|
1601
|
+
if (!options.name && !options.locale && !options.device) throw new Error("Provide --name, --locale, or --device");
|
|
1602
|
+
const store = await openProject(program2);
|
|
1603
|
+
const current = await store.readSet(id);
|
|
1604
|
+
const value = await store.updateSetMetadata(id, {
|
|
1605
|
+
name: options.name ?? current.name,
|
|
1606
|
+
locale: options.locale ?? current.locale,
|
|
1607
|
+
device: options.device ?? current.device
|
|
1608
|
+
});
|
|
1609
|
+
emit(program2, `Updated ${id}`, { ok: true, set: value });
|
|
1610
|
+
});
|
|
1611
|
+
set.command("write").description("Replace a complete set document with schema-validated JSON").argument("<id>", "set id").requiredOption("--file <file>", "JSON file, or - for stdin").action(async (id, options) => {
|
|
1612
|
+
const input = options.file === "-" ? await readStandardInput() : await readFile4(path4.resolve(options.file), "utf8");
|
|
1613
|
+
let document;
|
|
1614
|
+
try {
|
|
1615
|
+
document = JSON.parse(input);
|
|
1616
|
+
} catch {
|
|
1617
|
+
throw new Error("Set input must be valid JSON");
|
|
1618
|
+
}
|
|
1619
|
+
const value = parseScreenshotSet(document);
|
|
1620
|
+
if (value.id !== id) throw new Error(`Set input id ${value.id} does not match ${id}`);
|
|
1621
|
+
const store = await openProject(program2);
|
|
1622
|
+
const saved = await store.writeSet(id, value);
|
|
1623
|
+
emit(program2, `Wrote ${path4.join(store.setsPath, `${id}.json`)}`, { ok: true, set: saved });
|
|
1624
|
+
});
|
|
1625
|
+
set.command("duplicate").description("Duplicate a set with fresh object ids").argument("<id>", "set id").action(async (id) => {
|
|
1626
|
+
const store = await openProject(program2);
|
|
1627
|
+
const value = await store.duplicateSet(id);
|
|
1628
|
+
emit(program2, `Created duplicate ${value.id}`, { ok: true, set: value });
|
|
1629
|
+
});
|
|
1630
|
+
set.command("delete").description("Delete a screenshot set").argument("<id>", "set id").requiredOption("--yes", "confirm deletion").action(async (id) => {
|
|
1631
|
+
const store = await openProject(program2);
|
|
1632
|
+
await store.deleteSet(id);
|
|
1633
|
+
emit(program2, `Deleted ${id}`, { ok: true, deleted: id });
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
function configureAssetCommands(program2) {
|
|
1637
|
+
const asset = program2.command("asset").alias("assets").description("List, add, classify, or remove project assets");
|
|
1638
|
+
asset.command("list").description("List project assets").addOption(new Option("--category <category>", "filter by category").choices([...ASSET_CATEGORIES])).action(async (options) => {
|
|
1639
|
+
const store = await openProject(program2);
|
|
1640
|
+
const catalog = await store.listAssets();
|
|
1641
|
+
const assets = options.category ? catalog[options.category] : Object.values(catalog).flat();
|
|
1642
|
+
emit(
|
|
1643
|
+
program2,
|
|
1644
|
+
assets.length === 0 ? "No assets" : assets.map((value) => `${value.id} ${value.width ?? "?"}x${value.height ?? "?"} ${value.size} bytes`).join("\n"),
|
|
1645
|
+
{ ok: true, assets }
|
|
1646
|
+
);
|
|
1647
|
+
});
|
|
1648
|
+
asset.command("add").description("Copy image files into the project asset catalog").argument("<files...>", "image files").addOption(new Option("--category <category>", "asset category").choices([...ASSET_CATEGORIES]).default("screenshots")).option("--replace", "allow replacement of an existing same-name asset").action(async (files, options) => {
|
|
1649
|
+
const store = await openProject(program2);
|
|
1650
|
+
const added = [];
|
|
1651
|
+
for (const source of files) {
|
|
1652
|
+
const filename = path4.basename(source);
|
|
1653
|
+
const target = store.resolveAsset(options.category, filename);
|
|
1654
|
+
if (!options.replace && await fileExists(target)) throw new Error(`${options.category}/${filename} already exists; pass --replace to overwrite it`);
|
|
1655
|
+
const result = await store.addAsset(options.category, filename, await readFile4(path4.resolve(source)));
|
|
1656
|
+
added.push({ id: `${options.category}/${filename}`, replaced: result.replaced });
|
|
1657
|
+
}
|
|
1658
|
+
emit(program2, added.map((entry) => `${entry.replaced ? "Replaced" : "Added"} ${entry.id}`).join("\n"), { ok: true, assets: added });
|
|
1659
|
+
});
|
|
1660
|
+
asset.command("set-device").description("Override or clear raw screenshot device classification").argument("<id>", "asset id such as screenshots/home.png").argument("<device>", "iphone, ipad, mac, watch, or auto").action(async (id, device) => {
|
|
1661
|
+
if (device !== "auto" && !SCREENSHOT_DEVICE_TYPES.includes(device)) throw new Error("Device must be iphone, ipad, mac, watch, or auto");
|
|
1662
|
+
const { category, filename } = parseAssetId(id);
|
|
1663
|
+
const store = await openProject(program2);
|
|
1664
|
+
await store.updateAssetMetadata(category, filename, { deviceType: device === "auto" ? null : device });
|
|
1665
|
+
emit(program2, `Updated ${id}`, { ok: true, assetId: id, deviceType: device === "auto" ? null : device });
|
|
1666
|
+
});
|
|
1667
|
+
asset.command("remove").description("Delete assets that are not referenced by screenshot sets").argument("<ids...>", "asset ids").requiredOption("--yes", "confirm deletion").option("--force", "delete even when a set references the asset").action(async (ids, options) => {
|
|
1668
|
+
const store = await openProject(program2);
|
|
1669
|
+
const sets = await store.listSets();
|
|
1670
|
+
for (const id of ids) {
|
|
1671
|
+
const references = assetReferences(id, sets);
|
|
1672
|
+
if (references.length > 0 && !options.force) throw new Error(`${id} is referenced by ${references.join(", ")}; pass --force to delete it anyway`);
|
|
1673
|
+
}
|
|
1674
|
+
for (const id of ids) {
|
|
1675
|
+
const { category, filename } = parseAssetId(id);
|
|
1676
|
+
await store.deleteAsset(category, filename);
|
|
1677
|
+
}
|
|
1678
|
+
emit(program2, ids.map((id) => `Deleted ${id}`).join("\n"), { ok: true, deleted: ids });
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
function configureDevCommand(program2) {
|
|
1682
|
+
program2.command("dev").description("Open a live StoreShot workspace for a local directory").argument("[directory]", "project directory").addOption(new Option("-p, --port <port>", "local server port").default("4173")).addOption(new Option("--host <host>", "local server host").default("127.0.0.1")).option("--no-open", "do not open a browser").action(async (directory, options) => {
|
|
1683
|
+
const port = parseInteger(options.port, "Port");
|
|
1684
|
+
if (port > 65535) throw new Error("Port must be an integer between 1 and 65535");
|
|
1685
|
+
const projectDirectory2 = path4.resolve(directory ?? globalOptions(program2).project);
|
|
1686
|
+
const service = await startServer({ host: options.host, port, packageRoot, projectDirectory: projectDirectory2, useVite: isSourceExecution });
|
|
1687
|
+
const url = `http://${options.host}:${service.port}`;
|
|
1688
|
+
if (globalOptions(program2).json) console.log(JSON.stringify({ ok: true, url, directory: service.store.root }));
|
|
1689
|
+
else console.log(`
|
|
1690
|
+
StoreShot is ready at ${url}
|
|
1691
|
+
Project: ${service.store.root}
|
|
1692
|
+
Filesystem changes refresh the preview automatically.
|
|
1444
1693
|
`);
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
});
|
|
1694
|
+
if (options.open) await open(url);
|
|
1695
|
+
const shutdown = async () => {
|
|
1696
|
+
await service.close();
|
|
1697
|
+
process.exit(0);
|
|
1698
|
+
};
|
|
1699
|
+
process.once("SIGINT", shutdown);
|
|
1700
|
+
process.once("SIGTERM", shutdown);
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
async function openProject(program2) {
|
|
1704
|
+
const store = new ProjectStore(projectDirectory(program2));
|
|
1705
|
+
try {
|
|
1706
|
+
await access2(store.configPath, constants2.R_OK);
|
|
1707
|
+
} catch {
|
|
1708
|
+
throw new Error(`No StoreShot project found at ${store.root}; run storeshot init first`);
|
|
1709
|
+
}
|
|
1710
|
+
await store.readConfig();
|
|
1711
|
+
return store;
|
|
1712
|
+
}
|
|
1713
|
+
function globalOptions(program2) {
|
|
1714
|
+
return program2.opts();
|
|
1715
|
+
}
|
|
1716
|
+
function projectDirectory(program2) {
|
|
1717
|
+
return path4.resolve(globalOptions(program2).project);
|
|
1718
|
+
}
|
|
1719
|
+
function emit(program2, human, json) {
|
|
1720
|
+
console.log(globalOptions(program2).json ? JSON.stringify(json, null, 2) : human);
|
|
1721
|
+
}
|
|
1722
|
+
function emitDocument(program2, document, json) {
|
|
1723
|
+
console.log(JSON.stringify(globalOptions(program2).json ? json : document, null, 2));
|
|
1724
|
+
}
|
|
1725
|
+
function formatValidation(report) {
|
|
1726
|
+
const lines = [
|
|
1727
|
+
report.ok ? `Valid StoreShot project: ${report.directory}` : `Invalid StoreShot project: ${report.directory}`,
|
|
1728
|
+
`${report.summary.sets} sets, ${report.summary.areas} screenshots, ${report.summary.assets} assets`
|
|
1729
|
+
];
|
|
1730
|
+
for (const entry of [...report.errors, ...report.warnings]) {
|
|
1731
|
+
lines.push(`${entry.severity === "error" ? "error" : "warning"} [${entry.code}] ${entry.path}: ${entry.message}`);
|
|
1732
|
+
}
|
|
1733
|
+
return lines.join("\n");
|
|
1734
|
+
}
|
|
1735
|
+
function parsePlatforms(values) {
|
|
1736
|
+
const platforms = [...new Set(values.flatMap((value) => value.split(",")).map((value) => value.trim().toLowerCase()))];
|
|
1737
|
+
if (platforms.length === 0 || platforms.some((value) => value !== "ios" && value !== "android")) {
|
|
1738
|
+
throw new Error("Platforms must be ios, android, or both");
|
|
1739
|
+
}
|
|
1740
|
+
return platforms;
|
|
1741
|
+
}
|
|
1742
|
+
function parseInteger(value, name) {
|
|
1743
|
+
const result = Number(value);
|
|
1744
|
+
if (!Number.isInteger(result) || result <= 0) throw new Error(`${name} must be a positive integer`);
|
|
1745
|
+
return result;
|
|
1746
|
+
}
|
|
1747
|
+
function parseNumber(value, name) {
|
|
1748
|
+
const result = Number(value);
|
|
1749
|
+
if (!Number.isFinite(result)) throw new Error(`${name} must be a number`);
|
|
1750
|
+
return result;
|
|
1751
|
+
}
|
|
1752
|
+
function parseAssetId(id) {
|
|
1753
|
+
const separator = id.indexOf("/");
|
|
1754
|
+
const category = id.slice(0, separator);
|
|
1755
|
+
const filename = id.slice(separator + 1);
|
|
1756
|
+
if (separator < 1 || !ASSET_CATEGORIES.includes(category) || !filename || filename.includes("/")) throw new Error(`Invalid asset id: ${id}`);
|
|
1757
|
+
return { category, filename };
|
|
1758
|
+
}
|
|
1759
|
+
function assetReferences(assetId, sets) {
|
|
1760
|
+
return sets.flatMap((set) => set.areas.some((area) => area.elements.some(
|
|
1761
|
+
(element) => element.type === "mockup" && element.assetId === assetId || element.type === "image" && element.source.kind === "asset" && element.source.assetId === assetId
|
|
1762
|
+
)) ? [set.id] : []);
|
|
1763
|
+
}
|
|
1764
|
+
async function readStandardInput() {
|
|
1765
|
+
const chunks = [];
|
|
1766
|
+
for await (const chunk of process.stdin) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1767
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1768
|
+
}
|
|
1769
|
+
async function fileExists(filename) {
|
|
1770
|
+
return access2(filename, constants2.F_OK).then(() => true).catch(() => false);
|
|
1771
|
+
}
|
|
1772
|
+
function collect(value, previous) {
|
|
1773
|
+
return [...previous, value];
|
|
1774
|
+
}
|
|
1775
|
+
var program = createCli();
|
|
1453
1776
|
program.parseAsync().catch((error) => {
|
|
1454
1777
|
const message = error instanceof Error ? error.message : String(error);
|
|
1455
|
-
console.error(
|
|
1778
|
+
if (globalOptions(program).json) console.error(JSON.stringify({ ok: false, error: { message } }, null, 2));
|
|
1779
|
+
else console.error(`storeshot: ${message}`);
|
|
1456
1780
|
process.exitCode = 1;
|
|
1457
1781
|
});
|
|
1782
|
+
export {
|
|
1783
|
+
createCli
|
|
1784
|
+
};
|
|
1458
1785
|
//# sourceMappingURL=cli.js.map
|