storemeta 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.
Files changed (92) hide show
  1. package/.env.release.example +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -0
  4. package/dist/auth/apple/credential-state.js +14 -0
  5. package/dist/auth/apple/jwt.js +56 -0
  6. package/dist/auth/apple/load-credentials.js +36 -0
  7. package/dist/auth/credential-state.js +6 -0
  8. package/dist/auth/google/credential-state.js +10 -0
  9. package/dist/auth/google/load-credentials.js +18 -0
  10. package/dist/auth/google/service-account-auth.js +25 -0
  11. package/dist/auth/redact.js +17 -0
  12. package/dist/cli/auth-check.js +46 -0
  13. package/dist/cli/config-doctor.js +40 -0
  14. package/dist/cli/context.js +23 -0
  15. package/dist/cli/errors.js +10 -0
  16. package/dist/cli/exit-code.js +6 -0
  17. package/dist/cli/init.js +60 -0
  18. package/dist/cli/locales-list.js +37 -0
  19. package/dist/cli/metadata-diff.js +57 -0
  20. package/dist/cli/metadata-pull.js +52 -0
  21. package/dist/cli/metadata-push.js +95 -0
  22. package/dist/cli/render.js +57 -0
  23. package/dist/cli/result-types.js +1 -0
  24. package/dist/cli/scaffold.js +76 -0
  25. package/dist/cli/screenshots-diff.js +65 -0
  26. package/dist/cli/screenshots-pull.js +72 -0
  27. package/dist/cli/screenshots-push.js +96 -0
  28. package/dist/cli/validate.js +74 -0
  29. package/dist/cli.js +222 -0
  30. package/dist/config/apps.js +21 -0
  31. package/dist/config/load-config.js +30 -0
  32. package/dist/config/schema.js +97 -0
  33. package/dist/config/select-app.js +10 -0
  34. package/dist/config/select-platforms.js +17 -0
  35. package/dist/config/single-app.js +16 -0
  36. package/dist/config/types.js +1 -0
  37. package/dist/config/validate-base-dirs.js +33 -0
  38. package/dist/config/validate-credential-env-names.js +32 -0
  39. package/dist/config/validate-platform-identifiers.js +24 -0
  40. package/dist/formats/load-metadata.js +38 -0
  41. package/dist/formats/metadata-types.js +1 -0
  42. package/dist/formats/screenshot-types.js +1 -0
  43. package/dist/formats/serialize-metadata.js +20 -0
  44. package/dist/locales/groups.js +12 -0
  45. package/dist/locales/map.js +15 -0
  46. package/dist/locales/normalize.js +22 -0
  47. package/dist/locales/types.js +1 -0
  48. package/dist/locales/validate-groups.js +14 -0
  49. package/dist/platforms/apple/client.js +109 -0
  50. package/dist/platforms/apple/metadata/pull.js +120 -0
  51. package/dist/platforms/apple/metadata/push.js +272 -0
  52. package/dist/platforms/apple/metadata/types.js +1 -0
  53. package/dist/platforms/apple/screenshots/pull.js +160 -0
  54. package/dist/platforms/apple/screenshots/push.js +418 -0
  55. package/dist/platforms/google/client.js +42 -0
  56. package/dist/platforms/google/edits.js +38 -0
  57. package/dist/platforms/google/metadata/pull.js +23 -0
  58. package/dist/platforms/google/metadata/push.js +73 -0
  59. package/dist/platforms/google/metadata/types.js +1 -0
  60. package/dist/platforms/google/screenshots/pull.js +129 -0
  61. package/dist/platforms/google/screenshots/push.js +252 -0
  62. package/dist/platforms/google/screenshots/types.js +7 -0
  63. package/dist/validation/metadata/apple.js +31 -0
  64. package/dist/validation/metadata/files.js +58 -0
  65. package/dist/validation/metadata/google.js +46 -0
  66. package/dist/validation/screenshots/extensions.js +17 -0
  67. package/dist/validation/screenshots/files.js +55 -0
  68. package/dist/validation/screenshots/folders.js +41 -0
  69. package/dist/validation/screenshots/order.js +25 -0
  70. package/dist/writers/ensure-directory.js +16 -0
  71. package/dist/writers/order-screenshots.js +19 -0
  72. package/dist/writers/resolve-screenshot-path.js +8 -0
  73. package/dist/writers/resolve-within-base-dir.js +17 -0
  74. package/dist/writers/write-metadata.js +17 -0
  75. package/docs/API_EDITABLE_METADATA_SURFACE.md +319 -0
  76. package/docs/AUTH_SETUP.md +67 -0
  77. package/docs/DOCUMENTATION.md +285 -0
  78. package/docs/PROJECT_PLAN.md +687 -0
  79. package/docs/RELEASE_VERIFICATION.md +70 -0
  80. package/docs/TODO.md +308 -0
  81. package/examples/README.md +8 -0
  82. package/examples/metadata/apple/en-US.yml +11 -0
  83. package/examples/metadata/apple/tr.yml +11 -0
  84. package/examples/metadata/google/en-US.yml +6 -0
  85. package/examples/metadata/google/tr.yml +6 -0
  86. package/examples/screenshots/apple/en-US/APP_IPHONE_65/1.png +1 -0
  87. package/examples/screenshots/apple/tr/APP_IPHONE_65/1.png +1 -0
  88. package/examples/screenshots/google/en-US/phoneScreenshots/1.png +1 -0
  89. package/examples/screenshots/google/tr/phoneScreenshots/1.png +1 -0
  90. package/examples/storemeta.yml +29 -0
  91. package/package.json +55 -0
  92. package/storemeta.release.example.yml +27 -0
