@appeeky/spec 0.0.0-stage → 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/.github/assets/cover.gif +0 -0
- package/LICENSE +110 -0
- package/README.md +483 -2
- package/dist/ads.d.ts +39 -0
- package/dist/ads.js +67 -0
- package/dist/appeeky-api.d.ts +62 -0
- package/dist/appeeky-api.js +240 -0
- package/dist/attribution-setup.d.ts +28 -0
- package/dist/attribution-setup.js +65 -0
- package/dist/attribution.d.ts +121 -0
- package/dist/attribution.js +170 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +4 -0
- package/dist/category.d.ts +71 -0
- package/dist/category.js +198 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +1387 -0
- package/dist/competitors.d.ts +74 -0
- package/dist/competitors.js +143 -0
- package/dist/control.d.ts +21 -0
- package/dist/control.js +53 -0
- package/dist/core.d.ts +15 -0
- package/dist/core.js +15 -0
- package/dist/coverage.d.ts +46 -0
- package/dist/coverage.js +115 -0
- package/dist/data.d.ts +48 -0
- package/dist/data.js +298 -0
- package/dist/diff.d.ts +11 -0
- package/dist/diff.js +33 -0
- package/dist/experiments.d.ts +115 -0
- package/dist/experiments.js +85 -0
- package/dist/files.d.ts +27 -0
- package/dist/files.js +129 -0
- package/dist/history.d.ts +43 -0
- package/dist/history.js +55 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +24 -0
- package/dist/limits.d.ts +15 -0
- package/dist/limits.js +30 -0
- package/dist/lint.d.ts +16 -0
- package/dist/lint.js +159 -0
- package/dist/loops.d.ts +37 -0
- package/dist/loops.js +197 -0
- package/dist/measure.d.ts +40 -0
- package/dist/measure.js +159 -0
- package/dist/metrics.d.ts +270 -0
- package/dist/metrics.js +250 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +79 -0
- package/dist/project.d.ts +19 -0
- package/dist/project.js +35 -0
- package/dist/report.d.ts +5 -0
- package/dist/report.js +71 -0
- package/dist/reviews.d.ts +130 -0
- package/dist/reviews.js +287 -0
- package/dist/schema.d.ts +519 -0
- package/dist/schema.js +218 -0
- package/dist/screenshots.d.ts +23 -0
- package/dist/screenshots.js +116 -0
- package/dist/setup.d.ts +45 -0
- package/dist/setup.js +226 -0
- package/dist/storefronts.d.ts +15 -0
- package/dist/storefronts.js +89 -0
- package/dist/stores/app-store.d.ts +33 -0
- package/dist/stores/app-store.js +159 -0
- package/dist/stores/google-play.d.ts +13 -0
- package/dist/stores/google-play.js +61 -0
- package/dist/stores/types.d.ts +34 -0
- package/dist/stores/types.js +2 -0
- package/dist/suggest.d.ts +73 -0
- package/dist/suggest.js +135 -0
- package/dist/text.d.ts +18 -0
- package/dist/text.js +55 -0
- package/dist/transport.d.ts +98 -0
- package/dist/transport.js +170 -0
- package/dist/verify.d.ts +70 -0
- package/dist/verify.js +134 -0
- package/dist/visibility.d.ts +149 -0
- package/dist/visibility.js +239 -0
- package/package.json +76 -4
- package/skills/appeeky-spec/SKILL.md +146 -0
- package/skills/appeeky-spec-experiments/SKILL.md +83 -0
- package/skills/appeeky-spec-localize/SKILL.md +52 -0
- package/skills/appeeky-spec-review/SKILL.md +75 -0
- package/skills/appeeky-spec-targets/SKILL.md +66 -0
- package/skills/appeeky-spec-visibility/SKILL.md +79 -0
package/dist/schema.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Stores a spec can describe. The ids double as folder names under `appeeky/store/`. */
|
|
3
|
+
export const STORES = ["app-store", "google-play"];
|
|
4
|
+
/** How a store is reached: the local store CLI with your own key, or Appeeky's hosted gateway. */
|
|
5
|
+
export const transportMode = z.enum(["auto", "local", "hosted"]);
|
|
6
|
+
const countryCode = z
|
|
7
|
+
.string()
|
|
8
|
+
.regex(/^[a-z]{2}$/i, "Use a two-letter storefront code such as us, gb or tr")
|
|
9
|
+
.transform((s) => s.toLowerCase());
|
|
10
|
+
const appStoreConfig = z
|
|
11
|
+
.object({
|
|
12
|
+
/** Numeric App Store id (or a bundle id; the local CLI resolves it). */
|
|
13
|
+
appId: z.string().min(1),
|
|
14
|
+
platform: z.enum(["IOS", "MAC_OS", "TV_OS", "VISION_OS"]).default("IOS"),
|
|
15
|
+
via: transportMode.default("auto"),
|
|
16
|
+
/** Named account of the local `appstore` CLI. */
|
|
17
|
+
account: z.string().optional(),
|
|
18
|
+
})
|
|
19
|
+
.strict();
|
|
20
|
+
const googlePlayConfig = z
|
|
21
|
+
.object({
|
|
22
|
+
packageName: z.string().regex(/^[A-Za-z][\w]*(\.[A-Za-z_][\w]*)+$/, "Use the package name, e.g. com.example.app"),
|
|
23
|
+
via: transportMode.default("auto"),
|
|
24
|
+
/** Named account of the local `gps` CLI (`gps accounts add`). */
|
|
25
|
+
account: z.string().optional(),
|
|
26
|
+
/** Old name for `account`. */
|
|
27
|
+
profile: z.string().optional(),
|
|
28
|
+
})
|
|
29
|
+
.strict();
|
|
30
|
+
/** Meta ad campaigns that promote this app. Insights come through the Meta connection in Appeeky. */
|
|
31
|
+
const metaAdsConfig = z
|
|
32
|
+
.object({
|
|
33
|
+
/** Ad account (act_…); defaults to the one picked in Appeeky. */
|
|
34
|
+
adAccount: z.string().optional(),
|
|
35
|
+
campaigns: z.array(z.string().min(1)).min(1),
|
|
36
|
+
})
|
|
37
|
+
.strict();
|
|
38
|
+
/** TikTok ad campaigns that promote this app. Reports come through the TikTok connection in Appeeky. */
|
|
39
|
+
const tiktokAdsConfig = z
|
|
40
|
+
.object({
|
|
41
|
+
/** Advertiser id; defaults to the one picked in Appeeky. */
|
|
42
|
+
advertiser: z.string().optional(),
|
|
43
|
+
campaigns: z.array(z.string().min(1)).min(1),
|
|
44
|
+
})
|
|
45
|
+
.strict();
|
|
46
|
+
/** One thing people ask AI assistants for, and the prompts that ask it. */
|
|
47
|
+
const aiIntent = z
|
|
48
|
+
.object({
|
|
49
|
+
label: z.string().min(8, "An intent label needs at least 8 characters"),
|
|
50
|
+
description: z.string().optional(),
|
|
51
|
+
prompts: z.array(z.string().min(15, "A prompt needs at least 15 characters")).default([]),
|
|
52
|
+
})
|
|
53
|
+
.strict();
|
|
54
|
+
/** Store charts a category rank is read from. */
|
|
55
|
+
export const CHARTS = ["top-free", "top-paid", "top-grossing"];
|
|
56
|
+
/** `appeeky.spec.yaml`: who the app is, where it ships, and what it should be found for. */
|
|
57
|
+
export const specFileSchema = z
|
|
58
|
+
.object({
|
|
59
|
+
version: z.literal(1),
|
|
60
|
+
app: z
|
|
61
|
+
.object({
|
|
62
|
+
name: z.string().min(1),
|
|
63
|
+
/** Locale the store falls back to when a storefront has no localization of its own. */
|
|
64
|
+
primaryLocale: z.string().default("en-US"),
|
|
65
|
+
})
|
|
66
|
+
.strict(),
|
|
67
|
+
stores: z
|
|
68
|
+
.object({
|
|
69
|
+
appStore: appStoreConfig.optional(),
|
|
70
|
+
googlePlay: googlePlayConfig.optional(),
|
|
71
|
+
})
|
|
72
|
+
.strict()
|
|
73
|
+
.default({}),
|
|
74
|
+
/** Target keywords per storefront: the searches this app should be found for. */
|
|
75
|
+
targets: z.record(countryCode, z.array(z.string().min(1)).default([])).default({}),
|
|
76
|
+
/** Override which listing locales a storefront indexes (App Store). */
|
|
77
|
+
storefronts: z.record(countryCode, z.array(z.string().min(2)).min(1)).default({}),
|
|
78
|
+
/**
|
|
79
|
+
* Paid channels outside the store. Apple Search Ads needs nothing here: it is
|
|
80
|
+
* matched to the app in Appeeky. Meta and TikTok campaigns are listed by id.
|
|
81
|
+
*/
|
|
82
|
+
ads: z
|
|
83
|
+
.object({
|
|
84
|
+
meta: metaAdsConfig.optional(),
|
|
85
|
+
tiktok: tiktokAdsConfig.optional(),
|
|
86
|
+
})
|
|
87
|
+
.strict()
|
|
88
|
+
.default({}),
|
|
89
|
+
/** Apps you compete with. `competitors pull` writes each one to appeeky/competitors/. */
|
|
90
|
+
competitors: z
|
|
91
|
+
.array(z
|
|
92
|
+
.object({
|
|
93
|
+
name: z.string().optional(),
|
|
94
|
+
appStore: z.string().regex(/^\d+$/, "Use the numeric App Store id").optional(),
|
|
95
|
+
googlePlay: z.string().optional(),
|
|
96
|
+
})
|
|
97
|
+
.strict()
|
|
98
|
+
.refine((c) => c.appStore || c.googlePlay, "Give an appStore id or a googlePlay package name"))
|
|
99
|
+
.default([]),
|
|
100
|
+
/**
|
|
101
|
+
* Install attribution from Appeeky Analytics: organic search, Search Ads keywords,
|
|
102
|
+
* smart links, with the revenue each led to. `via: self` reads your own server on
|
|
103
|
+
* Cloudflare (`url`, ATTRIBUTION_TOKEN); `via: hosted` reads Appeeky's (APPEEKY_API_KEY).
|
|
104
|
+
*/
|
|
105
|
+
attribution: z
|
|
106
|
+
.object({
|
|
107
|
+
via: z.enum(["self", "hosted"]).default("hosted"),
|
|
108
|
+
url: z.string().url().optional(),
|
|
109
|
+
/**
|
|
110
|
+
* The app's id in Appeeky Analytics. An app there is one platform, so an app on both
|
|
111
|
+
* stores gives one per store: `{ app-store: …, google-play: … }`.
|
|
112
|
+
*/
|
|
113
|
+
app: z.union([
|
|
114
|
+
z.string().min(1),
|
|
115
|
+
z
|
|
116
|
+
.object({ "app-store": z.string().min(1).optional(), "google-play": z.string().min(1).optional() })
|
|
117
|
+
.strict()
|
|
118
|
+
.refine((m) => m["app-store"] || m["google-play"], "give the app id for app-store, google-play or both"),
|
|
119
|
+
]),
|
|
120
|
+
})
|
|
121
|
+
.strict()
|
|
122
|
+
.refine((a) => a.via !== "self" || a.url, "via: self needs the url of your Appeeky Analytics server")
|
|
123
|
+
.optional(),
|
|
124
|
+
/**
|
|
125
|
+
* Where `data pull` writes store analytics, ads and revenue. They are your private
|
|
126
|
+
* business numbers, so by default they stay in .appeeky/data/ (gitignored).
|
|
127
|
+
* `commit: true` writes them to appeeky/data/ so they are versioned with the spec.
|
|
128
|
+
*/
|
|
129
|
+
data: z.object({ commit: z.boolean().default(false) }).strict().default({}),
|
|
130
|
+
/**
|
|
131
|
+
* Whether ChatGPT, Claude, Gemini and Perplexity name the app when people ask
|
|
132
|
+
* for one like it. Intents are what people want ("fall asleep to rain sounds"),
|
|
133
|
+
* prompts are how they ask; both are tracked per storefront in Appeeky AI Visibility.
|
|
134
|
+
*/
|
|
135
|
+
aiVisibility: z
|
|
136
|
+
.object({
|
|
137
|
+
/** Which app to watch when it is on both stores; defaults to the App Store app. */
|
|
138
|
+
store: z.enum(STORES).optional(),
|
|
139
|
+
/** Storefronts to watch; defaults to the first storefront under targets. */
|
|
140
|
+
countries: z.array(countryCode).min(1).optional(),
|
|
141
|
+
/** What people ask AI assistants, per storefront. `visibility sync` sends them to Appeeky. */
|
|
142
|
+
intents: z.record(countryCode, z.array(aiIntent)).default({}),
|
|
143
|
+
})
|
|
144
|
+
.strict()
|
|
145
|
+
.optional(),
|
|
146
|
+
/** Rank in the app's category chart (top 100), recorded daily by `category pull`. */
|
|
147
|
+
category: z
|
|
148
|
+
.object({
|
|
149
|
+
/** App Store genre id, e.g. "6013" for Health & Fitness; read from the app when left out. */
|
|
150
|
+
appStore: z.string().regex(/^\d+$/, "Use the numeric App Store genre id, e.g. 6013").optional(),
|
|
151
|
+
/** Google Play category, e.g. HEALTH_AND_FITNESS. */
|
|
152
|
+
googlePlay: z.string().regex(/^[A-Z_]+$/, "Use the Google Play category id, e.g. HEALTH_AND_FITNESS").optional(),
|
|
153
|
+
/** Defaults to top-paid for a paid App Store app, top-free otherwise. */
|
|
154
|
+
chart: z.enum(CHARTS).optional(),
|
|
155
|
+
/** Storefronts to record; defaults to the storefronts under targets. */
|
|
156
|
+
countries: z.array(countryCode).min(1).optional(),
|
|
157
|
+
})
|
|
158
|
+
.strict()
|
|
159
|
+
.optional(),
|
|
160
|
+
/** Who the app is for. Experiments, screenshots and UGC refer to these by id. */
|
|
161
|
+
personas: z
|
|
162
|
+
.array(z
|
|
163
|
+
.object({
|
|
164
|
+
id: z.string().regex(/^[a-z0-9-]+$/),
|
|
165
|
+
name: z.string().min(1),
|
|
166
|
+
description: z.string().optional(),
|
|
167
|
+
/** How this persona searches: seeds for targets and keyword ideas. */
|
|
168
|
+
searches: z.array(z.string()).default([]),
|
|
169
|
+
})
|
|
170
|
+
.strict())
|
|
171
|
+
.default([]),
|
|
172
|
+
})
|
|
173
|
+
.strict();
|
|
174
|
+
/**
|
|
175
|
+
* One App Store locale: `appeeky/store/app-store/<locale>.yaml`.
|
|
176
|
+
* A field left out is not managed by the spec: it is never diffed or pushed.
|
|
177
|
+
*/
|
|
178
|
+
export const appStoreListingSchema = z
|
|
179
|
+
.object({
|
|
180
|
+
name: z.string().optional(),
|
|
181
|
+
subtitle: z.string().optional(),
|
|
182
|
+
keywords: z.string().optional(),
|
|
183
|
+
promotionalText: z.string().optional(),
|
|
184
|
+
description: z.string().optional(),
|
|
185
|
+
whatsNew: z.string().optional(),
|
|
186
|
+
marketingUrl: z.string().url().optional(),
|
|
187
|
+
supportUrl: z.string().url().optional(),
|
|
188
|
+
})
|
|
189
|
+
.strict();
|
|
190
|
+
/** One Google Play language: `appeeky/store/google-play/<language>.yaml`. */
|
|
191
|
+
export const googlePlayListingSchema = z
|
|
192
|
+
.object({
|
|
193
|
+
title: z.string().optional(),
|
|
194
|
+
shortDescription: z.string().optional(),
|
|
195
|
+
fullDescription: z.string().optional(),
|
|
196
|
+
video: z.string().url().optional(),
|
|
197
|
+
})
|
|
198
|
+
.strict();
|
|
199
|
+
export const APP_STORE_FIELDS = [
|
|
200
|
+
"name",
|
|
201
|
+
"subtitle",
|
|
202
|
+
"keywords",
|
|
203
|
+
"promotionalText",
|
|
204
|
+
"description",
|
|
205
|
+
"whatsNew",
|
|
206
|
+
"marketingUrl",
|
|
207
|
+
"supportUrl",
|
|
208
|
+
];
|
|
209
|
+
export const GOOGLE_PLAY_FIELDS = [
|
|
210
|
+
"title",
|
|
211
|
+
"shortDescription",
|
|
212
|
+
"fullDescription",
|
|
213
|
+
"video",
|
|
214
|
+
];
|
|
215
|
+
export function fieldsFor(store) {
|
|
216
|
+
return store === "app-store" ? APP_STORE_FIELDS : GOOGLE_PLAY_FIELDS;
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Issue } from "./lint.js";
|
|
2
|
+
import type { RemoteState } from "./stores/types.js";
|
|
3
|
+
import type { OpsTransport } from "./transport.js";
|
|
4
|
+
/**
|
|
5
|
+
* Screenshots live next to the listings:
|
|
6
|
+
* `appeeky/screenshots/app-store/<locale>/<DISPLAY_TYPE>/01.png`.
|
|
7
|
+
* `screenshots pull` downloads the live set; `check` validates sizes and counts.
|
|
8
|
+
*/
|
|
9
|
+
export declare function screenshotsDir(root: string): string;
|
|
10
|
+
/** Portrait sizes App Store Connect accepts per display type (landscape is the same, swapped). */
|
|
11
|
+
export declare const SCREENSHOT_SIZES: Record<string, Array<[number, number]>>;
|
|
12
|
+
/** Width and height from a PNG or JPEG header, without decoding the image. */
|
|
13
|
+
export declare function imageSize(file: string): Promise<{
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
} | null>;
|
|
17
|
+
/** Sizes and counts of every screenshot set in the spec. */
|
|
18
|
+
export declare function checkScreenshots(root: string): Promise<Issue[]>;
|
|
19
|
+
/** Download the live App Store screenshots of every locale into the spec. */
|
|
20
|
+
export declare function pullScreenshots(root: string, transport: OpsTransport, state: RemoteState, options?: {
|
|
21
|
+
locales?: string[];
|
|
22
|
+
fetcher?: typeof fetch;
|
|
23
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, open, readdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { SPEC_DIR } from "./files.js";
|
|
5
|
+
/**
|
|
6
|
+
* Screenshots live next to the listings:
|
|
7
|
+
* `appeeky/screenshots/app-store/<locale>/<DISPLAY_TYPE>/01.png`.
|
|
8
|
+
* `screenshots pull` downloads the live set; `check` validates sizes and counts.
|
|
9
|
+
*/
|
|
10
|
+
export function screenshotsDir(root) {
|
|
11
|
+
return join(root, SPEC_DIR, "screenshots", "app-store");
|
|
12
|
+
}
|
|
13
|
+
/** Portrait sizes App Store Connect accepts per display type (landscape is the same, swapped). */
|
|
14
|
+
export const SCREENSHOT_SIZES = {
|
|
15
|
+
APP_IPHONE_67: [[1290, 2796], [1320, 2868], [1260, 2736]],
|
|
16
|
+
APP_IPHONE_65: [[1242, 2688], [1284, 2778]],
|
|
17
|
+
APP_IPHONE_61: [[1179, 2556], [1206, 2622], [1170, 2532]],
|
|
18
|
+
APP_IPHONE_58: [[1125, 2436], [1080, 2340]],
|
|
19
|
+
APP_IPHONE_55: [[1242, 2208]],
|
|
20
|
+
APP_IPAD_PRO_3GEN_129: [[2048, 2732], [2064, 2752]],
|
|
21
|
+
APP_IPAD_PRO_129: [[2048, 2732]],
|
|
22
|
+
APP_IPAD_PRO_3GEN_11: [[1668, 2388], [1640, 2360]],
|
|
23
|
+
};
|
|
24
|
+
const MAX_PER_SET = 10;
|
|
25
|
+
/** Width and height from a PNG or JPEG header, without decoding the image. */
|
|
26
|
+
export async function imageSize(file) {
|
|
27
|
+
const fh = await open(file, "r");
|
|
28
|
+
try {
|
|
29
|
+
const head = Buffer.alloc(64 * 1024);
|
|
30
|
+
const { bytesRead } = await fh.read(head, 0, head.length, 0);
|
|
31
|
+
const b = head.subarray(0, bytesRead);
|
|
32
|
+
if (b.length >= 24 && b.readUInt32BE(0) === 0x89504e47)
|
|
33
|
+
return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
|
|
34
|
+
if (b[0] === 0xff && b[1] === 0xd8) {
|
|
35
|
+
let i = 2;
|
|
36
|
+
while (i + 9 < b.length) {
|
|
37
|
+
if (b[i] !== 0xff)
|
|
38
|
+
return null;
|
|
39
|
+
const marker = b[i + 1];
|
|
40
|
+
const len = b.readUInt16BE(i + 2);
|
|
41
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
42
|
+
return { height: b.readUInt16BE(i + 5), width: b.readUInt16BE(i + 7) };
|
|
43
|
+
}
|
|
44
|
+
i += 2 + len;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await fh.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Sizes and counts of every screenshot set in the spec. */
|
|
54
|
+
export async function checkScreenshots(root) {
|
|
55
|
+
const base = screenshotsDir(root);
|
|
56
|
+
if (!existsSync(base))
|
|
57
|
+
return [];
|
|
58
|
+
const issues = [];
|
|
59
|
+
for (const locale of await readdir(base)) {
|
|
60
|
+
const localeDir = join(base, locale);
|
|
61
|
+
if (!(await readdir(localeDir).catch(() => null)))
|
|
62
|
+
continue;
|
|
63
|
+
for (const type of await readdir(localeDir)) {
|
|
64
|
+
const files = (await readdir(join(localeDir, type)).catch(() => [])).filter((f) => /\.(png|jpe?g)$/i.test(f)).sort();
|
|
65
|
+
const at = (severity, message) => issues.push({ severity, rule: "screenshots", store: "app-store", locale, field: type, message });
|
|
66
|
+
if (files.length > MAX_PER_SET)
|
|
67
|
+
at("error", `${type} has ${files.length} screenshots; the limit is ${MAX_PER_SET}.`);
|
|
68
|
+
const sizes = SCREENSHOT_SIZES[type];
|
|
69
|
+
if (!sizes)
|
|
70
|
+
continue;
|
|
71
|
+
for (const f of files) {
|
|
72
|
+
const size = await imageSize(join(localeDir, type, f));
|
|
73
|
+
if (!size)
|
|
74
|
+
continue;
|
|
75
|
+
const ok = sizes.some(([w, h]) => (size.width === w && size.height === h) || (size.width === h && size.height === w));
|
|
76
|
+
if (!ok)
|
|
77
|
+
at("error", `${f} is ${size.width}×${size.height}; ${type} takes ${sizes.map(([w, h]) => `${w}×${h}`).join(", ")}.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return issues;
|
|
82
|
+
}
|
|
83
|
+
const list = (doc) => {
|
|
84
|
+
const d = doc?.data;
|
|
85
|
+
return Array.isArray(d) ? d : [];
|
|
86
|
+
};
|
|
87
|
+
/** Download the live App Store screenshots of every locale into the spec. */
|
|
88
|
+
export async function pullScreenshots(root, transport, state, options = {}) {
|
|
89
|
+
const ctx = state.context;
|
|
90
|
+
const doFetch = options.fetcher ?? fetch;
|
|
91
|
+
let count = 0;
|
|
92
|
+
for (const [locale, localizationId] of Object.entries(ctx.versionLocIds)) {
|
|
93
|
+
if (options.locales && !options.locales.includes(locale))
|
|
94
|
+
continue;
|
|
95
|
+
const sets = list(await transport.call("appstore_list_screenshot_sets", { localizationId, limit: 50 }));
|
|
96
|
+
for (const set of sets) {
|
|
97
|
+
const type = set.attributes?.screenshotDisplayType ?? "UNKNOWN";
|
|
98
|
+
const shots = list(await transport.call("appstore_list_screenshots", { setId: set.id, limit: 50 }));
|
|
99
|
+
const dir = join(screenshotsDir(root), locale, type);
|
|
100
|
+
await mkdir(dir, { recursive: true });
|
|
101
|
+
for (const [i, shot] of shots.entries()) {
|
|
102
|
+
const asset = shot.attributes?.imageAsset;
|
|
103
|
+
if (!asset?.templateUrl || !asset.width || !asset.height)
|
|
104
|
+
continue;
|
|
105
|
+
const url = asset.templateUrl.replace("{w}", String(asset.width)).replace("{h}", String(asset.height)).replace("{f}", "png");
|
|
106
|
+
const res = await doFetch(url);
|
|
107
|
+
if (!res.ok)
|
|
108
|
+
continue;
|
|
109
|
+
await writeFile(join(dir, `${String(i + 1).padStart(2, "0")}.png`), Buffer.from(await res.arrayBuffer()));
|
|
110
|
+
count++;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return count;
|
|
115
|
+
}
|
|
116
|
+
//# sourceMappingURL=screenshots.js.map
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Project, StoreId } from "./schema.js";
|
|
2
|
+
import type { RankSource } from "./verify.js";
|
|
3
|
+
type Fetcher = typeof fetch;
|
|
4
|
+
export interface FoundApp {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
developer: string;
|
|
8
|
+
bundleId: string;
|
|
9
|
+
rating?: number;
|
|
10
|
+
}
|
|
11
|
+
/** App Store apps matching a name, from Apple's public search. */
|
|
12
|
+
export declare function findApps(term: string, country?: string, fetcher?: Fetcher): Promise<FoundApp[]>;
|
|
13
|
+
/** Whether a Google Play package has a public store page. */
|
|
14
|
+
export declare function playPackageExists(pkg: string, fetcher?: Fetcher): Promise<boolean>;
|
|
15
|
+
/**
|
|
16
|
+
* Searches the listing already speaks to: the parts of the name and subtitle between
|
|
17
|
+
* punctuation ("Sleep Sounds: Rain & Ocean" → "sleep sounds", "rain", "ocean"), their two-word
|
|
18
|
+
* runs, and keyword-field entries. The brand (the name's first part, alone) is left out.
|
|
19
|
+
*/
|
|
20
|
+
export declare function targetCandidates(project: Project, limit?: number, storeName?: string): string[];
|
|
21
|
+
export interface RankedCandidate {
|
|
22
|
+
keyword: string;
|
|
23
|
+
rank: number | null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The searches to start with: the ones the app already ranks for (a target you are near is
|
|
27
|
+
* the cheapest win), best first, then two-word phrases from the listing.
|
|
28
|
+
*/
|
|
29
|
+
export declare function suggestTargets(candidates: string[], appId: string | undefined, country: string, source: RankSource, count?: number): Promise<RankedCandidate[]>;
|
|
30
|
+
export declare const WORKFLOWS: Record<string, string>;
|
|
31
|
+
export type CheckStatus = "ok" | "warn" | "fail";
|
|
32
|
+
export interface DoctorCheck {
|
|
33
|
+
area: string;
|
|
34
|
+
status: CheckStatus;
|
|
35
|
+
detail: string;
|
|
36
|
+
/** How to fix it, when it is not ok. */
|
|
37
|
+
fix?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Where each store CLI keeps its saved accounts (`appstore accounts add`, `gps accounts add`). */
|
|
40
|
+
export declare function savedAccountsFile(store: StoreId, env?: NodeJS.ProcessEnv): string;
|
|
41
|
+
/** Names of the accounts saved with the store CLI, if any. */
|
|
42
|
+
export declare function savedAccounts(store: StoreId, env?: NodeJS.ProcessEnv): string[];
|
|
43
|
+
/** What is set up for this repo, and what is missing. Works without a spec (it says so). */
|
|
44
|
+
export declare function diagnose(root: string, project: Project | null, env?: NodeJS.ProcessEnv, nodeVersion?: string): DoctorCheck[];
|
|
45
|
+
export {};
|
package/dist/setup.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { SPEC_DIR, SPEC_FILE } from "./files.js";
|
|
4
|
+
import { normalize, words } from "./text.js";
|
|
5
|
+
import { STORE_CLIS, onPath } from "./transport.js";
|
|
6
|
+
/** App Store apps matching a name, from Apple's public search. */
|
|
7
|
+
export async function findApps(term, country = "us", fetcher = fetch) {
|
|
8
|
+
const url = `https://itunes.apple.com/search?media=software&entity=software&limit=6&country=${encodeURIComponent(country)}&term=${encodeURIComponent(term)}`;
|
|
9
|
+
const res = await fetcher(url, { headers: { "User-Agent": "appeeky-spec" } });
|
|
10
|
+
if (!res.ok)
|
|
11
|
+
return [];
|
|
12
|
+
const json = (await res.json());
|
|
13
|
+
return (json.results ?? [])
|
|
14
|
+
.filter((r) => r.trackId && r.trackName)
|
|
15
|
+
.map((r) => ({
|
|
16
|
+
id: String(r.trackId),
|
|
17
|
+
name: r.trackName,
|
|
18
|
+
developer: r.sellerName ?? r.artistName ?? "",
|
|
19
|
+
bundleId: r.bundleId ?? "",
|
|
20
|
+
...(r.averageUserRating ? { rating: Math.round(r.averageUserRating * 10) / 10 } : {}),
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
/** Whether a Google Play package has a public store page. */
|
|
24
|
+
export async function playPackageExists(pkg, fetcher = fetch) {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetcher(`https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en`, { method: "HEAD", headers: { "User-Agent": "Mozilla/5.0 appeeky-spec" } });
|
|
27
|
+
return res.ok;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// ── Targets ─────────────────────────────────────────────────────────────────
|
|
34
|
+
/** Words nobody searches with, at the start or the end of a phrase ("AI-powered", "the best"). */
|
|
35
|
+
const FILLER = new Set("a an the and or for with your my our to of in on by best top new free app apps powered based simple easy fast smart ultimate official".split(" "));
|
|
36
|
+
/**
|
|
37
|
+
* Searches the listing already speaks to: the parts of the name and subtitle between
|
|
38
|
+
* punctuation ("Sleep Sounds: Rain & Ocean" → "sleep sounds", "rain", "ocean"), their two-word
|
|
39
|
+
* runs, and keyword-field entries. The brand (the name's first part, alone) is left out.
|
|
40
|
+
*/
|
|
41
|
+
export function targetCandidates(project, limit = 14, storeName) {
|
|
42
|
+
const locale = project.spec.app.primaryLocale;
|
|
43
|
+
const asc = project.listings["app-store"][locale];
|
|
44
|
+
const play = project.listings["google-play"][locale] ?? Object.values(project.listings["google-play"])[0];
|
|
45
|
+
const name = asc?.name ?? play?.title ?? storeName ?? project.spec.app.name;
|
|
46
|
+
const subtitle = asc?.subtitle ?? play?.shortDescription ?? "";
|
|
47
|
+
const brand = normalize(project.spec.app.name).trim();
|
|
48
|
+
const out = [];
|
|
49
|
+
const add = (phrase) => {
|
|
50
|
+
const ws = words(phrase);
|
|
51
|
+
const p = ws.join(" ");
|
|
52
|
+
if (!p || p.length < 3 || p === brand || out.includes(p) || ws.length > 3)
|
|
53
|
+
return;
|
|
54
|
+
if (FILLER.has(ws[0]) || FILLER.has(ws[ws.length - 1]))
|
|
55
|
+
return;
|
|
56
|
+
out.push(p);
|
|
57
|
+
};
|
|
58
|
+
for (const text of [name, subtitle]) {
|
|
59
|
+
for (const part of text.split(/[:\-–—|,&+/()]| and | for | with /i)) {
|
|
60
|
+
add(part);
|
|
61
|
+
const ws = words(part);
|
|
62
|
+
for (let i = 0; i + 1 < ws.length; i++)
|
|
63
|
+
add(`${ws[i]} ${ws[i + 1]}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const k of (asc?.keywords ?? "").split(","))
|
|
67
|
+
add(k);
|
|
68
|
+
return out.slice(0, limit);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The searches to start with: the ones the app already ranks for (a target you are near is
|
|
72
|
+
* the cheapest win), best first, then two-word phrases from the listing.
|
|
73
|
+
*/
|
|
74
|
+
export async function suggestTargets(candidates, appId, country, source, count = 5) {
|
|
75
|
+
const ranks = appId ? await source.ranks("app-store", appId, country, candidates).catch(() => ({})) : {};
|
|
76
|
+
const ranked = candidates.map((keyword) => ({ keyword, rank: ranks[keyword] ?? null }));
|
|
77
|
+
const found = ranked.filter((r) => r.rank !== null).sort((a, b) => a.rank - b.rank);
|
|
78
|
+
const rest = ranked.filter((r) => r.rank === null).sort((a, b) => b.keyword.split(" ").length - a.keyword.split(" ").length);
|
|
79
|
+
return [...found, ...rest].slice(0, count);
|
|
80
|
+
}
|
|
81
|
+
// ── GitHub workflows ────────────────────────────────────────────────────────
|
|
82
|
+
export const WORKFLOWS = {
|
|
83
|
+
"listing-check.yml": `# Appeeky Spec: report listing changes on every pull request.
|
|
84
|
+
on: pull_request
|
|
85
|
+
permissions:
|
|
86
|
+
contents: read
|
|
87
|
+
pull-requests: write # to comment
|
|
88
|
+
jobs:
|
|
89
|
+
check:
|
|
90
|
+
runs-on: ubuntu-latest
|
|
91
|
+
steps:
|
|
92
|
+
- uses: actions/checkout@v4
|
|
93
|
+
with:
|
|
94
|
+
fetch-depth: 0 # the report compares with main
|
|
95
|
+
- uses: appeeky/appeeky-spec@v0
|
|
96
|
+
`,
|
|
97
|
+
"listing-push.yml": `# Appeeky Spec: push the listing to the stores when a change is merged.
|
|
98
|
+
on:
|
|
99
|
+
push:
|
|
100
|
+
branches: [main]
|
|
101
|
+
paths: ["appeeky/store/**", "appeeky.spec.yaml"]
|
|
102
|
+
permissions:
|
|
103
|
+
contents: write # to commit the release file
|
|
104
|
+
jobs:
|
|
105
|
+
push:
|
|
106
|
+
runs-on: ubuntu-latest
|
|
107
|
+
environment: stores # optional: a required approval before it runs
|
|
108
|
+
steps:
|
|
109
|
+
- uses: actions/checkout@v4
|
|
110
|
+
- run: npx @appeeky/spec push --yes --note "\${{ github.event.head_commit.message }}"
|
|
111
|
+
env:
|
|
112
|
+
APPEEKY_API_KEY: \${{ secrets.APPEEKY_API_KEY }}
|
|
113
|
+
- run: |
|
|
114
|
+
git config user.name "appeeky-spec"
|
|
115
|
+
git config user.email "spec@users.noreply.github.com"
|
|
116
|
+
git add appeeky && git commit -m "Record release" && git push || true
|
|
117
|
+
`,
|
|
118
|
+
"listing-daily.yml": `# Appeeky Spec: record the category rank and AI visibility, measure releases on day 7 and 14, refresh reviews and competitors.
|
|
119
|
+
on:
|
|
120
|
+
schedule: [{ cron: "0 6 * * *" }] # 06:00 UTC
|
|
121
|
+
workflow_dispatch:
|
|
122
|
+
permissions:
|
|
123
|
+
contents: write
|
|
124
|
+
jobs:
|
|
125
|
+
refresh:
|
|
126
|
+
runs-on: ubuntu-latest
|
|
127
|
+
steps:
|
|
128
|
+
- uses: actions/checkout@v4
|
|
129
|
+
- run: |
|
|
130
|
+
npx @appeeky/spec category pull || true
|
|
131
|
+
npx @appeeky/spec visibility pull || true
|
|
132
|
+
npx @appeeky/spec verify --playbook
|
|
133
|
+
npx @appeeky/spec reviews pull || true
|
|
134
|
+
npx @appeeky/spec competitors pull || true
|
|
135
|
+
env:
|
|
136
|
+
APPEEKY_API_KEY: \${{ secrets.APPEEKY_API_KEY }}
|
|
137
|
+
- run: |
|
|
138
|
+
git config user.name "appeeky-spec"
|
|
139
|
+
git config user.email "spec@users.noreply.github.com"
|
|
140
|
+
git add appeeky && git commit -m "Daily: category, AI visibility, verify, reviews, competitors" && git push || true
|
|
141
|
+
`,
|
|
142
|
+
};
|
|
143
|
+
const ok = (area, detail) => ({ area, status: "ok", detail });
|
|
144
|
+
const warn = (area, detail, fix) => ({ area, status: "warn", detail, fix });
|
|
145
|
+
const fail = (area, detail, fix) => ({ area, status: "fail", detail, fix });
|
|
146
|
+
const hasLocalKeys = (store, env) => store === "app-store"
|
|
147
|
+
? Boolean(env.ASC_KEY_ID && (env.ASC_PRIVATE_KEY || env.ASC_PRIVATE_KEY_PATH))
|
|
148
|
+
: Boolean(env.GPS_CREDENTIALS || env.GOOGLE_PLAY_CREDENTIALS || env.GOOGLE_APPLICATION_CREDENTIALS);
|
|
149
|
+
/** Where each store CLI keeps its saved accounts (`appstore accounts add`, `gps accounts add`). */
|
|
150
|
+
export function savedAccountsFile(store, env = process.env) {
|
|
151
|
+
const base = env.XDG_CONFIG_HOME ?? join(env.HOME ?? env.USERPROFILE ?? "", ".config");
|
|
152
|
+
return join(base, store === "app-store" ? "appstore" : "gps", "config.json");
|
|
153
|
+
}
|
|
154
|
+
/** Names of the accounts saved with the store CLI, if any. */
|
|
155
|
+
export function savedAccounts(store, env = process.env) {
|
|
156
|
+
try {
|
|
157
|
+
const config = JSON.parse(readFileSync(savedAccountsFile(store, env), "utf8"));
|
|
158
|
+
const accounts = config.accounts;
|
|
159
|
+
return Array.isArray(accounts) ? accounts.map((a) => String(a.name ?? a)) : Object.keys(accounts ?? {});
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** What is set up for this repo, and what is missing. Works without a spec (it says so). */
|
|
166
|
+
export function diagnose(root, project, env = process.env, nodeVersion = process.versions.node) {
|
|
167
|
+
const out = [];
|
|
168
|
+
const major = Number(nodeVersion.split(".")[0]);
|
|
169
|
+
out.push(major >= 20 ? ok("node", `Node ${nodeVersion}`) : fail("node", `Node ${nodeVersion}`, "Install Node 20 or newer."));
|
|
170
|
+
if (!project) {
|
|
171
|
+
out.push(fail("spec", `No ${SPEC_FILE} here or above`, "Run `npx @appeeky/spec init` in your app's repo."));
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
out.push(ok("spec", `${SPEC_FILE} · ${project.spec.app.name}`));
|
|
175
|
+
const key = Boolean(env.APPEEKY_API_KEY);
|
|
176
|
+
const stores = [...(project.spec.stores.appStore ? ["app-store"] : []), ...(project.spec.stores.googlePlay ? ["google-play"] : [])];
|
|
177
|
+
if (stores.length === 0)
|
|
178
|
+
out.push(fail("stores", "No store in the spec", "Add stores.appStore.appId or stores.googlePlay.packageName, or run `init` again."));
|
|
179
|
+
for (const store of stores) {
|
|
180
|
+
const cli = STORE_CLIS[store];
|
|
181
|
+
const label = store === "app-store" ? "App Store" : "Google Play";
|
|
182
|
+
const via = (store === "app-store" ? project.spec.stores.appStore?.via : project.spec.stores.googlePlay?.via) ?? "auto";
|
|
183
|
+
const local = onPath(cli.bin, env);
|
|
184
|
+
// The CLI runs from PATH or through npx; either way it reads keys from the environment or its saved accounts.
|
|
185
|
+
const accounts = savedAccounts(store, env);
|
|
186
|
+
const keys = hasLocalKeys(store, env) ? "keys in the environment" : accounts.length ? `saved account${accounts.length > 1 ? "s" : ""} ${accounts.join(", ")}` : "";
|
|
187
|
+
const runner = local ? `${cli.bin} CLI` : `npx ${cli.npmPackage}`;
|
|
188
|
+
if (via === "hosted")
|
|
189
|
+
out.push(key ? ok(label, "hosted through your Appeeky connection (APPEEKY_API_KEY)") : fail(label, "via: hosted, but APPEEKY_API_KEY is not set", "Set APPEEKY_API_KEY (appeeky.com/dashboard/settings)."));
|
|
190
|
+
else if (keys && (via === "local" || local || !key))
|
|
191
|
+
out.push(ok(label, `your own key: ${runner}, ${keys}`));
|
|
192
|
+
else if (via !== "local" && key)
|
|
193
|
+
out.push(ok(label, "hosted through your Appeeky connection (APPEEKY_API_KEY)"));
|
|
194
|
+
else if (local)
|
|
195
|
+
out.push(fail(label, `${cli.bin} CLI is installed but has no saved account and no keys in the environment`, cli.setupHint));
|
|
196
|
+
else
|
|
197
|
+
out.push(fail(label, `No store key saved, no ${cli.bin} CLI and no APPEEKY_API_KEY`, cli.setupHint));
|
|
198
|
+
const files = Object.keys(project.listings[store]).length;
|
|
199
|
+
out.push(files ? ok(`${label} listing`, `${files} locale${files === 1 ? "" : "s"} in ${SPEC_DIR}/store/${store}/`) : warn(`${label} listing`, "Not pulled yet", `Run \`npx @appeeky/spec pull --store ${store}\`.`));
|
|
200
|
+
}
|
|
201
|
+
const targets = Object.entries(project.spec.targets).filter(([, list]) => list.length > 0);
|
|
202
|
+
const count = targets.reduce((n, [, list]) => n + list.length, 0);
|
|
203
|
+
out.push(count ? ok("targets", `${count} in ${targets.map(([cc]) => cc).join(", ")}`) : fail("targets", "None yet: coverage and verify have nothing to measure", "Add the searches to win under targets:, or run `npx @appeeky/spec suggest`."));
|
|
204
|
+
if (targets.length === 1)
|
|
205
|
+
out.push(warn("control group", "Targets in one storefront only", "Add a few targets for a second country: verify compares the storefronts a release changed with the ones it did not."));
|
|
206
|
+
out.push(key ? ok("Appeeky API", "APPEEKY_API_KEY set: popularity, both stores' ranks, conversion, revenue, ads") : warn("Appeeky API", "Not set: App Store ranks only, no popularity or outcome metrics", "Optional: set APPEEKY_API_KEY."));
|
|
207
|
+
const attribution = project.spec.attribution;
|
|
208
|
+
if (!attribution)
|
|
209
|
+
out.push(warn("attribution", "Not set up: releases are judged on ranks and store numbers", "Optional: `npx @appeeky/spec attribution init`."));
|
|
210
|
+
else if (attribution.via === "self" && !env.ATTRIBUTION_TOKEN)
|
|
211
|
+
out.push(fail("attribution", "via: self, but ATTRIBUTION_TOKEN is not set", "Set ATTRIBUTION_TOKEN to your server's ADMIN_TOKEN."));
|
|
212
|
+
else if (attribution.via === "hosted" && !key)
|
|
213
|
+
out.push(fail("attribution", "via: hosted, but APPEEKY_API_KEY is not set", "Set APPEEKY_API_KEY."));
|
|
214
|
+
else
|
|
215
|
+
out.push(ok("attribution", `${attribution.via === "self" ? attribution.url : "Appeeky hosted"}`));
|
|
216
|
+
const workflows = join(root, ".github", "workflows");
|
|
217
|
+
const found = existsSync(workflows) ? readdirSync(workflows).filter((f) => /\.ya?ml$/.test(f) && /appeeky\/appeeky-spec|@appeeky\/spec/.test(readFileSync(join(workflows, f), "utf8"))) : [];
|
|
218
|
+
out.push(found.length ? ok("GitHub Actions", found.join(", ")) : warn("GitHub Actions", "No workflow runs the spec", "Optional: `npx @appeeky/spec init --ci` writes the pull request check, push on merge and daily verify."));
|
|
219
|
+
const skills = [".claude/skills/appeeky-spec", ".agents/skills/appeeky-spec", ".cursor/skills/appeeky-spec"].some((p) => existsSync(join(root, p)));
|
|
220
|
+
out.push(skills ? ok("agent skills", "installed") : warn("agent skills", "Not installed in this repo", "Optional: `npx skills add appeeky/appeeky-spec`."));
|
|
221
|
+
const ignore = existsSync(join(root, ".gitignore")) ? readFileSync(join(root, ".gitignore"), "utf8") : "";
|
|
222
|
+
if (!/\.appeeky\/?(\s|$)|\.appeeky\/data|\.appeeky\/\*/m.test(ignore))
|
|
223
|
+
out.push(warn(".gitignore", ".appeeky/ is not ignored: data and cache could be committed", "Add `.appeeky/` to .gitignore."));
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
//# sourceMappingURL=setup.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Project } from "./schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Listing locales each App Store storefront is commonly observed to index, in
|
|
4
|
+
* order of preference. Apple does not publish this list and it changes, so it
|
|
5
|
+
* is a starting point: override any storefront with `storefronts:` in
|
|
6
|
+
* `appeeky.spec.yaml`. Storefronts not listed here fall back to the app's
|
|
7
|
+
* primary locale.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_STOREFRONT_LOCALES: Record<string, string[]>;
|
|
10
|
+
/** Listing locales that count for a storefront, limited to locales the spec has. */
|
|
11
|
+
export declare function indexedLocales(project: Project, country: string): string[];
|
|
12
|
+
/** Best Google Play language for a storefront: exact match first, then the bare language, then the primary locale. */
|
|
13
|
+
export declare function playLanguageFor(project: Project, country: string): string | undefined;
|
|
14
|
+
/** The Google Play language for an App Store locale (`tr` → `tr-TR`, `zh-Hans` → `zh-CN`). */
|
|
15
|
+
export declare function playLanguageCode(appStoreLocale: string): string;
|