storeshot 0.1.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 +15 -0
- package/CONTRIBUTING.md +75 -0
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/SECURITY.md +25 -0
- package/THIRD_PARTY_LICENSES.txt +668 -0
- package/THIRD_PARTY_NOTICES.md +93 -0
- package/dist/cli.js +1458 -0
- package/dist/cli.js.map +1 -0
- package/dist/ui/artwork/tabler/LICENSE +21 -0
- package/dist/ui/artwork/tabler/README.md +23 -0
- package/dist/ui/artwork/tabler/filled/grid-pattern.svg +13 -0
- package/dist/ui/artwork/tabler/filled/laurel-wreath-1.svg +13 -0
- package/dist/ui/artwork/tabler/filled/laurel-wreath-2.svg +13 -0
- package/dist/ui/artwork/tabler/filled/laurel-wreath-3.svg +13 -0
- package/dist/ui/artwork/tabler/filled/laurel-wreath.svg +13 -0
- package/dist/ui/artwork/tabler/filled/sparkles.svg +15 -0
- package/dist/ui/artwork/tabler/filled/stars.svg +15 -0
- package/dist/ui/artwork/tabler/outline/scribble.svg +19 -0
- package/dist/ui/artwork/tabler/outline/underline.svg +20 -0
- package/dist/ui/assets/AssetsView-D0lbK5rm.js +1 -0
- package/dist/ui/assets/SetEditor-BBUlgGT0.js +440 -0
- package/dist/ui/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/dist/ui/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/dist/ui/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/dist/ui/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/dist/ui/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/dist/ui/assets/index-DPDaBFld.js +10 -0
- package/dist/ui/assets/index-DfZBqsD3.css +2 -0
- package/dist/ui/assets/shared-BiRg4I01.js +1 -0
- package/dist/ui/index.html +14 -0
- package/dist/ui/licenses/geist-OFL-1.1.txt +93 -0
- package/dist/ui/mockup-bundles/frameup-free/LICENSE +21 -0
- package/dist/ui/mockup-bundles/frameup-free/README.md +12 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/imac-pro.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/imac-retina.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/imac.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-air-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-air-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-air-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-mini-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-mini-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-mini-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-pro-11-silver-landscape.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-pro-11-silver-portrait.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-pro-11-space-gray-landscape.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-pro-11-space-gray-portrait.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-pro-13-silver-landscape.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-pro-13-silver-portrait.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-pro-13-space-gray-landscape.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-pro-13-space-gray-portrait.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/ipad-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-black.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-green.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-pro-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-pro-max-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-pro-max-midnight-green.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-pro-max-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-pro-max-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-pro-midnight-green.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-pro-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-pro-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-purple.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-red.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-white.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-11-yellow.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-13-pro-max.svg +5 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-13-pro.svg +5 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-14-pro-max-no-cutout.svg +4 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-14-pro-max.svg +5 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-14-pro-no-cutout.svg +4 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-14-pro.svg +5 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-15-pro-max-no-cutout.svg +4 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-15-pro-max.svg +5 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-15-pro-no-cutout.svg +4 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-15-pro.svg +5 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-16-pro-max-no-cutout.svg +10 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-16-pro-max.svg +11 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-16-pro-no-cutout.svg +4 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-16-pro.svg +5 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-17-pro-max-no-cutout.svg +4 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-17-pro-max.svg +5 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-17-pro-no-cutout.svg +4 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-17-pro.svg +5 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-5c-blue.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-5c-green.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-5c-red.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-5c-white.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-5c-yellow.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-5s-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-5s-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-5s-space-gray.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-6s-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-6s-plus-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-6s-plus-rose-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-6s-plus-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-6s-plus-space-gray.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-6s-rose-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-6s-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-6s-space-gray.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-7-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-7-jet-black.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-7-matte-black.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-7-plus-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-7-plus-jet-black.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-7-plus-matte-black.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-7-plus-rose-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-7-plus-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-7-rose-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-7-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-8-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-8-plus-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-8-plus-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-8-plus-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-8-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-8-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-se-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-se-rose-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-se-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-se-space-gray.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-x-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-x-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xr-blue.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xr-coral.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xr-red.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xr-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xr-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xr-yellow.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xs-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xs-max-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xs-max-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xs-max-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xs-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/iphone-xs-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/macbook-air-13-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/macbook-air-13-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/macbook-air-13-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/macbook-gold.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/macbook-pro-13-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/macbook-pro-13-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/macbook-pro-15-silver.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/macbook-pro-15-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/macbook-space-grey.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/pro-display-xdr.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/assets/thunderbolt-display.png +0 -0
- package/dist/ui/mockup-bundles/frameup-free/storeshot-mockups.json +2279 -0
- package/docs/mockup-bundles.md +104 -0
- package/docs/releasing.md +155 -0
- package/package.json +85 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1458 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import path3 from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { Command, Option } from "commander";
|
|
7
|
+
import open from "open";
|
|
8
|
+
|
|
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
|
+
// src/project-store.ts
|
|
16
|
+
import { createHash, randomUUID } from "crypto";
|
|
17
|
+
import { constants } from "fs";
|
|
18
|
+
import { access, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "fs/promises";
|
|
19
|
+
import path from "path";
|
|
20
|
+
|
|
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
|
+
// src/image-metadata.ts
|
|
257
|
+
function readImageMetadata(contents) {
|
|
258
|
+
return readPngMetadata(contents) ?? readJpegMetadata(contents) ?? readWebpMetadata(contents) ?? readSvgMetadata(contents);
|
|
259
|
+
}
|
|
260
|
+
function readSvgMetadata(contents) {
|
|
261
|
+
const source = contents.subarray(0, 64 * 1024).toString("utf8");
|
|
262
|
+
const svg = source.match(/<svg\b[^>]*>/i)?.[0];
|
|
263
|
+
if (!svg) return void 0;
|
|
264
|
+
const width = readSvgLength(svg, "width");
|
|
265
|
+
const height = readSvgLength(svg, "height");
|
|
266
|
+
if (width && height) return validDimensions(width, height);
|
|
267
|
+
const viewBox = svg.match(/\bviewBox\s*=\s*["']\s*([-+\d.e]+)[\s,]+([-+\d.e]+)[\s,]+([-+\d.e]+)[\s,]+([-+\d.e]+)\s*["']/i);
|
|
268
|
+
if (!viewBox) return void 0;
|
|
269
|
+
return validDimensions(Number(viewBox[3]), Number(viewBox[4]));
|
|
270
|
+
}
|
|
271
|
+
function readSvgLength(svg, attribute) {
|
|
272
|
+
const match = svg.match(new RegExp(`\\b${attribute}\\s*=\\s*["']\\s*([-+\\d.e]+)`, "i"));
|
|
273
|
+
const value = match ? Number(match[1]) : NaN;
|
|
274
|
+
return Number.isFinite(value) && value > 0 ? value : void 0;
|
|
275
|
+
}
|
|
276
|
+
function detectScreenshotDeviceType(width, height) {
|
|
277
|
+
const shortSide = Math.min(width, height);
|
|
278
|
+
const longSide = Math.max(width, height);
|
|
279
|
+
const ratio = longSide / shortSide;
|
|
280
|
+
const dimensions = `${shortSide}x${longSide}`;
|
|
281
|
+
if (WATCH_SCREENSHOT_DIMENSIONS.has(dimensions)) return "watch";
|
|
282
|
+
if (IPAD_SCREENSHOT_DIMENSIONS.has(dimensions)) return "ipad";
|
|
283
|
+
if (IPHONE_SCREENSHOT_DIMENSIONS.has(dimensions) && (height > width || longSide >= 2200)) return "iphone";
|
|
284
|
+
if (longSide <= 700 && ratio <= 1.4) return "watch";
|
|
285
|
+
if (shortSide >= 1400 && ratio >= 1.25 && ratio <= 1.45) return "ipad";
|
|
286
|
+
if (height > width && ratio >= 1.65) return "iphone";
|
|
287
|
+
if (width > height && ratio >= 1.45) return "mac";
|
|
288
|
+
if (shortSide >= 1e3 && ratio >= 1.25 && ratio <= 1.45) return "ipad";
|
|
289
|
+
return "unknown";
|
|
290
|
+
}
|
|
291
|
+
var IPHONE_SCREENSHOT_DIMENSIONS = /* @__PURE__ */ new Set([
|
|
292
|
+
"640x1136",
|
|
293
|
+
"750x1334",
|
|
294
|
+
"828x1792",
|
|
295
|
+
"1080x1920",
|
|
296
|
+
"1125x2436",
|
|
297
|
+
"1170x2532",
|
|
298
|
+
"1179x2556",
|
|
299
|
+
"1206x2622",
|
|
300
|
+
"1242x2208",
|
|
301
|
+
"1242x2688",
|
|
302
|
+
"1284x2778",
|
|
303
|
+
"1290x2796",
|
|
304
|
+
"1320x2868"
|
|
305
|
+
]);
|
|
306
|
+
var IPAD_SCREENSHOT_DIMENSIONS = /* @__PURE__ */ new Set([
|
|
307
|
+
"1536x2048",
|
|
308
|
+
"1668x2224",
|
|
309
|
+
"1668x2388",
|
|
310
|
+
"2048x2732",
|
|
311
|
+
"2064x2752"
|
|
312
|
+
]);
|
|
313
|
+
var WATCH_SCREENSHOT_DIMENSIONS = /* @__PURE__ */ new Set([
|
|
314
|
+
"368x448",
|
|
315
|
+
"396x484",
|
|
316
|
+
"416x496",
|
|
317
|
+
"422x514"
|
|
318
|
+
]);
|
|
319
|
+
function readPngMetadata(contents) {
|
|
320
|
+
if (contents.length < 24 || contents.toString("ascii", 1, 4) !== "PNG") return void 0;
|
|
321
|
+
return validDimensions(contents.readUInt32BE(16), contents.readUInt32BE(20));
|
|
322
|
+
}
|
|
323
|
+
function readJpegMetadata(contents) {
|
|
324
|
+
if (contents.length < 4 || contents[0] !== 255 || contents[1] !== 216) return void 0;
|
|
325
|
+
let offset = 2;
|
|
326
|
+
while (offset + 8 < contents.length) {
|
|
327
|
+
if (contents[offset] !== 255) {
|
|
328
|
+
offset += 1;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const marker = contents[offset + 1];
|
|
332
|
+
offset += 2;
|
|
333
|
+
if (marker === 216 || marker === 217) continue;
|
|
334
|
+
if (offset + 2 > contents.length) return void 0;
|
|
335
|
+
const length = contents.readUInt16BE(offset);
|
|
336
|
+
if (length < 2 || offset + length > contents.length) return void 0;
|
|
337
|
+
if (isJpegStartOfFrame(marker) && length >= 7) {
|
|
338
|
+
return validDimensions(contents.readUInt16BE(offset + 5), contents.readUInt16BE(offset + 3));
|
|
339
|
+
}
|
|
340
|
+
offset += length;
|
|
341
|
+
}
|
|
342
|
+
return void 0;
|
|
343
|
+
}
|
|
344
|
+
function isJpegStartOfFrame(marker) {
|
|
345
|
+
return marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker);
|
|
346
|
+
}
|
|
347
|
+
function readWebpMetadata(contents) {
|
|
348
|
+
if (contents.length < 30 || contents.toString("ascii", 0, 4) !== "RIFF" || contents.toString("ascii", 8, 12) !== "WEBP") return void 0;
|
|
349
|
+
const type = contents.toString("ascii", 12, 16);
|
|
350
|
+
if (type === "VP8X") {
|
|
351
|
+
return validDimensions(readUInt24LE(contents, 24) + 1, readUInt24LE(contents, 27) + 1);
|
|
352
|
+
}
|
|
353
|
+
if (type === "VP8 " && contents.length >= 30 && contents[23] === 157 && contents[24] === 1 && contents[25] === 42) {
|
|
354
|
+
return validDimensions(contents.readUInt16LE(26) & 16383, contents.readUInt16LE(28) & 16383);
|
|
355
|
+
}
|
|
356
|
+
if (type === "VP8L" && contents.length >= 25 && contents[20] === 47) {
|
|
357
|
+
const bits = contents.readUInt32LE(21);
|
|
358
|
+
return validDimensions((bits & 16383) + 1, (bits >>> 14 & 16383) + 1);
|
|
359
|
+
}
|
|
360
|
+
return void 0;
|
|
361
|
+
}
|
|
362
|
+
function readUInt24LE(contents, offset) {
|
|
363
|
+
return contents[offset] | contents[offset + 1] << 8 | contents[offset + 2] << 16;
|
|
364
|
+
}
|
|
365
|
+
function validDimensions(width, height) {
|
|
366
|
+
return width > 0 && height > 0 ? { width, height } : void 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// src/screenshot-area.ts
|
|
370
|
+
var defaultIdFactory = () => globalThis.crypto.randomUUID();
|
|
371
|
+
function cloneCanvasElement(element, { idFactory = defaultIdFactory, offsetX = 0, offsetY = 0 } = {}) {
|
|
372
|
+
return {
|
|
373
|
+
...structuredClone(element),
|
|
374
|
+
id: `element-${idFactory()}`,
|
|
375
|
+
x: element.x + offsetX,
|
|
376
|
+
y: element.y + offsetY
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function cloneScreenshotArea(area, { idFactory = defaultIdFactory, name = area.name } = {}) {
|
|
380
|
+
return {
|
|
381
|
+
...structuredClone(area),
|
|
382
|
+
id: `area-${idFactory()}`,
|
|
383
|
+
name,
|
|
384
|
+
elements: area.elements.map((element) => cloneCanvasElement(element, { idFactory }))
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/shared.ts
|
|
389
|
+
var ASSET_CATEGORIES = ["screenshots", "brand", "logos", "other"];
|
|
390
|
+
var SCREENSHOT_DEVICE_TYPES = ["iphone", "ipad", "mac", "watch"];
|
|
391
|
+
|
|
392
|
+
// src/project-store.ts
|
|
393
|
+
var CONFIG_FILENAME = "storeshot.json";
|
|
394
|
+
var ASSETS_DIRECTORY = "assets";
|
|
395
|
+
var SETS_DIRECTORY = "sets";
|
|
396
|
+
var ASSET_METADATA_FILENAME = ".storeshot-metadata.json";
|
|
397
|
+
var MOCKUP_BUNDLES_DIRECTORY = "mockup-bundles";
|
|
398
|
+
var MOCKUP_IMPORTS_DIRECTORY = ".imports";
|
|
399
|
+
var supportedExtensions = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp", ".svg"]);
|
|
400
|
+
var supportedMockupBundleExtensions = /* @__PURE__ */ new Set([".jpeg", ".jpg", ".md", ".png", ".svg", ".txt", ".webp"]);
|
|
401
|
+
var safeIdentifierPattern = /^[a-z0-9][a-z0-9-]*$/;
|
|
402
|
+
var hexColorPattern = /^#[0-9a-f]{6}$/i;
|
|
403
|
+
var ProjectStore = class {
|
|
404
|
+
root;
|
|
405
|
+
configPath;
|
|
406
|
+
assetsPath;
|
|
407
|
+
setsPath;
|
|
408
|
+
mockupBundlesPath;
|
|
409
|
+
mockupImportsPath;
|
|
410
|
+
operations = /* @__PURE__ */ new Map();
|
|
411
|
+
assetHashes = /* @__PURE__ */ new Map();
|
|
412
|
+
constructor(directory) {
|
|
413
|
+
this.root = path.resolve(directory);
|
|
414
|
+
this.configPath = path.join(this.root, CONFIG_FILENAME);
|
|
415
|
+
this.assetsPath = path.join(this.root, ASSETS_DIRECTORY);
|
|
416
|
+
this.setsPath = path.join(this.root, SETS_DIRECTORY);
|
|
417
|
+
this.mockupBundlesPath = path.join(this.root, MOCKUP_BUNDLES_DIRECTORY);
|
|
418
|
+
this.mockupImportsPath = path.join(this.mockupBundlesPath, MOCKUP_IMPORTS_DIRECTORY);
|
|
419
|
+
}
|
|
420
|
+
async initialize() {
|
|
421
|
+
await mkdir(this.root, { recursive: true });
|
|
422
|
+
await this.assertManagedDirectory(this.root);
|
|
423
|
+
await this.ensureManagedDirectory(this.assetsPath, this.root);
|
|
424
|
+
await this.ensureManagedDirectory(this.setsPath, this.root);
|
|
425
|
+
await this.ensureManagedDirectory(this.mockupBundlesPath, this.root);
|
|
426
|
+
await rm(this.mockupImportsPath, { recursive: true, force: true });
|
|
427
|
+
await this.ensureManagedDirectory(this.mockupImportsPath, this.mockupBundlesPath);
|
|
428
|
+
for (const category of ASSET_CATEGORIES) {
|
|
429
|
+
await this.ensureManagedDirectory(path.join(this.assetsPath, category), this.assetsPath);
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
await access(this.configPath, constants.F_OK);
|
|
433
|
+
} catch {
|
|
434
|
+
await this.writeConfig({
|
|
435
|
+
version: 1,
|
|
436
|
+
appName: path.basename(this.root),
|
|
437
|
+
platforms: ["ios"]
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async readProject() {
|
|
442
|
+
const [config, assets, sets] = await Promise.all([this.readConfig(), this.listAssets(), this.listSets()]);
|
|
443
|
+
return { directory: this.root, config, assets, sets };
|
|
444
|
+
}
|
|
445
|
+
async readConfig() {
|
|
446
|
+
const value = JSON.parse(await this.readManagedFile(this.configPath, this.root, "utf8"));
|
|
447
|
+
return parseConfig(value);
|
|
448
|
+
}
|
|
449
|
+
async writeConfig(config) {
|
|
450
|
+
const value = parseConfig(config);
|
|
451
|
+
await this.runExclusive(this.configPath, async () => {
|
|
452
|
+
await this.assertSafeWriteTarget(this.configPath, this.root);
|
|
453
|
+
await writeJson(this.configPath, value);
|
|
454
|
+
});
|
|
455
|
+
return value;
|
|
456
|
+
}
|
|
457
|
+
async listAssets() {
|
|
458
|
+
const entries = await Promise.all(
|
|
459
|
+
ASSET_CATEGORIES.map(async (category) => [category, await this.listAssetsInCategory(category)])
|
|
460
|
+
);
|
|
461
|
+
return Object.fromEntries(entries);
|
|
462
|
+
}
|
|
463
|
+
async deleteAsset(category, filename) {
|
|
464
|
+
const target = await this.resolveExistingAsset(category, filename);
|
|
465
|
+
await this.runExclusive(target, async () => unlink(target));
|
|
466
|
+
this.assetHashes.delete(target);
|
|
467
|
+
await this.updateAssetMetadataFile((metadata) => {
|
|
468
|
+
delete metadata[`${category}/${filename}`];
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
async updateAssetMetadata(category, filename, input) {
|
|
472
|
+
await this.resolveExistingAsset(category, filename);
|
|
473
|
+
if (category !== "screenshots") throw new Error("Device type can only be set for raw screenshots");
|
|
474
|
+
const deviceType = input.deviceType;
|
|
475
|
+
if (deviceType !== null && !SCREENSHOT_DEVICE_TYPES.includes(deviceType)) throw new Error("Unsupported screenshot device type");
|
|
476
|
+
await this.updateAssetMetadataFile((metadata) => {
|
|
477
|
+
const id = `${category}/${filename}`;
|
|
478
|
+
if (deviceType === null) delete metadata[id];
|
|
479
|
+
else metadata[id] = { deviceType };
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
async addAsset(category, filename, contents) {
|
|
483
|
+
const target = this.resolveAsset(category, filename);
|
|
484
|
+
assertValidImage(filename, contents);
|
|
485
|
+
return this.runExclusive("asset-catalog", async () => {
|
|
486
|
+
await this.assertSafeWriteTarget(target, path.dirname(target));
|
|
487
|
+
const incomingHash = hash(contents);
|
|
488
|
+
const replaced = await access(target, constants.F_OK).then(() => true).catch(() => false);
|
|
489
|
+
for (const assetCategory of ASSET_CATEGORIES) {
|
|
490
|
+
const directory = path.join(this.assetsPath, assetCategory);
|
|
491
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
492
|
+
for (const entry of entries) {
|
|
493
|
+
if (!entry.isFile() || !supportedExtensions.has(path.extname(entry.name).toLowerCase())) continue;
|
|
494
|
+
const existingPath = path.join(directory, entry.name);
|
|
495
|
+
if (existingPath === target) continue;
|
|
496
|
+
if (await this.hashAsset(existingPath, directory) === incomingHash) {
|
|
497
|
+
throw new DuplicateAssetError(`${assetCategory}/${entry.name}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
await writeFileAtomically(target, contents);
|
|
502
|
+
const metadata = await stat(target);
|
|
503
|
+
this.assetHashes.set(target, { hash: incomingHash, mtimeMs: metadata.mtimeMs, size: metadata.size });
|
|
504
|
+
return { replaced };
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
resolveAsset(category, filename) {
|
|
508
|
+
if (!isAssetCategory(category)) throw new Error("Unsupported asset category");
|
|
509
|
+
const safeName = path.basename(filename);
|
|
510
|
+
const extension = path.extname(safeName).toLowerCase();
|
|
511
|
+
if (safeName !== filename || !supportedExtensions.has(extension)) {
|
|
512
|
+
throw new Error("Unsupported asset filename");
|
|
513
|
+
}
|
|
514
|
+
return path.join(this.assetsPath, category, safeName);
|
|
515
|
+
}
|
|
516
|
+
async resolveExistingAsset(category, filename) {
|
|
517
|
+
const target = this.resolveAsset(category, filename);
|
|
518
|
+
await this.assertRegularFile(target);
|
|
519
|
+
const resolved = await realpath(target);
|
|
520
|
+
await this.assertContained(resolved, path.join(this.assetsPath, category));
|
|
521
|
+
return resolved;
|
|
522
|
+
}
|
|
523
|
+
async listSets() {
|
|
524
|
+
const entries = await readdir(this.setsPath, { withFileTypes: true });
|
|
525
|
+
const sets = await Promise.all(
|
|
526
|
+
entries.filter((entry) => entry.isFile() && path.extname(entry.name) === ".json").map(async (entry) => parseSet(JSON.parse(await this.readManagedFile(path.join(this.setsPath, entry.name), this.setsPath, "utf8"))))
|
|
527
|
+
);
|
|
528
|
+
return sets.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
529
|
+
}
|
|
530
|
+
async createSet(input) {
|
|
531
|
+
const value = parseCreateSetInput(input);
|
|
532
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
533
|
+
const id = `${slugify(value.name)}-${randomUUID().slice(0, 8)}`;
|
|
534
|
+
const areaId = `area-${randomUUID()}`;
|
|
535
|
+
const set = {
|
|
536
|
+
version: 1,
|
|
537
|
+
id,
|
|
538
|
+
name: value.name,
|
|
539
|
+
locale: value.locale,
|
|
540
|
+
device: value.device,
|
|
541
|
+
canvas: { width: value.width, height: value.height },
|
|
542
|
+
areas: [
|
|
543
|
+
{
|
|
544
|
+
id: areaId,
|
|
545
|
+
name: "Screenshot 1",
|
|
546
|
+
background: "#245f4a",
|
|
547
|
+
elements: [
|
|
548
|
+
{
|
|
549
|
+
id: `element-${randomUUID()}`,
|
|
550
|
+
type: "text",
|
|
551
|
+
text: "Your headline",
|
|
552
|
+
x: Math.round(value.width * 0.08),
|
|
553
|
+
y: Math.round(value.height * 0.07),
|
|
554
|
+
width: Math.round(value.width * 0.84),
|
|
555
|
+
height: Math.round(value.height * 0.2),
|
|
556
|
+
rotation: 0,
|
|
557
|
+
opacity: 1,
|
|
558
|
+
fontFamily: "Geist Variable",
|
|
559
|
+
fontSize: Math.max(48, Math.round(value.width * 0.09)),
|
|
560
|
+
fontWeight: 700,
|
|
561
|
+
color: "#ffffff",
|
|
562
|
+
textAlign: "center"
|
|
563
|
+
}
|
|
564
|
+
]
|
|
565
|
+
}
|
|
566
|
+
],
|
|
567
|
+
createdAt: now,
|
|
568
|
+
updatedAt: now
|
|
569
|
+
};
|
|
570
|
+
const target = this.resolveSet(id);
|
|
571
|
+
await this.runExclusive(target, async () => {
|
|
572
|
+
await this.assertSafeWriteTarget(target, this.setsPath);
|
|
573
|
+
await writeJson(target, set);
|
|
574
|
+
});
|
|
575
|
+
return set;
|
|
576
|
+
}
|
|
577
|
+
async duplicateSet(id) {
|
|
578
|
+
const source = await this.readSet(id);
|
|
579
|
+
const existingSets = await this.listSets();
|
|
580
|
+
const name = duplicateName(source.name, existingSets.map((set) => set.name));
|
|
581
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
582
|
+
const duplicate = {
|
|
583
|
+
...structuredClone(source),
|
|
584
|
+
id: `${slugify(name)}-${randomUUID().slice(0, 8)}`,
|
|
585
|
+
name,
|
|
586
|
+
areas: source.areas.map((area) => cloneScreenshotArea(area, { idFactory: randomUUID })),
|
|
587
|
+
createdAt: now,
|
|
588
|
+
updatedAt: now
|
|
589
|
+
};
|
|
590
|
+
const target = this.resolveSet(duplicate.id);
|
|
591
|
+
await this.runExclusive(target, async () => {
|
|
592
|
+
await this.assertSafeWriteTarget(target, this.setsPath);
|
|
593
|
+
await writeJson(target, duplicate);
|
|
594
|
+
});
|
|
595
|
+
return duplicate;
|
|
596
|
+
}
|
|
597
|
+
async writeSet(id, set) {
|
|
598
|
+
if (id !== set.id) throw new Error("Set id cannot be changed");
|
|
599
|
+
const target = this.resolveSet(id);
|
|
600
|
+
return this.runExclusive(target, async () => {
|
|
601
|
+
const value = parseSet({ ...set, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
602
|
+
await this.assertSafeWriteTarget(target, this.setsPath);
|
|
603
|
+
await writeJson(target, value);
|
|
604
|
+
return value;
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
async updateSetMetadata(id, input) {
|
|
608
|
+
const metadata = parseSetMetadataInput(input);
|
|
609
|
+
const target = this.resolveSet(id);
|
|
610
|
+
return this.runExclusive(target, async () => {
|
|
611
|
+
const current = parseSet(JSON.parse(await this.readManagedFile(target, this.setsPath, "utf8")));
|
|
612
|
+
const value = parseSet({ ...current, ...metadata, updatedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
613
|
+
await writeJson(target, value);
|
|
614
|
+
return value;
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
async deleteSet(id) {
|
|
618
|
+
const target = this.resolveSet(id);
|
|
619
|
+
await this.runExclusive(target, async () => {
|
|
620
|
+
await this.assertRegularFile(target);
|
|
621
|
+
await unlink(target);
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
async listMockupCatalog() {
|
|
625
|
+
const entries = await readdir(this.mockupBundlesPath, { withFileTypes: true });
|
|
626
|
+
const catalogs = (await Promise.all(entries.filter((entry) => entry.isDirectory() && safeIdentifierPattern.test(entry.name)).map(async (entry) => {
|
|
627
|
+
const manifestPath = path.join(this.mockupBundlesPath, entry.name, MOCKUP_BUNDLE_FILENAME);
|
|
628
|
+
let contents;
|
|
629
|
+
try {
|
|
630
|
+
contents = await this.readManagedFile(manifestPath, path.dirname(manifestPath), "utf8");
|
|
631
|
+
} catch (error) {
|
|
632
|
+
if (error.code === "ENOENT") return void 0;
|
|
633
|
+
throw error;
|
|
634
|
+
}
|
|
635
|
+
const manifest = parseMockupBundleManifest(JSON.parse(contents));
|
|
636
|
+
if (manifest.id !== entry.name) throw new Error(`Mockup bundle folder ${entry.name} does not match manifest id ${manifest.id}`);
|
|
637
|
+
return resolveMockupBundle(
|
|
638
|
+
manifest,
|
|
639
|
+
`/api/mockup-bundle-files/${encodeURIComponent(manifest.id)}/`,
|
|
640
|
+
"project"
|
|
641
|
+
);
|
|
642
|
+
}))).filter((catalog) => catalog !== void 0);
|
|
643
|
+
return catalogs.length > 0 ? mergeDeviceMockupCatalogs(...catalogs) : emptyDeviceMockupCatalog();
|
|
644
|
+
}
|
|
645
|
+
async writeMockupBundleManifest(bundleId, input) {
|
|
646
|
+
if (!safeIdentifierPattern.test(bundleId)) throw new Error("Unsupported mockup bundle id");
|
|
647
|
+
if (bundleId === "frameup-free") throw new Error("frameup-free is reserved for StoreShot's built-in bundle");
|
|
648
|
+
const manifest = parseMockupBundleManifest(input);
|
|
649
|
+
if (manifest.id !== bundleId) throw new Error("Mockup bundle id cannot be changed");
|
|
650
|
+
const bundleDirectory = path.join(this.mockupBundlesPath, bundleId);
|
|
651
|
+
await this.ensureManagedDirectory(bundleDirectory, this.mockupBundlesPath);
|
|
652
|
+
await Promise.all(bundleAssetPaths(manifest).map(async (relativePath) => {
|
|
653
|
+
await this.resolveExistingMockupBundleFile(bundleId, relativePath);
|
|
654
|
+
}));
|
|
655
|
+
const manifestPath = path.join(bundleDirectory, MOCKUP_BUNDLE_FILENAME);
|
|
656
|
+
await this.runExclusive(manifestPath, async () => writeJson(manifestPath, manifest));
|
|
657
|
+
return manifest;
|
|
658
|
+
}
|
|
659
|
+
async writeMockupBundleFile(bundleId, relativePath, contents) {
|
|
660
|
+
if (bundleId === "frameup-free") throw new Error("frameup-free is reserved for StoreShot's built-in bundle");
|
|
661
|
+
const target = this.resolveMockupBundleFile(bundleId, relativePath);
|
|
662
|
+
if (isImageFile(relativePath)) assertValidImage(relativePath, contents);
|
|
663
|
+
await this.ensureManagedDirectory(path.dirname(target), this.mockupBundlesPath);
|
|
664
|
+
await this.assertSafeWriteTarget(target, path.dirname(target));
|
|
665
|
+
await this.runExclusive(target, async () => writeFileAtomically(target, contents));
|
|
666
|
+
}
|
|
667
|
+
async writeMockupBundleImportFile(importId, bundleId, relativePath, contents) {
|
|
668
|
+
this.assertMockupImportId(importId);
|
|
669
|
+
this.resolveMockupBundleFile(bundleId, relativePath);
|
|
670
|
+
if (isImageFile(relativePath)) assertValidImage(relativePath, contents);
|
|
671
|
+
const bundleDirectory = path.join(this.mockupImportsPath, importId, bundleId);
|
|
672
|
+
const target = path.resolve(bundleDirectory, relativePath);
|
|
673
|
+
if (!target.startsWith(`${bundleDirectory}${path.sep}`)) throw new Error("Mockup bundle file is outside the import");
|
|
674
|
+
await this.ensureManagedDirectory(path.dirname(target), this.mockupImportsPath);
|
|
675
|
+
await this.assertSafeWriteTarget(target, path.dirname(target));
|
|
676
|
+
await this.runExclusive(target, async () => writeFileAtomically(target, contents));
|
|
677
|
+
}
|
|
678
|
+
async commitMockupBundleImport(importId, bundleId, input) {
|
|
679
|
+
this.assertMockupImportId(importId);
|
|
680
|
+
if (!safeIdentifierPattern.test(bundleId)) throw new Error("Unsupported mockup bundle id");
|
|
681
|
+
if (bundleId === "frameup-free") throw new Error("frameup-free is reserved for StoreShot's built-in bundle");
|
|
682
|
+
const manifest = parseMockupBundleManifest(input);
|
|
683
|
+
if (manifest.id !== bundleId) throw new Error("Mockup bundle id cannot be changed");
|
|
684
|
+
const importDirectory = path.join(this.mockupImportsPath, importId);
|
|
685
|
+
const stagedBundle = path.join(importDirectory, bundleId);
|
|
686
|
+
await this.assertManagedDirectory(stagedBundle);
|
|
687
|
+
await Promise.all(bundleAssetPaths(manifest).map(async (relativePath) => {
|
|
688
|
+
const target = path.resolve(stagedBundle, relativePath);
|
|
689
|
+
if (!target.startsWith(`${stagedBundle}${path.sep}`)) throw new Error("Mockup bundle file is outside the import");
|
|
690
|
+
await this.readManagedFile(target, stagedBundle);
|
|
691
|
+
}));
|
|
692
|
+
await writeJson(path.join(stagedBundle, MOCKUP_BUNDLE_FILENAME), manifest);
|
|
693
|
+
const finalBundle = path.join(this.mockupBundlesPath, bundleId);
|
|
694
|
+
const backupBundle = path.join(this.mockupBundlesPath, `.${bundleId}.${randomUUID()}.backup`);
|
|
695
|
+
await this.runExclusive(`mockup-bundle:${bundleId}`, async () => {
|
|
696
|
+
let hasBackup = false;
|
|
697
|
+
try {
|
|
698
|
+
const metadata = await lstat(finalBundle);
|
|
699
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("Mockup bundle must be a regular directory");
|
|
700
|
+
await rename(finalBundle, backupBundle);
|
|
701
|
+
hasBackup = true;
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (error.code !== "ENOENT") throw error;
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
await rename(stagedBundle, finalBundle);
|
|
707
|
+
} catch (error) {
|
|
708
|
+
if (hasBackup) await rename(backupBundle, finalBundle);
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
if (hasBackup) await rm(backupBundle, { recursive: true, force: true }).catch(() => void 0);
|
|
712
|
+
await rm(importDirectory, { recursive: true, force: true }).catch(() => void 0);
|
|
713
|
+
});
|
|
714
|
+
return manifest;
|
|
715
|
+
}
|
|
716
|
+
async discardMockupBundleImport(importId) {
|
|
717
|
+
this.assertMockupImportId(importId);
|
|
718
|
+
const target = path.join(this.mockupImportsPath, importId);
|
|
719
|
+
const relative = path.relative(this.mockupImportsPath, target);
|
|
720
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Mockup import is outside the project boundary");
|
|
721
|
+
await rm(target, { recursive: true, force: true });
|
|
722
|
+
}
|
|
723
|
+
resolveMockupBundleFile(bundleId, relativePath) {
|
|
724
|
+
if (!safeIdentifierPattern.test(bundleId)) throw new Error("Unsupported mockup bundle id");
|
|
725
|
+
if (!isSafeBundlePath(relativePath) || relativePath === MOCKUP_BUNDLE_FILENAME) {
|
|
726
|
+
throw new Error("Unsupported mockup bundle file path");
|
|
727
|
+
}
|
|
728
|
+
const extension = path.extname(relativePath).toLowerCase();
|
|
729
|
+
if (path.basename(relativePath) !== "LICENSE" && !supportedMockupBundleExtensions.has(extension)) {
|
|
730
|
+
throw new Error("Unsupported mockup bundle file type");
|
|
731
|
+
}
|
|
732
|
+
const bundleRoot = path.join(this.mockupBundlesPath, bundleId);
|
|
733
|
+
const target = path.resolve(bundleRoot, relativePath);
|
|
734
|
+
if (!target.startsWith(`${bundleRoot}${path.sep}`)) throw new Error("Mockup bundle file is outside the bundle");
|
|
735
|
+
return target;
|
|
736
|
+
}
|
|
737
|
+
async resolveExistingMockupBundleFile(bundleId, relativePath) {
|
|
738
|
+
const bundleRoot = await realpath(path.join(this.mockupBundlesPath, bundleId));
|
|
739
|
+
await this.assertContained(bundleRoot, this.mockupBundlesPath);
|
|
740
|
+
await this.assertRegularFile(this.resolveMockupBundleFile(bundleId, relativePath));
|
|
741
|
+
const target = await realpath(this.resolveMockupBundleFile(bundleId, relativePath));
|
|
742
|
+
if (!target.startsWith(`${bundleRoot}${path.sep}`)) throw new Error("Mockup bundle file is outside the bundle");
|
|
743
|
+
return target;
|
|
744
|
+
}
|
|
745
|
+
async listAssetsInCategory(category) {
|
|
746
|
+
const directory = path.join(this.assetsPath, category);
|
|
747
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
748
|
+
const files = entries.filter((entry) => entry.isFile() && supportedExtensions.has(path.extname(entry.name).toLowerCase())).sort((a, b) => a.name.localeCompare(b.name));
|
|
749
|
+
const assetMetadata = await this.readAssetMetadata();
|
|
750
|
+
return Promise.all(
|
|
751
|
+
files.map(async (file) => {
|
|
752
|
+
const filename = path.join(directory, file.name);
|
|
753
|
+
const [metadata, contents] = await Promise.all([stat(filename), this.readManagedFile(filename, directory)]);
|
|
754
|
+
const dimensions = readImageMetadata(contents);
|
|
755
|
+
const detectedDeviceType = category === "screenshots" && dimensions ? detectScreenshotDeviceType(dimensions.width, dimensions.height) : void 0;
|
|
756
|
+
const deviceTypeOverride = assetMetadata[`${category}/${file.name}`]?.deviceType;
|
|
757
|
+
return {
|
|
758
|
+
id: `${category}/${file.name}`,
|
|
759
|
+
category,
|
|
760
|
+
name: file.name,
|
|
761
|
+
url: `/api/assets/${category}/${encodeURIComponent(file.name)}?v=${Math.trunc(metadata.mtimeMs)}`,
|
|
762
|
+
size: metadata.size,
|
|
763
|
+
modifiedAt: metadata.mtime.toISOString(),
|
|
764
|
+
...dimensions ?? {},
|
|
765
|
+
...detectedDeviceType ? {
|
|
766
|
+
detectedDeviceType,
|
|
767
|
+
deviceTypeOverride,
|
|
768
|
+
deviceType: deviceTypeOverride ?? detectedDeviceType
|
|
769
|
+
} : {}
|
|
770
|
+
};
|
|
771
|
+
})
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
async readAssetMetadata() {
|
|
775
|
+
const target = path.join(this.assetsPath, ASSET_METADATA_FILENAME);
|
|
776
|
+
try {
|
|
777
|
+
const value = JSON.parse(await this.readManagedFile(target, this.assetsPath, "utf8"));
|
|
778
|
+
if (!isRecord2(value) || value.version !== 1 || !isRecord2(value.assets)) return {};
|
|
779
|
+
return Object.fromEntries(Object.entries(value.assets).flatMap(([id, entry]) => {
|
|
780
|
+
if (!isRecord2(entry) || !SCREENSHOT_DEVICE_TYPES.includes(entry.deviceType)) return [];
|
|
781
|
+
return [[id, { deviceType: entry.deviceType }]];
|
|
782
|
+
}));
|
|
783
|
+
} catch (error) {
|
|
784
|
+
if (error.code === "ENOENT") return {};
|
|
785
|
+
throw error;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
async writeAssetMetadata(assets) {
|
|
789
|
+
const target = path.join(this.assetsPath, ASSET_METADATA_FILENAME);
|
|
790
|
+
await this.assertSafeWriteTarget(target, this.assetsPath);
|
|
791
|
+
await writeJson(target, { version: 1, assets });
|
|
792
|
+
}
|
|
793
|
+
async updateAssetMetadataFile(update) {
|
|
794
|
+
const target = path.join(this.assetsPath, ASSET_METADATA_FILENAME);
|
|
795
|
+
await this.runExclusive(target, async () => {
|
|
796
|
+
const metadata = await this.readAssetMetadata();
|
|
797
|
+
update(metadata);
|
|
798
|
+
await this.writeAssetMetadata(metadata);
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
async hashAsset(filename, boundary) {
|
|
802
|
+
const metadata = await stat(filename);
|
|
803
|
+
const cached = this.assetHashes.get(filename);
|
|
804
|
+
if (cached && cached.mtimeMs === metadata.mtimeMs && cached.size === metadata.size) return cached.hash;
|
|
805
|
+
const value = hash(await this.readManagedFile(filename, boundary));
|
|
806
|
+
this.assetHashes.set(filename, { hash: value, mtimeMs: metadata.mtimeMs, size: metadata.size });
|
|
807
|
+
return value;
|
|
808
|
+
}
|
|
809
|
+
async readSet(id) {
|
|
810
|
+
const target = this.resolveSet(id);
|
|
811
|
+
return parseSet(JSON.parse(await this.readManagedFile(target, this.setsPath, "utf8")));
|
|
812
|
+
}
|
|
813
|
+
async runExclusive(key, operation) {
|
|
814
|
+
const previous = this.operations.get(key) ?? Promise.resolve();
|
|
815
|
+
const result = previous.catch(() => void 0).then(operation);
|
|
816
|
+
const tail = result.then(() => void 0, () => void 0);
|
|
817
|
+
this.operations.set(key, tail);
|
|
818
|
+
try {
|
|
819
|
+
return await result;
|
|
820
|
+
} finally {
|
|
821
|
+
if (this.operations.get(key) === tail) this.operations.delete(key);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
async assertManagedDirectory(directory) {
|
|
825
|
+
const metadata = await lstat(directory);
|
|
826
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
827
|
+
throw new Error(`${path.relative(this.root, directory) || "."} must be a regular project directory`);
|
|
828
|
+
}
|
|
829
|
+
await this.assertContained(await realpath(directory), this.root, true);
|
|
830
|
+
}
|
|
831
|
+
async ensureManagedDirectory(directory, boundary) {
|
|
832
|
+
await this.assertManagedDirectory(boundary);
|
|
833
|
+
const relative = path.relative(boundary, directory);
|
|
834
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Directory is outside the project boundary");
|
|
835
|
+
let current = boundary;
|
|
836
|
+
for (const segment of relative.split(path.sep).filter(Boolean)) {
|
|
837
|
+
current = path.join(current, segment);
|
|
838
|
+
try {
|
|
839
|
+
await mkdir(current);
|
|
840
|
+
} catch (error) {
|
|
841
|
+
if (error.code !== "EEXIST") throw error;
|
|
842
|
+
}
|
|
843
|
+
const metadata = await lstat(current);
|
|
844
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) throw new Error("Project directories cannot be symbolic links");
|
|
845
|
+
}
|
|
846
|
+
await this.assertContained(await realpath(directory), boundary, true);
|
|
847
|
+
}
|
|
848
|
+
async assertSafeWriteTarget(target, boundary) {
|
|
849
|
+
await this.assertManagedDirectory(boundary);
|
|
850
|
+
const relative = path.relative(boundary, target);
|
|
851
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("File is outside the project boundary");
|
|
852
|
+
try {
|
|
853
|
+
const metadata = await lstat(target);
|
|
854
|
+
if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error("Project files must be regular files");
|
|
855
|
+
} catch (error) {
|
|
856
|
+
if (error.code !== "ENOENT") throw error;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
async readManagedFile(filename, boundary, encoding) {
|
|
860
|
+
await this.assertRegularFile(filename);
|
|
861
|
+
await this.assertContained(await realpath(filename), boundary);
|
|
862
|
+
return encoding ? readFile(filename, encoding) : readFile(filename);
|
|
863
|
+
}
|
|
864
|
+
async assertRegularFile(filename) {
|
|
865
|
+
const metadata = await lstat(filename);
|
|
866
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("Project files must be regular files");
|
|
867
|
+
}
|
|
868
|
+
async assertContained(candidate, boundary, allowEqual = false) {
|
|
869
|
+
const resolvedBoundary = await realpath(boundary);
|
|
870
|
+
if (allowEqual && candidate === resolvedBoundary || candidate.startsWith(`${resolvedBoundary}${path.sep}`)) return;
|
|
871
|
+
throw new Error("File is outside the project boundary");
|
|
872
|
+
}
|
|
873
|
+
assertMockupImportId(importId) {
|
|
874
|
+
if (!safeIdentifierPattern.test(importId)) throw new Error("Unsupported mockup import id");
|
|
875
|
+
}
|
|
876
|
+
resolveSet(id) {
|
|
877
|
+
if (!safeIdentifierPattern.test(id)) throw new Error("Unsupported set id");
|
|
878
|
+
return path.join(this.setsPath, `${id}.json`);
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
var DuplicateAssetError = class extends Error {
|
|
882
|
+
constructor(existingAssetId) {
|
|
883
|
+
super(`This file is already in the asset catalog as ${existingAssetId}`);
|
|
884
|
+
this.existingAssetId = existingAssetId;
|
|
885
|
+
}
|
|
886
|
+
existingAssetId;
|
|
887
|
+
};
|
|
888
|
+
function hash(contents) {
|
|
889
|
+
return createHash("sha256").update(contents).digest("hex");
|
|
890
|
+
}
|
|
891
|
+
function parseConfig(value) {
|
|
892
|
+
if (!isRecord2(value)) throw new Error("storeshot.json must contain a JSON object");
|
|
893
|
+
const appName = readString2(value.appName, "appName");
|
|
894
|
+
const platforms2 = Array.isArray(value.platforms) ? value.platforms.filter((platform) => platform === "ios" || platform === "android") : [];
|
|
895
|
+
if (platforms2.length === 0) throw new Error("platforms must include ios or android");
|
|
896
|
+
return { version: 1, appName, platforms: [...new Set(platforms2)] };
|
|
897
|
+
}
|
|
898
|
+
function parseCreateSetInput(value) {
|
|
899
|
+
if (!isRecord2(value)) throw new Error("Set details must be an object");
|
|
900
|
+
return {
|
|
901
|
+
name: readString2(value.name, "Set name"),
|
|
902
|
+
locale: readString2(value.locale, "Locale"),
|
|
903
|
+
device: readString2(value.device, "Device"),
|
|
904
|
+
width: readDimension2(value.width, "Canvas width"),
|
|
905
|
+
height: readDimension2(value.height, "Canvas height")
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
function duplicateName(sourceName, existingNames) {
|
|
909
|
+
const names = new Set(existingNames.map((name) => name.toLocaleLowerCase()));
|
|
910
|
+
const baseName = `${sourceName} copy`;
|
|
911
|
+
if (!names.has(baseName.toLocaleLowerCase())) return baseName;
|
|
912
|
+
let copyNumber = 2;
|
|
913
|
+
while (names.has(`${baseName} ${copyNumber}`.toLocaleLowerCase())) copyNumber += 1;
|
|
914
|
+
return `${baseName} ${copyNumber}`;
|
|
915
|
+
}
|
|
916
|
+
function parseSetMetadataInput(value) {
|
|
917
|
+
if (!isRecord2(value)) throw new Error("Set settings must be an object");
|
|
918
|
+
return {
|
|
919
|
+
name: readString2(value.name, "Set name"),
|
|
920
|
+
locale: readString2(value.locale, "Locale"),
|
|
921
|
+
device: readString2(value.device, "Device")
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
function parseSet(value) {
|
|
925
|
+
if (!isRecord2(value)) throw new Error("Set file must contain an object");
|
|
926
|
+
const id = readString2(value.id, "Set id");
|
|
927
|
+
if (!safeIdentifierPattern.test(id)) throw new Error("Set id is invalid");
|
|
928
|
+
if (!isRecord2(value.canvas)) throw new Error("Set canvas is invalid");
|
|
929
|
+
if (!Array.isArray(value.areas) || value.areas.length === 0) throw new Error("A set needs at least one screenshot area");
|
|
930
|
+
return {
|
|
931
|
+
version: 1,
|
|
932
|
+
id,
|
|
933
|
+
name: readString2(value.name, "Set name"),
|
|
934
|
+
locale: readString2(value.locale, "Locale"),
|
|
935
|
+
device: readString2(value.device, "Device"),
|
|
936
|
+
canvas: {
|
|
937
|
+
width: readDimension2(value.canvas.width, "Canvas width"),
|
|
938
|
+
height: readDimension2(value.canvas.height, "Canvas height")
|
|
939
|
+
},
|
|
940
|
+
areas: value.areas.map(parseArea),
|
|
941
|
+
createdAt: readString2(value.createdAt, "Created time"),
|
|
942
|
+
updatedAt: readString2(value.updatedAt, "Updated time")
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
function parseArea(value) {
|
|
946
|
+
if (!isRecord2(value)) throw new Error("Screenshot area is invalid");
|
|
947
|
+
const background = readString2(value.background, "Area background");
|
|
948
|
+
if (!hexColorPattern.test(background)) throw new Error("Area background must be a hex color");
|
|
949
|
+
if (!Array.isArray(value.elements)) throw new Error("Area elements must be an array");
|
|
950
|
+
return {
|
|
951
|
+
id: readString2(value.id, "Area id"),
|
|
952
|
+
name: readString2(value.name, "Area name"),
|
|
953
|
+
background,
|
|
954
|
+
elements: value.elements.map(parseElement)
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
function parseImageSource(value, legacyAssetId) {
|
|
958
|
+
if (isRecord2(value)) {
|
|
959
|
+
if (value.kind === "builtin") return { kind: "builtin", id: readString2(value.id, "Built-in artwork id") };
|
|
960
|
+
if (value.kind === "asset") return { kind: "asset", assetId: readString2(value.assetId, "Asset id") };
|
|
961
|
+
throw new Error("Image source type is invalid");
|
|
962
|
+
}
|
|
963
|
+
if (legacyAssetId !== void 0) return { kind: "asset", assetId: readString2(legacyAssetId, "Asset id") };
|
|
964
|
+
throw new Error("Image source is invalid");
|
|
965
|
+
}
|
|
966
|
+
function parseElement(value) {
|
|
967
|
+
if (!isRecord2(value)) throw new Error("Canvas element is invalid");
|
|
968
|
+
const base = {
|
|
969
|
+
id: readString2(value.id, "Element id"),
|
|
970
|
+
x: readNumber2(value.x, "Element x"),
|
|
971
|
+
y: readNumber2(value.y, "Element y"),
|
|
972
|
+
width: readDimension2(value.width, "Element width"),
|
|
973
|
+
height: readDimension2(value.height, "Element height"),
|
|
974
|
+
rotation: readNumber2(value.rotation, "Element rotation"),
|
|
975
|
+
opacity: readOptionalRange(value.opacity, 1, 0, 1, "Element opacity")
|
|
976
|
+
};
|
|
977
|
+
if (value.type === "image") {
|
|
978
|
+
if (value.fit !== "contain" && value.fit !== "cover") throw new Error("Image fit is invalid");
|
|
979
|
+
const source = parseImageSource(value.source, value.assetId);
|
|
980
|
+
const fill = value.fill === void 0 ? void 0 : readString2(value.fill, "Image fill");
|
|
981
|
+
if (fill !== void 0 && !hexColorPattern.test(fill)) throw new Error("Image fill must be a hex color");
|
|
982
|
+
return { ...base, type: "image", source, fit: value.fit, ...fill === void 0 ? {} : { fill } };
|
|
983
|
+
}
|
|
984
|
+
if (value.type === "mockup") {
|
|
985
|
+
return {
|
|
986
|
+
...base,
|
|
987
|
+
type: "mockup",
|
|
988
|
+
mockupId: readString2(value.mockupId, "Device mockup id"),
|
|
989
|
+
assetId: readString2(value.assetId, "Asset id")
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
if (value.type === "element") {
|
|
993
|
+
const source = parseImageSource(value.source);
|
|
994
|
+
const fill = value.fill === void 0 ? void 0 : readString2(value.fill, "Image fill");
|
|
995
|
+
if (fill !== void 0 && !hexColorPattern.test(fill)) throw new Error("Image fill must be a hex color");
|
|
996
|
+
return {
|
|
997
|
+
...base,
|
|
998
|
+
type: "image",
|
|
999
|
+
source,
|
|
1000
|
+
fit: "contain",
|
|
1001
|
+
...fill === void 0 ? {} : { fill }
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
if (value.type === "text") {
|
|
1005
|
+
const fontWeight = Number(value.fontWeight);
|
|
1006
|
+
if (!Number.isInteger(fontWeight) || fontWeight < 100 || fontWeight > 900 || fontWeight % 100 !== 0) {
|
|
1007
|
+
throw new Error("Text weight is invalid");
|
|
1008
|
+
}
|
|
1009
|
+
if (value.textAlign !== "left" && value.textAlign !== "center" && value.textAlign !== "right") {
|
|
1010
|
+
throw new Error("Text alignment is invalid");
|
|
1011
|
+
}
|
|
1012
|
+
const color = readString2(value.color, "Text color");
|
|
1013
|
+
if (!hexColorPattern.test(color)) throw new Error("Text color must be a hex color");
|
|
1014
|
+
const lineHeight = value.lineHeight === void 0 ? void 0 : readDimension2(value.lineHeight, "Text line height");
|
|
1015
|
+
return {
|
|
1016
|
+
...base,
|
|
1017
|
+
type: "text",
|
|
1018
|
+
text: typeof value.text === "string" ? value.text : "",
|
|
1019
|
+
fontFamily: readOptionalString(value.fontFamily, "Geist Variable"),
|
|
1020
|
+
fontSize: readDimension2(value.fontSize, "Font size"),
|
|
1021
|
+
fontWeight,
|
|
1022
|
+
...lineHeight === void 0 ? {} : { lineHeight },
|
|
1023
|
+
color,
|
|
1024
|
+
textAlign: value.textAlign
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
if (value.type === "shape") {
|
|
1028
|
+
if (value.shape !== "rectangle") throw new Error("Shape type is invalid");
|
|
1029
|
+
const fill = readString2(value.fill, "Shape fill");
|
|
1030
|
+
if (!hexColorPattern.test(fill)) throw new Error("Shape fill must be a hex color");
|
|
1031
|
+
return {
|
|
1032
|
+
...base,
|
|
1033
|
+
type: "shape",
|
|
1034
|
+
shape: value.shape,
|
|
1035
|
+
fill,
|
|
1036
|
+
cornerRadius: readOptionalRange(value.cornerRadius, 0, 0, 1e4, "Shape corner radius")
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
throw new Error("Unsupported canvas element type");
|
|
1040
|
+
}
|
|
1041
|
+
function readString2(value, name) {
|
|
1042
|
+
const result = typeof value === "string" ? value.trim() : "";
|
|
1043
|
+
if (!result) throw new Error(`${name} must be a non-empty string`);
|
|
1044
|
+
return result;
|
|
1045
|
+
}
|
|
1046
|
+
function readNumber2(value, name) {
|
|
1047
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a number`);
|
|
1048
|
+
return value;
|
|
1049
|
+
}
|
|
1050
|
+
function readOptionalRange(value, fallback, min, max, name) {
|
|
1051
|
+
if (value === void 0) return fallback;
|
|
1052
|
+
const result = readNumber2(value, name);
|
|
1053
|
+
if (result < min || result > max) throw new Error(`${name} is outside the supported range`);
|
|
1054
|
+
return result;
|
|
1055
|
+
}
|
|
1056
|
+
function readOptionalString(value, fallback) {
|
|
1057
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
1058
|
+
}
|
|
1059
|
+
function readDimension2(value, name) {
|
|
1060
|
+
const result = readNumber2(value, name);
|
|
1061
|
+
if (result <= 0 || result > 2e4) throw new Error(`${name} is outside the supported range`);
|
|
1062
|
+
return result;
|
|
1063
|
+
}
|
|
1064
|
+
function isAssetCategory(value) {
|
|
1065
|
+
return ASSET_CATEGORIES.includes(value);
|
|
1066
|
+
}
|
|
1067
|
+
function isRecord2(value) {
|
|
1068
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1069
|
+
}
|
|
1070
|
+
function slugify(value) {
|
|
1071
|
+
const result = value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1072
|
+
return result || "set";
|
|
1073
|
+
}
|
|
1074
|
+
async function writeJson(filename, value) {
|
|
1075
|
+
await writeFileAtomically(filename, `${JSON.stringify(value, null, 2)}
|
|
1076
|
+
`);
|
|
1077
|
+
}
|
|
1078
|
+
async function writeFileAtomically(filename, contents) {
|
|
1079
|
+
const temporary = path.join(path.dirname(filename), `.${path.basename(filename)}.${randomUUID()}.tmp`);
|
|
1080
|
+
try {
|
|
1081
|
+
await writeFile(temporary, contents, { flag: "wx" });
|
|
1082
|
+
await rename(temporary, filename);
|
|
1083
|
+
} finally {
|
|
1084
|
+
await unlink(temporary).catch((error) => {
|
|
1085
|
+
if (error.code !== "ENOENT") throw error;
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
function isImageFile(filename) {
|
|
1090
|
+
return supportedExtensions.has(path.extname(filename).toLowerCase());
|
|
1091
|
+
}
|
|
1092
|
+
function assertValidImage(filename, contents) {
|
|
1093
|
+
if (!isImageFile(filename) || !readImageMetadata(contents)) throw new Error("The uploaded file is not a valid supported image");
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// src/server.ts
|
|
1097
|
+
var MAX_REQUEST_BYTES = 25 * 1024 * 1024;
|
|
1098
|
+
async function startServer(options) {
|
|
1099
|
+
const store = new ProjectStore(options.projectDirectory);
|
|
1100
|
+
await store.initialize();
|
|
1101
|
+
let vite;
|
|
1102
|
+
if (options.useVite) {
|
|
1103
|
+
const { createServer: createViteServer } = await import("vite");
|
|
1104
|
+
vite = await createViteServer({
|
|
1105
|
+
configFile: path2.join(options.packageRoot, "vite.config.ts"),
|
|
1106
|
+
server: { middlewareMode: true },
|
|
1107
|
+
appType: "spa"
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
const server = createServer(async (request, response) => {
|
|
1111
|
+
try {
|
|
1112
|
+
if ((request.url ?? "").startsWith("/api/")) assertAllowedBrowserRequest(request, options.host);
|
|
1113
|
+
if (await handleApiRequest(request, response, store)) return;
|
|
1114
|
+
if (vite) {
|
|
1115
|
+
vite.middlewares(request, response);
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
await serveBuiltFrontend(request, response, path2.join(options.packageRoot, "dist/ui"));
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
const failure = httpFailureFor(error, request);
|
|
1121
|
+
if (failure.status >= 500) console.error(error);
|
|
1122
|
+
if (!response.headersSent) sendJson(response, failure.status, { error: failure.message, ...failure.code ? { code: failure.code } : {} });
|
|
1123
|
+
else response.end();
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
await new Promise((resolve, reject) => {
|
|
1127
|
+
server.once("error", reject);
|
|
1128
|
+
server.listen(options.port, options.host, resolve);
|
|
1129
|
+
});
|
|
1130
|
+
return {
|
|
1131
|
+
store,
|
|
1132
|
+
port: server.address().port,
|
|
1133
|
+
close: async () => {
|
|
1134
|
+
await vite?.close();
|
|
1135
|
+
await new Promise((resolve, reject) => {
|
|
1136
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
async function handleApiRequest(request, response, store) {
|
|
1142
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
1143
|
+
if (request.method === "GET" && url.pathname === "/api/project") {
|
|
1144
|
+
sendJson(response, 200, await store.readProject());
|
|
1145
|
+
return true;
|
|
1146
|
+
}
|
|
1147
|
+
if (request.method === "GET" && url.pathname === "/api/mockup-bundles") {
|
|
1148
|
+
sendJson(response, 200, await store.listMockupCatalog());
|
|
1149
|
+
return true;
|
|
1150
|
+
}
|
|
1151
|
+
const mockupBundleImportFileMatch = url.pathname.match(/^\/api\/mockup-bundle-imports\/([^/]+)\/([^/]+)\/files\/(.+)$/);
|
|
1152
|
+
if (request.method === "PUT" && mockupBundleImportFileMatch) {
|
|
1153
|
+
const importId = decodeURIComponent(mockupBundleImportFileMatch[1]);
|
|
1154
|
+
const bundleId = decodeURIComponent(mockupBundleImportFileMatch[2]);
|
|
1155
|
+
const relativePath = mockupBundleImportFileMatch[3].split("/").map(decodeURIComponent).join("/");
|
|
1156
|
+
await store.writeMockupBundleImportFile(importId, bundleId, relativePath, await readBody(request));
|
|
1157
|
+
response.writeHead(204).end();
|
|
1158
|
+
return true;
|
|
1159
|
+
}
|
|
1160
|
+
const mockupBundleImportMatch = url.pathname.match(/^\/api\/mockup-bundle-imports\/([^/]+)\/([^/]+)$/);
|
|
1161
|
+
if (request.method === "PUT" && mockupBundleImportMatch) {
|
|
1162
|
+
const importId = decodeURIComponent(mockupBundleImportMatch[1]);
|
|
1163
|
+
const bundleId = decodeURIComponent(mockupBundleImportMatch[2]);
|
|
1164
|
+
await store.commitMockupBundleImport(importId, bundleId, await readJsonBody(request));
|
|
1165
|
+
sendJson(response, 200, await store.listMockupCatalog());
|
|
1166
|
+
return true;
|
|
1167
|
+
}
|
|
1168
|
+
const mockupBundleImportDiscardMatch = url.pathname.match(/^\/api\/mockup-bundle-imports\/([^/]+)$/);
|
|
1169
|
+
if (request.method === "DELETE" && mockupBundleImportDiscardMatch) {
|
|
1170
|
+
await store.discardMockupBundleImport(decodeURIComponent(mockupBundleImportDiscardMatch[1]));
|
|
1171
|
+
response.writeHead(204).end();
|
|
1172
|
+
return true;
|
|
1173
|
+
}
|
|
1174
|
+
const mockupBundleMatch = url.pathname.match(/^\/api\/mockup-bundles\/([^/]+)$/);
|
|
1175
|
+
if (request.method === "PUT" && mockupBundleMatch) {
|
|
1176
|
+
const bundleId = decodeURIComponent(mockupBundleMatch[1]);
|
|
1177
|
+
await store.writeMockupBundleManifest(bundleId, await readJsonBody(request));
|
|
1178
|
+
sendJson(response, 200, await store.listMockupCatalog());
|
|
1179
|
+
return true;
|
|
1180
|
+
}
|
|
1181
|
+
const mockupBundleFileMatch = url.pathname.match(/^\/api\/mockup-bundle-files\/([^/]+)\/(.+)$/);
|
|
1182
|
+
if (mockupBundleFileMatch) {
|
|
1183
|
+
const bundleId = decodeURIComponent(mockupBundleFileMatch[1]);
|
|
1184
|
+
const relativePath = mockupBundleFileMatch[2].split("/").map(decodeURIComponent).join("/");
|
|
1185
|
+
if (request.method === "PUT") {
|
|
1186
|
+
await store.writeMockupBundleFile(bundleId, relativePath, await readBody(request));
|
|
1187
|
+
response.writeHead(204).end();
|
|
1188
|
+
return true;
|
|
1189
|
+
}
|
|
1190
|
+
if (request.method === "GET") {
|
|
1191
|
+
const target = await store.resolveExistingMockupBundleFile(bundleId, relativePath);
|
|
1192
|
+
const metadata = await stat2(target);
|
|
1193
|
+
response.writeHead(200, {
|
|
1194
|
+
"Content-Type": contentTypeFor(target),
|
|
1195
|
+
"Content-Length": metadata.size,
|
|
1196
|
+
"Cache-Control": "no-store",
|
|
1197
|
+
"X-Content-Type-Options": "nosniff",
|
|
1198
|
+
...path2.extname(target).toLowerCase() === ".svg" ? { "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox" } : {}
|
|
1199
|
+
});
|
|
1200
|
+
createReadStream(target).pipe(response);
|
|
1201
|
+
return true;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (request.method === "PATCH" && url.pathname === "/api/config") {
|
|
1205
|
+
const config = await store.writeConfig(await readJsonBody(request));
|
|
1206
|
+
sendJson(response, 200, config);
|
|
1207
|
+
return true;
|
|
1208
|
+
}
|
|
1209
|
+
if (request.method === "POST" && url.pathname === "/api/assets") {
|
|
1210
|
+
const category = url.searchParams.get("category");
|
|
1211
|
+
const filename = url.searchParams.get("filename");
|
|
1212
|
+
if (!category || !filename) {
|
|
1213
|
+
sendJson(response, 400, { error: "A category and filename are required" });
|
|
1214
|
+
return true;
|
|
1215
|
+
}
|
|
1216
|
+
let replaced = false;
|
|
1217
|
+
try {
|
|
1218
|
+
const result = await store.addAsset(category, filename, await readBody(request));
|
|
1219
|
+
replaced = result.replaced;
|
|
1220
|
+
} catch (error) {
|
|
1221
|
+
if (error instanceof DuplicateAssetError) {
|
|
1222
|
+
sendJson(response, 409, { error: error.message, code: "DUPLICATE_ASSET" });
|
|
1223
|
+
return true;
|
|
1224
|
+
}
|
|
1225
|
+
throw error;
|
|
1226
|
+
}
|
|
1227
|
+
sendJson(response, replaced ? 200 : 201, { replaced });
|
|
1228
|
+
return true;
|
|
1229
|
+
}
|
|
1230
|
+
const assetMatch = url.pathname.match(/^\/api\/assets\/([^/]+)\/([^/]+)$/);
|
|
1231
|
+
if (assetMatch) {
|
|
1232
|
+
const category = decodeURIComponent(assetMatch[1]);
|
|
1233
|
+
const filename = decodeURIComponent(assetMatch[2]);
|
|
1234
|
+
if (request.method === "GET") {
|
|
1235
|
+
const target = await store.resolveExistingAsset(category, filename);
|
|
1236
|
+
const metadata = await stat2(target);
|
|
1237
|
+
response.writeHead(200, {
|
|
1238
|
+
"Content-Type": contentTypeFor(target),
|
|
1239
|
+
"Content-Length": metadata.size,
|
|
1240
|
+
"Cache-Control": "no-store",
|
|
1241
|
+
"X-Content-Type-Options": "nosniff",
|
|
1242
|
+
...path2.extname(target).toLowerCase() === ".svg" ? { "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox" } : {}
|
|
1243
|
+
});
|
|
1244
|
+
createReadStream(target).pipe(response);
|
|
1245
|
+
return true;
|
|
1246
|
+
}
|
|
1247
|
+
if (request.method === "DELETE") {
|
|
1248
|
+
await store.deleteAsset(category, filename);
|
|
1249
|
+
response.writeHead(204).end();
|
|
1250
|
+
return true;
|
|
1251
|
+
}
|
|
1252
|
+
if (request.method === "PATCH") {
|
|
1253
|
+
await store.updateAssetMetadata(category, filename, await readJsonBody(request));
|
|
1254
|
+
sendJson(response, 200, await store.readProject());
|
|
1255
|
+
return true;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (request.method === "POST" && url.pathname === "/api/sets") {
|
|
1259
|
+
const set = await store.createSet(await readJsonBody(request));
|
|
1260
|
+
sendJson(response, 201, set);
|
|
1261
|
+
return true;
|
|
1262
|
+
}
|
|
1263
|
+
const duplicateSetMatch = url.pathname.match(/^\/api\/sets\/([^/]+)\/duplicate$/);
|
|
1264
|
+
if (request.method === "POST" && duplicateSetMatch) {
|
|
1265
|
+
const set = await store.duplicateSet(decodeURIComponent(duplicateSetMatch[1]));
|
|
1266
|
+
sendJson(response, 201, set);
|
|
1267
|
+
return true;
|
|
1268
|
+
}
|
|
1269
|
+
const setMatch = url.pathname.match(/^\/api\/sets\/([^/]+)$/);
|
|
1270
|
+
if (setMatch) {
|
|
1271
|
+
const id = decodeURIComponent(setMatch[1]);
|
|
1272
|
+
if (request.method === "PATCH") {
|
|
1273
|
+
const set = await store.updateSetMetadata(id, await readJsonBody(request));
|
|
1274
|
+
sendJson(response, 200, set);
|
|
1275
|
+
return true;
|
|
1276
|
+
}
|
|
1277
|
+
if (request.method === "PUT") {
|
|
1278
|
+
const set = await store.writeSet(id, await readJsonBody(request));
|
|
1279
|
+
sendJson(response, 200, set);
|
|
1280
|
+
return true;
|
|
1281
|
+
}
|
|
1282
|
+
if (request.method === "DELETE") {
|
|
1283
|
+
await store.deleteSet(id);
|
|
1284
|
+
response.writeHead(204).end();
|
|
1285
|
+
return true;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
if (url.pathname.startsWith("/api/")) {
|
|
1289
|
+
sendJson(response, 404, { error: "Not found" });
|
|
1290
|
+
return true;
|
|
1291
|
+
}
|
|
1292
|
+
return false;
|
|
1293
|
+
}
|
|
1294
|
+
async function serveBuiltFrontend(request, response, uiDirectory) {
|
|
1295
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
1296
|
+
const relativePath = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1));
|
|
1297
|
+
const candidate = path2.resolve(uiDirectory, relativePath);
|
|
1298
|
+
const safeRoot = `${path2.resolve(uiDirectory)}${path2.sep}`;
|
|
1299
|
+
const target = candidate.startsWith(safeRoot) ? candidate : path2.join(uiDirectory, "index.html");
|
|
1300
|
+
try {
|
|
1301
|
+
const metadata = await stat2(target);
|
|
1302
|
+
if (!metadata.isFile()) throw new Error("Not a file");
|
|
1303
|
+
const body = await readFile2(target);
|
|
1304
|
+
response.writeHead(200, { "Content-Type": contentTypeFor(target), "Content-Length": body.length });
|
|
1305
|
+
response.end(body);
|
|
1306
|
+
} catch {
|
|
1307
|
+
const body = await readFile2(path2.join(uiDirectory, "index.html"));
|
|
1308
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1309
|
+
response.end(body);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
async function readJsonBody(request) {
|
|
1313
|
+
const contentType = request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase();
|
|
1314
|
+
if (contentType !== "application/json") throw new HttpError(415, "Request body must use application/json");
|
|
1315
|
+
const body = await readBody(request);
|
|
1316
|
+
try {
|
|
1317
|
+
return JSON.parse(body.toString("utf8"));
|
|
1318
|
+
} catch {
|
|
1319
|
+
throw new HttpError(400, "Request body must be valid JSON");
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
async function readBody(request) {
|
|
1323
|
+
const chunks = [];
|
|
1324
|
+
let size = 0;
|
|
1325
|
+
for await (const chunk of request) {
|
|
1326
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
1327
|
+
size += buffer.length;
|
|
1328
|
+
if (size > MAX_REQUEST_BYTES) throw new HttpError(413, "Request is larger than 25 MB");
|
|
1329
|
+
chunks.push(buffer);
|
|
1330
|
+
}
|
|
1331
|
+
return Buffer.concat(chunks);
|
|
1332
|
+
}
|
|
1333
|
+
var HttpError = class extends Error {
|
|
1334
|
+
constructor(status, message, code) {
|
|
1335
|
+
super(message);
|
|
1336
|
+
this.status = status;
|
|
1337
|
+
this.code = code;
|
|
1338
|
+
}
|
|
1339
|
+
status;
|
|
1340
|
+
code;
|
|
1341
|
+
};
|
|
1342
|
+
function assertAllowedBrowserRequest(request, configuredHost) {
|
|
1343
|
+
const host = request.headers.host;
|
|
1344
|
+
if (!host) throw new HttpError(400, "A Host header is required");
|
|
1345
|
+
let hostUrl;
|
|
1346
|
+
try {
|
|
1347
|
+
hostUrl = new URL(`http://${host}`);
|
|
1348
|
+
} catch {
|
|
1349
|
+
throw new HttpError(400, "The Host header is invalid");
|
|
1350
|
+
}
|
|
1351
|
+
const configuredHostname = normalizedHostname(configuredHost);
|
|
1352
|
+
if (!isLoopbackHostname(hostUrl.hostname) && hostUrl.hostname !== configuredHostname) {
|
|
1353
|
+
throw new HttpError(403, "This StoreShot server does not accept requests for that host");
|
|
1354
|
+
}
|
|
1355
|
+
const origin = request.headers.origin;
|
|
1356
|
+
if (!origin) return;
|
|
1357
|
+
let originUrl;
|
|
1358
|
+
try {
|
|
1359
|
+
originUrl = new URL(origin);
|
|
1360
|
+
} catch {
|
|
1361
|
+
throw new HttpError(403, "The request origin is invalid");
|
|
1362
|
+
}
|
|
1363
|
+
if (originUrl.protocol !== "http:" && originUrl.protocol !== "https:" || originUrl.host !== host) {
|
|
1364
|
+
throw new HttpError(403, "Cross-origin API requests are not allowed");
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
function normalizedHostname(host) {
|
|
1368
|
+
try {
|
|
1369
|
+
return new URL(`http://${host}`).hostname;
|
|
1370
|
+
} catch {
|
|
1371
|
+
return host.replace(/^\[|\]$/g, "");
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
function isLoopbackHostname(hostname) {
|
|
1375
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
|
1376
|
+
}
|
|
1377
|
+
function httpFailureFor(error, request) {
|
|
1378
|
+
if (error instanceof HttpError) return error;
|
|
1379
|
+
if (error instanceof DuplicateAssetError) return new HttpError(409, error.message, "DUPLICATE_ASSET");
|
|
1380
|
+
const code = error?.code;
|
|
1381
|
+
if (code === "ENOENT") return new HttpError(404, "The requested project file was not found");
|
|
1382
|
+
if (code === "EACCES" || code === "EPERM") return new HttpError(403, "StoreShot cannot access that project file");
|
|
1383
|
+
if (isMutation(request) && error instanceof Error && !code) return new HttpError(400, error.message);
|
|
1384
|
+
return new HttpError(500, "StoreShot could not complete the request");
|
|
1385
|
+
}
|
|
1386
|
+
function isMutation(request) {
|
|
1387
|
+
return request.method === "POST" || request.method === "PUT" || request.method === "PATCH" || request.method === "DELETE";
|
|
1388
|
+
}
|
|
1389
|
+
function sendJson(response, status, value) {
|
|
1390
|
+
const body = JSON.stringify(value);
|
|
1391
|
+
response.writeHead(status, {
|
|
1392
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1393
|
+
"Content-Length": Buffer.byteLength(body)
|
|
1394
|
+
});
|
|
1395
|
+
response.end(body);
|
|
1396
|
+
}
|
|
1397
|
+
function contentTypeFor(filename) {
|
|
1398
|
+
switch (path2.extname(filename).toLowerCase()) {
|
|
1399
|
+
case ".html":
|
|
1400
|
+
return "text/html; charset=utf-8";
|
|
1401
|
+
case ".js":
|
|
1402
|
+
return "text/javascript; charset=utf-8";
|
|
1403
|
+
case ".css":
|
|
1404
|
+
return "text/css; charset=utf-8";
|
|
1405
|
+
case ".png":
|
|
1406
|
+
return "image/png";
|
|
1407
|
+
case ".jpg":
|
|
1408
|
+
case ".jpeg":
|
|
1409
|
+
return "image/jpeg";
|
|
1410
|
+
case ".webp":
|
|
1411
|
+
return "image/webp";
|
|
1412
|
+
case ".svg":
|
|
1413
|
+
return "image/svg+xml";
|
|
1414
|
+
case ".md":
|
|
1415
|
+
case ".txt":
|
|
1416
|
+
return "text/plain; charset=utf-8";
|
|
1417
|
+
default:
|
|
1418
|
+
return "application/octet-stream";
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// src/cli.ts
|
|
1423
|
+
var sourceFile = fileURLToPath(import.meta.url);
|
|
1424
|
+
var packageRoot = path3.resolve(path3.dirname(sourceFile), "..");
|
|
1425
|
+
var isSourceExecution = path3.extname(sourceFile) === ".ts";
|
|
1426
|
+
var program = new Command().name("storeshot").description("Manage app store screenshots locally").version("0.1.0");
|
|
1427
|
+
program.command("dev").description("Open a 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) => {
|
|
1428
|
+
const port = Number(options.port);
|
|
1429
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
1430
|
+
throw new Error("Port must be an integer between 1 and 65535");
|
|
1431
|
+
}
|
|
1432
|
+
const projectDirectory = path3.resolve(directory);
|
|
1433
|
+
const service = await startServer({
|
|
1434
|
+
host: options.host,
|
|
1435
|
+
port,
|
|
1436
|
+
packageRoot,
|
|
1437
|
+
projectDirectory,
|
|
1438
|
+
useVite: isSourceExecution
|
|
1439
|
+
});
|
|
1440
|
+
const url = `http://${options.host}:${port}`;
|
|
1441
|
+
console.log(`
|
|
1442
|
+
StoreShot is ready at ${url}`);
|
|
1443
|
+
console.log(` Project: ${service.store.root}
|
|
1444
|
+
`);
|
|
1445
|
+
if (options.open) await open(url);
|
|
1446
|
+
const shutdown = async () => {
|
|
1447
|
+
await service.close();
|
|
1448
|
+
process.exit(0);
|
|
1449
|
+
};
|
|
1450
|
+
process.once("SIGINT", shutdown);
|
|
1451
|
+
process.once("SIGTERM", shutdown);
|
|
1452
|
+
});
|
|
1453
|
+
program.parseAsync().catch((error) => {
|
|
1454
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1455
|
+
console.error(`storeshot: ${message}`);
|
|
1456
|
+
process.exitCode = 1;
|
|
1457
|
+
});
|
|
1458
|
+
//# sourceMappingURL=cli.js.map
|