@@ -0,0 +1,129 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { StoremetaError } from "../../../cli/errors.js";
3
+ import { listScreenshotGroups } from "../../../locales/groups.js";
4
+ import { normalizeLocaleCode } from "../../../locales/normalize.js";
5
+ import { ensureParentDirectory } from "../../../writers/ensure-directory.js";
6
+ import { resolveScreenshotFilePath } from "../../../writers/resolve-screenshot-path.js";
7
+ export async function listGoogleImagesForLocaleAndType(client, packageName, editId, locale, imageType) {
8
+ const response = await client.requestJson(`/applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/listings/${encodeURIComponent(locale)}/${encodeURIComponent(imageType)}`);
9
+ return {
10
+ locale: normalizeLocaleCode(locale),
11
+ imageType,
12
+ images: response.images ?? [],
13
+ };
14
+ }
15
+ export async function listGoogleImagesForLocalesAndTypes(client, packageName, editId, locales, imageTypes) {
16
+ return Promise.all(locales.flatMap((locale) => imageTypes.map((imageType) => listGoogleImagesForLocaleAndType(client, packageName, editId, locale, imageType))));
17
+ }
18
+ export function expandGoogleScreenshotPullLocales(locales, settings) {
19
+ const resolvedLocales = [];
20
+ const seenLocales = new Set();
21
+ for (const locale of locales.map(normalizeLocaleCode)) {
22
+ if (seenLocales.has(locale)) {
23
+ continue;
24
+ }
25
+ resolvedLocales.push(locale);
26
+ seenLocales.add(locale);
27
+ }
28
+ for (const group of listScreenshotGroups(settings)) {
29
+ const groupMatches = group.locales.some((locale) => seenLocales.has(locale));
30
+ if (!groupMatches) {
31
+ continue;
32
+ }
33
+ for (const locale of group.locales) {
34
+ if (seenLocales.has(locale)) {
35
+ continue;
36
+ }
37
+ resolvedLocales.push(locale);
38
+ seenLocales.add(locale);
39
+ }
40
+ }
41
+ return resolvedLocales;
42
+ }
43
+ function normalizeGoogleImageExtension(extension) {
44
+ return extension.replace(/^\./u, "").toLowerCase();
45
+ }
46
+ function inferGoogleImageExtension(imageUrl, contentType) {
47
+ if (contentType !== null) {
48
+ if (contentType.includes("png")) {
49
+ return "png";
50
+ }
51
+ if (contentType.includes("jpeg") || contentType.includes("jpg")) {
52
+ return "jpg";
53
+ }
54
+ if (contentType.includes("webp")) {
55
+ return "webp";
56
+ }
57
+ }
58
+ try {
59
+ const parsedUrl = new URL(imageUrl);
60
+ const match = parsedUrl.pathname.match(/\.([A-Za-z0-9]+)$/u);
61
+ if (match?.[1] !== undefined) {
62
+ return normalizeGoogleImageExtension(match[1]);
63
+ }
64
+ }
65
+ catch {
66
+ // Fall back to png when the remote URL is not parseable.
67
+ }
68
+ return "png";
69
+ }
70
+ function createGoogleDownloadedFileName(position, extension) {
71
+ return `${position}.${normalizeGoogleImageExtension(extension)}`;
72
+ }
73
+ export async function downloadGoogleImage(image) {
74
+ if (image.url === undefined || image.url.trim().length === 0) {
75
+ throw new StoremetaError("API_ERROR", "Google Play returned an image without a download URL");
76
+ }
77
+ const response = await fetch(image.url);
78
+ if (!response.ok) {
79
+ throw new StoremetaError("API_ERROR", `Failed to download a Google Play screenshot with ${response.status} ${response.statusText}`);
80
+ }
81
+ return {
82
+ buffer: new Uint8Array(await response.arrayBuffer()),
83
+ extension: inferGoogleImageExtension(image.url, response.headers.get("content-type")),
84
+ };
85
+ }
86
+ export async function downloadGoogleScreenshotSet(screenshotsBaseDir, screenshotSet) {
87
+ const normalizedLocale = normalizeLocaleCode(screenshotSet.locale);
88
+ if (screenshotSet.images.length === 0) {
89
+ return {
90
+ platform: "google",
91
+ locale: normalizedLocale,
92
+ assetType: screenshotSet.imageType,
93
+ files: [],
94
+ };
95
+ }
96
+ const files = [];
97
+ for (const [index, image] of screenshotSet.images.entries()) {
98
+ const position = index + 1;
99
+ const downloadedImage = await downloadGoogleImage(image);
100
+ const fileName = createGoogleDownloadedFileName(position, downloadedImage.extension);
101
+ const filePath = resolveScreenshotFilePath(screenshotsBaseDir, {
102
+ platform: "google",
103
+ locale: screenshotSet.locale,
104
+ assetType: screenshotSet.imageType,
105
+ fileName,
106
+ });
107
+ await ensureParentDirectory(filePath);
108
+ try {
109
+ await writeFile(filePath, downloadedImage.buffer);
110
+ }
111
+ catch (cause) {
112
+ throw new StoremetaError("FILESYSTEM_ERROR", `Failed to write Google Play screenshot ${screenshotSet.locale}/${screenshotSet.imageType}/${fileName}`, { cause });
113
+ }
114
+ files.push({
115
+ platform: "google",
116
+ locale: normalizedLocale,
117
+ assetType: screenshotSet.imageType,
118
+ filePath,
119
+ fileName,
120
+ position,
121
+ });
122
+ }
123
+ return {
124
+ platform: "google",
125
+ locale: normalizedLocale,
126
+ assetType: screenshotSet.imageType,
127
+ files,
128
+ };
129
+ }
@@ -0,0 +1,252 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { extname, join, resolve } from "node:path";
3
+ import { StoremetaError } from "../../../cli/errors.js";
4
+ import { listScreenshotGroups } from "../../../locales/groups.js";
5
+ import { mapLocaleCode, mapLocaleCodes } from "../../../locales/map.js";
6
+ import { normalizeLocaleCode } from "../../../locales/normalize.js";
7
+ import { GOOGLE_SCREENSHOT_IMAGE_TYPES, } from "./types.js";
8
+ const SUPPORTED_SCREENSHOT_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
9
+ const GOOGLE_SCREENSHOT_IMAGE_TYPE_SET = new Set(GOOGLE_SCREENSHOT_IMAGE_TYPES);
10
+ async function listDirectoryEntries(directoryPath) {
11
+ try {
12
+ return await readdir(directoryPath, { withFileTypes: true });
13
+ }
14
+ catch (cause) {
15
+ if (typeof cause === "object" &&
16
+ cause !== null &&
17
+ "code" in cause &&
18
+ cause.code === "ENOENT") {
19
+ return [];
20
+ }
21
+ throw new StoremetaError("FILESYSTEM_ERROR", `Failed to read Google screenshot directory at ${directoryPath}`, { cause });
22
+ }
23
+ }
24
+ function validateGoogleScreenshotAssetType(assetType, directoryPath) {
25
+ if (GOOGLE_SCREENSHOT_IMAGE_TYPE_SET.has(assetType)) {
26
+ return assetType;
27
+ }
28
+ throw new StoremetaError("VALIDATION_ERROR", `${directoryPath}: unsupported Google screenshot image type`);
29
+ }
30
+ function sortScreenshotFileNames(fileNames) {
31
+ return [...fileNames].sort((left, right) => left.localeCompare(right, undefined, {
32
+ numeric: true,
33
+ }));
34
+ }
35
+ function validateGoogleScreenshotFileNames(assetTypeDirectory, fileNames) {
36
+ for (const [index, fileName] of sortScreenshotFileNames(fileNames).entries()) {
37
+ const extension = extname(fileName).toLowerCase();
38
+ if (!SUPPORTED_SCREENSHOT_EXTENSIONS.has(extension)) {
39
+ throw new StoremetaError("VALIDATION_ERROR", `${join(assetTypeDirectory, fileName)}: unsupported screenshot file extension`);
40
+ }
41
+ const numericName = fileName.slice(0, Math.max(0, fileName.length - extension.length));
42
+ if (!/^\d+$/u.test(numericName)) {
43
+ throw new StoremetaError("VALIDATION_ERROR", `${join(assetTypeDirectory, fileName)}: expected a numeric filename`);
44
+ }
45
+ if (Number(numericName) !== index + 1) {
46
+ throw new StoremetaError("VALIDATION_ERROR", `${join(assetTypeDirectory, fileName)}: expected screenshot order ${index + 1}`);
47
+ }
48
+ }
49
+ }
50
+ function createGoogleScreenshotDescriptors(screenshotsBaseDir, localeDirectoryName, locale, imageType, fileNames) {
51
+ return sortScreenshotFileNames(fileNames).map((fileName, index) => ({
52
+ platform: "google",
53
+ locale,
54
+ assetType: imageType,
55
+ filePath: resolve(screenshotsBaseDir, "google", localeDirectoryName, imageType, fileName),
56
+ fileName,
57
+ position: index + 1,
58
+ }));
59
+ }
60
+ function resolveGoogleScreenshotGroupLocales(sourceLocale, localeSettings, screenshotSettings) {
61
+ const mappedSourceLocale = mapLocaleCode(sourceLocale, localeSettings);
62
+ const matchingGroups = listScreenshotGroups(screenshotSettings).filter((group) => {
63
+ const mappedGroupLocales = mapLocaleCodes(group.locales, localeSettings);
64
+ return (group.locales.includes(sourceLocale) ||
65
+ group.locales.includes(mappedSourceLocale) ||
66
+ mappedGroupLocales.includes(mappedSourceLocale));
67
+ });
68
+ if (matchingGroups.length > 1) {
69
+ throw new StoremetaError("VALIDATION_ERROR", `Google screenshot locale ${sourceLocale} matches multiple screenshot groups`);
70
+ }
71
+ if (matchingGroups.length === 0) {
72
+ return [mappedSourceLocale];
73
+ }
74
+ const mappedGroupLocales = mapLocaleCodes(matchingGroups[0].locales, localeSettings);
75
+ const uniqueLocales = new Set(mappedGroupLocales);
76
+ if (uniqueLocales.size !== mappedGroupLocales.length) {
77
+ throw new StoremetaError("VALIDATION_ERROR", `Google screenshot group ${matchingGroups[0].name} resolves to duplicate target locales`);
78
+ }
79
+ return mappedGroupLocales;
80
+ }
81
+ export async function loadGoogleScreenshotSets(screenshotsBaseDir) {
82
+ const googleBaseDir = resolve(screenshotsBaseDir, "google");
83
+ const localeEntries = await listDirectoryEntries(googleBaseDir);
84
+ const screenshotSets = [];
85
+ for (const localeEntry of localeEntries) {
86
+ if (!localeEntry.isDirectory()) {
87
+ throw new StoremetaError("VALIDATION_ERROR", `${join(googleBaseDir, localeEntry.name)}: expected a locale directory`);
88
+ }
89
+ const locale = normalizeLocaleCode(localeEntry.name);
90
+ const localeDirectory = join(googleBaseDir, localeEntry.name);
91
+ const imageTypeEntries = await listDirectoryEntries(localeDirectory);
92
+ for (const imageTypeEntry of imageTypeEntries) {
93
+ if (!imageTypeEntry.isDirectory()) {
94
+ throw new StoremetaError("VALIDATION_ERROR", `${join(localeDirectory, imageTypeEntry.name)}: expected an image type directory`);
95
+ }
96
+ const imageTypeDirectory = join(localeDirectory, imageTypeEntry.name);
97
+ const imageType = validateGoogleScreenshotAssetType(imageTypeEntry.name, imageTypeDirectory);
98
+ const fileEntries = await listDirectoryEntries(imageTypeDirectory);
99
+ const fileNames = fileEntries
100
+ .filter((entry) => entry.isFile())
101
+ .map((entry) => entry.name);
102
+ validateGoogleScreenshotFileNames(imageTypeDirectory, fileNames);
103
+ screenshotSets.push({
104
+ platform: "google",
105
+ locale,
106
+ assetType: imageType,
107
+ files: createGoogleScreenshotDescriptors(screenshotsBaseDir, localeEntry.name, locale, imageType, fileNames),
108
+ });
109
+ }
110
+ }
111
+ return screenshotSets.sort((left, right) => {
112
+ const localeOrder = left.locale.localeCompare(right.locale);
113
+ if (localeOrder !== 0) {
114
+ return localeOrder;
115
+ }
116
+ return left.assetType.localeCompare(right.assetType);
117
+ });
118
+ }
119
+ export function mapGoogleScreenshotSetsToTargets(screenshotSets, settings) {
120
+ const uploadTargets = [];
121
+ const seenTargets = new Map();
122
+ for (const screenshotSet of screenshotSets) {
123
+ const imageType = validateGoogleScreenshotAssetType(screenshotSet.assetType, screenshotSet.assetType);
124
+ const sourceLocale = normalizeLocaleCode(screenshotSet.locale);
125
+ const targetLocales = resolveGoogleScreenshotGroupLocales(sourceLocale, settings.locales, settings.screenshots);
126
+ for (const targetLocale of targetLocales) {
127
+ const targetKey = `${targetLocale}:${imageType}`;
128
+ const existingSourceLocale = seenTargets.get(targetKey);
129
+ if (existingSourceLocale !== undefined && existingSourceLocale !== sourceLocale) {
130
+ throw new StoremetaError("VALIDATION_ERROR", `Google screenshot target ${targetLocale}/${imageType} is defined by both ${existingSourceLocale} and ${sourceLocale}`);
131
+ }
132
+ seenTargets.set(targetKey, sourceLocale);
133
+ uploadTargets.push({
134
+ platform: "google",
135
+ locale: targetLocale,
136
+ targetLocale,
137
+ sourceLocale,
138
+ assetType: imageType,
139
+ imageType,
140
+ files: screenshotSet.files.map((file) => ({
141
+ ...file,
142
+ locale: targetLocale,
143
+ assetType: imageType,
144
+ })),
145
+ });
146
+ }
147
+ }
148
+ return uploadTargets.sort((left, right) => {
149
+ const localeOrder = left.targetLocale.localeCompare(right.targetLocale);
150
+ if (localeOrder !== 0) {
151
+ return localeOrder;
152
+ }
153
+ const imageTypeOrder = left.imageType.localeCompare(right.imageType);
154
+ if (imageTypeOrder !== 0) {
155
+ return imageTypeOrder;
156
+ }
157
+ return left.sourceLocale.localeCompare(right.sourceLocale);
158
+ });
159
+ }
160
+ function getGoogleScreenshotContentType(filePath) {
161
+ const extension = extname(filePath).toLowerCase();
162
+ if (extension === ".png") {
163
+ return "image/png";
164
+ }
165
+ if (extension === ".jpg" || extension === ".jpeg") {
166
+ return "image/jpeg";
167
+ }
168
+ throw new StoremetaError("VALIDATION_ERROR", `${filePath}: unsupported screenshot file extension`);
169
+ }
170
+ export async function clearGoogleScreenshotTargets(client, packageName, editId, uploadTargets, options) {
171
+ if (options?.clearExisting !== true) {
172
+ return [];
173
+ }
174
+ const clearResults = [];
175
+ const clearedTargets = new Set();
176
+ for (const uploadTarget of uploadTargets) {
177
+ const targetKey = `${uploadTarget.targetLocale}:${uploadTarget.imageType}`;
178
+ if (clearedTargets.has(targetKey)) {
179
+ continue;
180
+ }
181
+ const response = await client.requestJson(`/applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/listings/${encodeURIComponent(uploadTarget.targetLocale)}/${encodeURIComponent(uploadTarget.imageType)}`, {
182
+ method: "DELETE",
183
+ });
184
+ clearResults.push({
185
+ targetLocale: uploadTarget.targetLocale,
186
+ imageType: uploadTarget.imageType,
187
+ deleted: response.deleted ?? [],
188
+ });
189
+ clearedTargets.add(targetKey);
190
+ }
191
+ return clearResults.sort((left, right) => {
192
+ const localeOrder = left.targetLocale.localeCompare(right.targetLocale);
193
+ if (localeOrder !== 0) {
194
+ return localeOrder;
195
+ }
196
+ return left.imageType.localeCompare(right.imageType);
197
+ });
198
+ }
199
+ export async function uploadGoogleScreenshotTarget(client, packageName, editId, uploadTarget) {
200
+ const uploadResults = [];
201
+ for (const file of uploadTarget.files) {
202
+ const contentType = getGoogleScreenshotContentType(file.filePath);
203
+ const fileContents = await readFile(file.filePath);
204
+ const response = await client.requestJson(`https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${encodeURIComponent(packageName)}/edits/${encodeURIComponent(editId)}/listings/${encodeURIComponent(uploadTarget.targetLocale)}/${encodeURIComponent(uploadTarget.imageType)}?uploadType=media`, {
205
+ method: "POST",
206
+ headers: {
207
+ "Content-Type": contentType,
208
+ },
209
+ body: fileContents,
210
+ });
211
+ if (response.image === undefined) {
212
+ throw new StoremetaError("API_ERROR", `Google Play upload did not return image data for ${uploadTarget.targetLocale}/${uploadTarget.imageType}/${file.fileName}`);
213
+ }
214
+ uploadResults.push({
215
+ targetLocale: uploadTarget.targetLocale,
216
+ imageType: uploadTarget.imageType,
217
+ position: file.position,
218
+ filePath: file.filePath,
219
+ image: response.image,
220
+ });
221
+ }
222
+ return uploadResults;
223
+ }
224
+ export async function uploadGoogleScreenshotTargets(client, packageName, editId, uploadTargets, options) {
225
+ const uploadResults = [];
226
+ for (const uploadTarget of uploadTargets) {
227
+ if (options?.onTargetStarted !== undefined) {
228
+ await options.onTargetStarted(uploadTarget);
229
+ }
230
+ const targetResults = await uploadGoogleScreenshotTarget(client, packageName, editId, uploadTarget);
231
+ for (const result of targetResults) {
232
+ uploadResults.push(result);
233
+ if (options?.onUploaded !== undefined) {
234
+ await options.onUploaded(result);
235
+ }
236
+ }
237
+ if (options?.onTargetCompleted !== undefined) {
238
+ await options.onTargetCompleted(uploadTarget, targetResults);
239
+ }
240
+ }
241
+ return uploadResults.sort((left, right) => {
242
+ const localeOrder = left.targetLocale.localeCompare(right.targetLocale);
243
+ if (localeOrder !== 0) {
244
+ return localeOrder;
245
+ }
246
+ const imageTypeOrder = left.imageType.localeCompare(right.imageType);
247
+ if (imageTypeOrder !== 0) {
248
+ return imageTypeOrder;
249
+ }
250
+ return left.position - right.position;
251
+ });
252
+ }
@@ -0,0 +1,7 @@
1
+ export const GOOGLE_SCREENSHOT_IMAGE_TYPES = [
2
+ "phoneScreenshots",
3
+ "sevenInchScreenshots",
4
+ "tenInchScreenshots",
5
+ "tvScreenshots",
6
+ "wearScreenshots",
7
+ ];
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+ import { StoremetaError } from "../../cli/errors.js";
3
+ export const appleMetadataDocumentSchema = z
4
+ .object({
5
+ locale: z.string().min(1),
6
+ app_name: z.string().min(1).optional(),
7
+ subtitle: z.string().min(1).optional(),
8
+ keywords: z.string().min(1).optional(),
9
+ promotional_text: z.string().min(1).optional(),
10
+ description: z.string().min(1).optional(),
11
+ whats_new: z.string().min(1).optional(),
12
+ support_url: z.string().url().optional(),
13
+ marketing_url: z.string().url().optional(),
14
+ privacy_policy_url: z.string().url().optional(),
15
+ })
16
+ .strict();
17
+ function formatAppleMetadataIssues(error) {
18
+ return error.issues
19
+ .map((issue) => {
20
+ const path = issue.path.length > 0 ? issue.path.join(".") : "root";
21
+ return `${path}: ${issue.message}`;
22
+ })
23
+ .join("; ");
24
+ }
25
+ export function validateAppleMetadataDocument(document) {
26
+ const result = appleMetadataDocumentSchema.safeParse(document);
27
+ if (!result.success) {
28
+ throw new StoremetaError("VALIDATION_ERROR", `Apple metadata validation failed: ${formatAppleMetadataIssues(result.error)}`, { cause: result.error });
29
+ }
30
+ return result.data;
31
+ }
@@ -0,0 +1,58 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { dirname, extname, join, resolve } from "node:path";
3
+ import { StoremetaError } from "../../cli/errors.js";
4
+ import { loadMarkdownMetadataFile, loadYamlMetadataFile, loadYmlMetadataFile, } from "../../formats/load-metadata.js";
5
+ import { validateAppleMetadataDocument } from "./apple.js";
6
+ import { validateGoogleMetadataDocument, validateGoogleMetadataLengthConstraints, } from "./google.js";
7
+ async function listMetadataFiles(platformDir) {
8
+ let entries;
9
+ try {
10
+ entries = await readdir(platformDir, { withFileTypes: true });
11
+ }
12
+ catch (error) {
13
+ if (typeof error === "object" &&
14
+ error !== null &&
15
+ "code" in error &&
16
+ error.code === "ENOENT") {
17
+ return [];
18
+ }
19
+ throw new StoremetaError("FILESYSTEM_ERROR", `Failed to read metadata directory at ${platformDir}`, { cause: error });
20
+ }
21
+ return entries
22
+ .filter((entry) => entry.isFile())
23
+ .map((entry) => join(platformDir, entry.name))
24
+ .sort((left, right) => left.localeCompare(right));
25
+ }
26
+ async function loadMetadataDocument(filePath) {
27
+ const extension = extname(filePath).toLowerCase();
28
+ if (extension === ".yml") {
29
+ return (await loadYmlMetadataFile(filePath)).parsed;
30
+ }
31
+ if (extension === ".yaml") {
32
+ return (await loadYamlMetadataFile(filePath)).parsed;
33
+ }
34
+ if (extension === ".md") {
35
+ return (await loadMarkdownMetadataFile(filePath)).parsed;
36
+ }
37
+ return undefined;
38
+ }
39
+ export async function validateMetadataFiles(configPath, app, platforms) {
40
+ const projectRoot = dirname(resolve(configPath));
41
+ for (const platform of platforms) {
42
+ const platformDir = resolve(projectRoot, app.settings.metadata.baseDir, platform);
43
+ const files = await listMetadataFiles(platformDir);
44
+ for (const filePath of files) {
45
+ const parsedDocument = await loadMetadataDocument(filePath);
46
+ if (parsedDocument === undefined) {
47
+ continue;
48
+ }
49
+ if (platform === "apple") {
50
+ validateAppleMetadataDocument(parsedDocument);
51
+ }
52
+ if (platform === "google") {
53
+ const googleDocument = validateGoogleMetadataDocument(parsedDocument);
54
+ validateGoogleMetadataLengthConstraints(googleDocument);
55
+ }
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,46 @@
1
+ import { z } from "zod";
2
+ import { StoremetaError } from "../../cli/errors.js";
3
+ export const googleMetadataDocumentSchema = z
4
+ .object({
5
+ locale: z.string().min(1),
6
+ title: z.string().min(1).optional(),
7
+ short_description: z.string().min(1).optional(),
8
+ full_description: z.string().min(1).optional(),
9
+ video: z.string().url().optional(),
10
+ })
11
+ .strict();
12
+ function formatGoogleMetadataIssues(error) {
13
+ return error.issues
14
+ .map((issue) => {
15
+ const path = issue.path.length > 0 ? issue.path.join(".") : "root";
16
+ return `${path}: ${issue.message}`;
17
+ })
18
+ .join("; ");
19
+ }
20
+ export function validateGoogleMetadataDocument(document) {
21
+ const result = googleMetadataDocumentSchema.safeParse(document);
22
+ if (!result.success) {
23
+ throw new StoremetaError("VALIDATION_ERROR", `Google metadata validation failed: ${formatGoogleMetadataIssues(result.error)}`, { cause: result.error });
24
+ }
25
+ return result.data;
26
+ }
27
+ function getCharacterCount(value) {
28
+ return Array.from(value).length;
29
+ }
30
+ export function validateGoogleMetadataLengthConstraints(document) {
31
+ const issues = [];
32
+ if (document.title !== undefined && getCharacterCount(document.title) > 30) {
33
+ issues.push("title: must be 30 characters or fewer");
34
+ }
35
+ if (document.short_description !== undefined &&
36
+ getCharacterCount(document.short_description) > 80) {
37
+ issues.push("short_description: must be 80 characters or fewer");
38
+ }
39
+ if (document.full_description !== undefined &&
40
+ getCharacterCount(document.full_description) > 4000) {
41
+ issues.push("full_description: must be 4000 characters or fewer");
42
+ }
43
+ if (issues.length > 0) {
44
+ throw new StoremetaError("VALIDATION_ERROR", `Google metadata length validation failed: ${issues.join("; ")}`);
45
+ }
46
+ }
@@ -0,0 +1,17 @@
1
+ import { extname } from "node:path";
2
+ import { StoremetaError } from "../../cli/errors.js";
3
+ import { listScreenshotFiles } from "./files.js";
4
+ const SUPPORTED_SCREENSHOT_EXTENSIONS = new Set([".png", ".jpg", ".jpeg"]);
5
+ export async function validateScreenshotFileExtensions(configPath, app, platforms) {
6
+ const files = await listScreenshotFiles(configPath, app, platforms);
7
+ const issues = [];
8
+ for (const filePath of files) {
9
+ const extension = extname(filePath).toLowerCase();
10
+ if (!SUPPORTED_SCREENSHOT_EXTENSIONS.has(extension)) {
11
+ issues.push(`${filePath}: unsupported screenshot file extension`);
12
+ }
13
+ }
14
+ if (issues.length > 0) {
15
+ throw new StoremetaError("VALIDATION_ERROR", `Screenshot extension validation failed: ${issues.join("; ")}`);
16
+ }
17
+ }
@@ -0,0 +1,55 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { StoremetaError } from "../../cli/errors.js";
4
+ async function listDirectoryEntries(directoryPath) {
5
+ try {
6
+ return await readdir(directoryPath, { withFileTypes: true });
7
+ }
8
+ catch (error) {
9
+ if (typeof error === "object" &&
10
+ error !== null &&
11
+ "code" in error &&
12
+ error.code === "ENOENT") {
13
+ return [];
14
+ }
15
+ throw new StoremetaError("FILESYSTEM_ERROR", `Failed to read screenshot directory at ${directoryPath}`, { cause: error });
16
+ }
17
+ }
18
+ export async function listScreenshotFileGroups(configPath, app, platforms) {
19
+ const projectRoot = dirname(resolve(configPath));
20
+ const groups = [];
21
+ for (const platform of platforms) {
22
+ const platformDir = resolve(projectRoot, app.settings.screenshots.baseDir, platform);
23
+ const localeEntries = await listDirectoryEntries(platformDir);
24
+ for (const localeEntry of localeEntries) {
25
+ if (!localeEntry.isDirectory()) {
26
+ continue;
27
+ }
28
+ const localeDir = join(platformDir, localeEntry.name);
29
+ const assetTypeEntries = await listDirectoryEntries(localeDir);
30
+ for (const assetTypeEntry of assetTypeEntries) {
31
+ if (!assetTypeEntry.isDirectory()) {
32
+ continue;
33
+ }
34
+ const assetTypeDir = join(localeDir, assetTypeEntry.name);
35
+ const fileEntries = await listDirectoryEntries(assetTypeDir);
36
+ const files = fileEntries
37
+ .filter((fileEntry) => fileEntry.isFile())
38
+ .map((fileEntry) => join(assetTypeDir, fileEntry.name))
39
+ .sort((left, right) => left.localeCompare(right));
40
+ groups.push({
41
+ directory: assetTypeDir,
42
+ files,
43
+ });
44
+ }
45
+ }
46
+ }
47
+ return groups;
48
+ }
49
+ export async function listScreenshotFiles(configPath, app, platforms) {
50
+ const files = [];
51
+ for (const group of await listScreenshotFileGroups(configPath, app, platforms)) {
52
+ files.push(...group.files);
53
+ }
54
+ return files.sort((left, right) => left.localeCompare(right));
55
+ }
@@ -0,0 +1,41 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { StoremetaError } from "../../cli/errors.js";
4
+ async function listDirectoryEntries(directoryPath) {
5
+ try {
6
+ return await readdir(directoryPath, { withFileTypes: true });
7
+ }
8
+ catch (error) {
9
+ if (typeof error === "object" &&
10
+ error !== null &&
11
+ "code" in error &&
12
+ error.code === "ENOENT") {
13
+ return [];
14
+ }
15
+ throw new StoremetaError("FILESYSTEM_ERROR", `Failed to read screenshot directory at ${directoryPath}`, { cause: error });
16
+ }
17
+ }
18
+ export async function validateScreenshotFolderStructure(configPath, app, platforms) {
19
+ const projectRoot = dirname(resolve(configPath));
20
+ const issues = [];
21
+ for (const platform of platforms) {
22
+ const platformDir = resolve(projectRoot, app.settings.screenshots.baseDir, platform);
23
+ const localeEntries = await listDirectoryEntries(platformDir);
24
+ for (const localeEntry of localeEntries) {
25
+ if (!localeEntry.isDirectory()) {
26
+ issues.push(`${join(platformDir, localeEntry.name)}: expected a locale directory`);
27
+ continue;
28
+ }
29
+ const localeDir = join(platformDir, localeEntry.name);
30
+ const assetTypeEntries = await listDirectoryEntries(localeDir);
31
+ for (const assetTypeEntry of assetTypeEntries) {
32
+ if (!assetTypeEntry.isDirectory()) {
33
+ issues.push(`${join(localeDir, assetTypeEntry.name)}: expected an asset type directory`);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ if (issues.length > 0) {
39
+ throw new StoremetaError("VALIDATION_ERROR", `Screenshot folder validation failed: ${issues.join("; ")}`);
40
+ }
41
+ }
@@ -0,0 +1,25 @@
1
+ import { basename, extname } from "node:path";
2
+ import { StoremetaError } from "../../cli/errors.js";
3
+ import { listScreenshotFileGroups } from "./files.js";
4
+ export async function validateScreenshotFileOrdering(configPath, app, platforms) {
5
+ const groups = await listScreenshotFileGroups(configPath, app, platforms);
6
+ const issues = [];
7
+ for (const group of groups) {
8
+ const orderedFileNames = group.files
9
+ .map((filePath) => basename(filePath))
10
+ .sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
11
+ for (const [index, fileName] of orderedFileNames.entries()) {
12
+ const numericName = basename(fileName, extname(fileName));
13
+ if (!/^\d+$/.test(numericName)) {
14
+ issues.push(`${group.directory}/${fileName}: expected a numeric filename`);
15
+ continue;
16
+ }
17
+ if (Number(numericName) !== index + 1) {
18
+ issues.push(`${group.directory}/${fileName}: expected screenshot order ${index + 1}`);
19
+ }
20
+ }
21
+ }
22
+ if (issues.length > 0) {
23
+ throw new StoremetaError("VALIDATION_ERROR", `Screenshot ordering validation failed: ${issues.join("; ")}`);
24
+ }
25
+ }