@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.
Files changed (86) hide show
  1. package/.github/assets/cover.gif +0 -0
  2. package/LICENSE +110 -0
  3. package/README.md +483 -2
  4. package/dist/ads.d.ts +39 -0
  5. package/dist/ads.js +67 -0
  6. package/dist/appeeky-api.d.ts +62 -0
  7. package/dist/appeeky-api.js +240 -0
  8. package/dist/attribution-setup.d.ts +28 -0
  9. package/dist/attribution-setup.js +65 -0
  10. package/dist/attribution.d.ts +121 -0
  11. package/dist/attribution.js +170 -0
  12. package/dist/bin.d.ts +2 -0
  13. package/dist/bin.js +4 -0
  14. package/dist/category.d.ts +71 -0
  15. package/dist/category.js +198 -0
  16. package/dist/cli.d.ts +3 -0
  17. package/dist/cli.js +1387 -0
  18. package/dist/competitors.d.ts +74 -0
  19. package/dist/competitors.js +143 -0
  20. package/dist/control.d.ts +21 -0
  21. package/dist/control.js +53 -0
  22. package/dist/core.d.ts +15 -0
  23. package/dist/core.js +15 -0
  24. package/dist/coverage.d.ts +46 -0
  25. package/dist/coverage.js +115 -0
  26. package/dist/data.d.ts +48 -0
  27. package/dist/data.js +298 -0
  28. package/dist/diff.d.ts +11 -0
  29. package/dist/diff.js +33 -0
  30. package/dist/experiments.d.ts +115 -0
  31. package/dist/experiments.js +85 -0
  32. package/dist/files.d.ts +27 -0
  33. package/dist/files.js +129 -0
  34. package/dist/history.d.ts +43 -0
  35. package/dist/history.js +55 -0
  36. package/dist/index.d.ts +24 -0
  37. package/dist/index.js +24 -0
  38. package/dist/limits.d.ts +15 -0
  39. package/dist/limits.js +30 -0
  40. package/dist/lint.d.ts +16 -0
  41. package/dist/lint.js +159 -0
  42. package/dist/loops.d.ts +37 -0
  43. package/dist/loops.js +197 -0
  44. package/dist/measure.d.ts +40 -0
  45. package/dist/measure.js +159 -0
  46. package/dist/metrics.d.ts +270 -0
  47. package/dist/metrics.js +250 -0
  48. package/dist/output.d.ts +21 -0
  49. package/dist/output.js +79 -0
  50. package/dist/project.d.ts +19 -0
  51. package/dist/project.js +35 -0
  52. package/dist/report.d.ts +5 -0
  53. package/dist/report.js +71 -0
  54. package/dist/reviews.d.ts +130 -0
  55. package/dist/reviews.js +287 -0
  56. package/dist/schema.d.ts +519 -0
  57. package/dist/schema.js +218 -0
  58. package/dist/screenshots.d.ts +23 -0
  59. package/dist/screenshots.js +116 -0
  60. package/dist/setup.d.ts +45 -0
  61. package/dist/setup.js +226 -0
  62. package/dist/storefronts.d.ts +15 -0
  63. package/dist/storefronts.js +89 -0
  64. package/dist/stores/app-store.d.ts +33 -0
  65. package/dist/stores/app-store.js +159 -0
  66. package/dist/stores/google-play.d.ts +13 -0
  67. package/dist/stores/google-play.js +61 -0
  68. package/dist/stores/types.d.ts +34 -0
  69. package/dist/stores/types.js +2 -0
  70. package/dist/suggest.d.ts +73 -0
  71. package/dist/suggest.js +135 -0
  72. package/dist/text.d.ts +18 -0
  73. package/dist/text.js +55 -0
  74. package/dist/transport.d.ts +98 -0
  75. package/dist/transport.js +170 -0
  76. package/dist/verify.d.ts +70 -0
  77. package/dist/verify.js +134 -0
  78. package/dist/visibility.d.ts +149 -0
  79. package/dist/visibility.js +239 -0
  80. package/package.json +76 -4
  81. package/skills/appeeky-spec/SKILL.md +146 -0
  82. package/skills/appeeky-spec-experiments/SKILL.md +83 -0
  83. package/skills/appeeky-spec-localize/SKILL.md +52 -0
  84. package/skills/appeeky-spec-review/SKILL.md +75 -0
  85. package/skills/appeeky-spec-targets/SKILL.md +66 -0
  86. package/skills/appeeky-spec-visibility/SKILL.md +79 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Listing locales each App Store storefront is commonly observed to index, in
3
+ * order of preference. Apple does not publish this list and it changes, so it
4
+ * is a starting point: override any storefront with `storefronts:` in
5
+ * `appeeky.spec.yaml`. Storefronts not listed here fall back to the app's
6
+ * primary locale.
7
+ */
8
+ export const DEFAULT_STOREFRONT_LOCALES = {
9
+ us: ["en-US", "es-MX"],
10
+ gb: ["en-GB"],
11
+ ca: ["en-CA", "fr-CA"],
12
+ au: ["en-AU"],
13
+ nz: ["en-AU", "en-GB"],
14
+ ie: ["en-GB"],
15
+ in: ["en-GB", "hi"],
16
+ de: ["de-DE"],
17
+ at: ["de-DE"],
18
+ ch: ["de-DE", "fr-FR", "it"],
19
+ fr: ["fr-FR"],
20
+ be: ["fr-FR", "nl-NL"],
21
+ nl: ["nl-NL"],
22
+ es: ["es-ES"],
23
+ mx: ["es-MX"],
24
+ ar: ["es-MX"],
25
+ co: ["es-MX"],
26
+ cl: ["es-MX"],
27
+ it: ["it"],
28
+ pt: ["pt-PT"],
29
+ br: ["pt-BR"],
30
+ tr: ["tr"],
31
+ ru: ["ru"],
32
+ ua: ["uk"],
33
+ pl: ["pl"],
34
+ se: ["sv"],
35
+ no: ["no"],
36
+ dk: ["da"],
37
+ fi: ["fi"],
38
+ cz: ["cs"],
39
+ gr: ["el"],
40
+ hu: ["hu"],
41
+ ro: ["ro"],
42
+ jp: ["ja"],
43
+ kr: ["ko"],
44
+ cn: ["zh-Hans"],
45
+ tw: ["zh-Hant"],
46
+ hk: ["zh-Hant"],
47
+ th: ["th"],
48
+ vn: ["vi"],
49
+ id: ["id"],
50
+ my: ["ms"],
51
+ sa: ["ar-SA"],
52
+ ae: ["ar-SA", "en-GB"],
53
+ il: ["he"],
54
+ };
55
+ /** Listing locales that count for a storefront, limited to locales the spec has. */
56
+ export function indexedLocales(project, country) {
57
+ const cc = country.toLowerCase();
58
+ const available = Object.keys(project.listings["app-store"]);
59
+ const wanted = project.spec.storefronts[cc] ?? DEFAULT_STOREFRONT_LOCALES[cc] ?? [];
60
+ const present = wanted.filter((l) => available.includes(l));
61
+ if (present.length > 0)
62
+ return present;
63
+ const primary = project.spec.app.primaryLocale;
64
+ return available.includes(primary) ? [primary] : available.slice(0, 1);
65
+ }
66
+ /** Best Google Play language for a storefront: exact match first, then the bare language, then the primary locale. */
67
+ export function playLanguageFor(project, country) {
68
+ const available = Object.keys(project.listings["google-play"]);
69
+ const appStoreGuess = (project.spec.storefronts[country] ?? DEFAULT_STOREFRONT_LOCALES[country] ?? [])[0];
70
+ const candidates = [appStoreGuess, appStoreGuess?.split("-")[0], project.spec.app.primaryLocale].filter(Boolean);
71
+ for (const c of candidates) {
72
+ const hit = available.find((l) => l.toLowerCase() === c.toLowerCase()) ?? available.find((l) => l.toLowerCase().startsWith(`${c.toLowerCase()}-`));
73
+ if (hit)
74
+ return hit;
75
+ }
76
+ return available[0];
77
+ }
78
+ /** App Store locale code → Google Play language code, where they differ. */
79
+ const PLAY_LANGUAGE = {
80
+ tr: "tr-TR", ja: "ja-JP", ko: "ko-KR", it: "it-IT", ru: "ru-RU", uk: "uk", "zh-Hans": "zh-CN", "zh-Hant": "zh-TW",
81
+ "es-MX": "es-419", "ar-SA": "ar", he: "iw-IL", no: "no-NO", sv: "sv-SE", da: "da-DK", fi: "fi-FI", pl: "pl-PL",
82
+ cs: "cs-CZ", el: "el-GR", hu: "hu-HU", ro: "ro", th: "th", vi: "vi", id: "id", ms: "ms", hi: "hi-IN", ca: "ca",
83
+ hr: "hr", sk: "sk", "en-AU": "en-AU", "en-CA": "en-CA", "en-GB": "en-GB", "en-US": "en-US",
84
+ };
85
+ /** The Google Play language for an App Store locale (`tr` → `tr-TR`, `zh-Hans` → `zh-CN`). */
86
+ export function playLanguageCode(appStoreLocale) {
87
+ return PLAY_LANGUAGE[appStoreLocale] ?? appStoreLocale;
88
+ }
89
+ //# sourceMappingURL=storefronts.js.map
@@ -0,0 +1,33 @@
1
+ import type { AppStoreListing, StoreListings } from "../schema.js";
2
+ import type { OpsTransport } from "../transport.js";
3
+ import type { FieldChange, PushResult, RemoteState, StoreAdapter } from "./types.js";
4
+ export interface AppStoreContext {
5
+ version?: {
6
+ id: string;
7
+ versionString?: string;
8
+ state: string;
9
+ editable: boolean;
10
+ };
11
+ appInfo?: {
12
+ id: string;
13
+ state: string;
14
+ editable: boolean;
15
+ };
16
+ versionLocIds: Record<string, string>;
17
+ appInfoLocIds: Record<string, string>;
18
+ }
19
+ export declare class AppStoreAdapter implements StoreAdapter<AppStoreListing> {
20
+ private readonly transport;
21
+ private readonly appId;
22
+ private readonly platform;
23
+ readonly store: "app-store";
24
+ constructor(transport: OpsTransport, appId: string, platform?: string);
25
+ pull(): Promise<RemoteState<AppStoreListing>>;
26
+ /** Create the next App Store version so name, subtitle and keywords can change. */
27
+ createVersion(versionString: string): Promise<{
28
+ id: string;
29
+ versionString: string;
30
+ }>;
31
+ annotate(changes: FieldChange[], state: RemoteState<AppStoreListing>, local: StoreListings<AppStoreListing>): FieldChange[];
32
+ push(changes: FieldChange[], state: RemoteState<AppStoreListing>, local: StoreListings<AppStoreListing>): Promise<PushResult>;
33
+ }
@@ -0,0 +1,159 @@
1
+ import { LIVE_EDITABLE } from "../limits.js";
2
+ /** Version and app info states in which App Store Connect accepts metadata edits. */
3
+ const EDITABLE_STATES = new Set([
4
+ "PREPARE_FOR_SUBMISSION",
5
+ "DEVELOPER_REJECTED",
6
+ "REJECTED",
7
+ "METADATA_REJECTED",
8
+ "INVALID_BINARY",
9
+ ]);
10
+ const LIVE_STATES = new Set(["READY_FOR_SALE", "READY_FOR_DISTRIBUTION", "ACCEPTED", "PROCESSING_FOR_DISTRIBUTION"]);
11
+ const APP_INFO_FIELDS = new Set(["name", "subtitle"]);
12
+ const VERSION_FIELDS = ["keywords", "promotionalText", "description", "whatsNew", "marketingUrl", "supportUrl"];
13
+ function list(doc) {
14
+ if (!doc)
15
+ return [];
16
+ if (Array.isArray(doc))
17
+ return doc;
18
+ if (Array.isArray(doc.data))
19
+ return doc.data;
20
+ return doc.data ? [doc.data] : [];
21
+ }
22
+ function versionState(v) {
23
+ return v.attributes?.appVersionState ?? v.attributes?.appStoreState ?? "UNKNOWN";
24
+ }
25
+ function appInfoState(i) {
26
+ return i.attributes?.state ?? i.attributes?.appStoreState ?? "UNKNOWN";
27
+ }
28
+ function newestFirst(items) {
29
+ return [...items].sort((a, b) => (b.attributes?.createdDate ?? "").localeCompare(a.attributes?.createdDate ?? ""));
30
+ }
31
+ function clean(attrs, keys) {
32
+ const out = {};
33
+ for (const k of keys) {
34
+ const v = attrs?.[k];
35
+ if (typeof v === "string" && v !== "")
36
+ out[k] = v;
37
+ }
38
+ return out;
39
+ }
40
+ export class AppStoreAdapter {
41
+ transport;
42
+ appId;
43
+ platform;
44
+ store = "app-store";
45
+ constructor(transport, appId, platform = "IOS") {
46
+ this.transport = transport;
47
+ this.appId = appId;
48
+ this.platform = platform;
49
+ }
50
+ async pull() {
51
+ const versions = newestFirst(list(await this.transport.call("appstore_list_versions", { appId: this.appId, platform: this.platform })));
52
+ const version = versions.find((v) => EDITABLE_STATES.has(versionState(v))) ?? versions.find((v) => LIVE_STATES.has(versionState(v)));
53
+ const infos = list(await this.transport.call("appstore_list_app_infos", { appId: this.appId }));
54
+ const appInfo = infos.find((i) => EDITABLE_STATES.has(appInfoState(i))) ?? infos.find((i) => LIVE_STATES.has(appInfoState(i))) ?? infos[0];
55
+ const listings = {};
56
+ const context = { versionLocIds: {}, appInfoLocIds: {} };
57
+ if (version) {
58
+ const state = versionState(version);
59
+ context.version = { id: version.id, versionString: version.attributes?.versionString, state, editable: EDITABLE_STATES.has(state) };
60
+ const locs = list(await this.transport.call("appstore_list_version_localizations", { versionId: version.id, limit: 200 }));
61
+ for (const loc of locs) {
62
+ const locale = loc.attributes?.locale;
63
+ if (!locale)
64
+ continue;
65
+ context.versionLocIds[locale] = loc.id;
66
+ listings[locale] = { ...listings[locale], ...clean(loc.attributes, VERSION_FIELDS) };
67
+ }
68
+ }
69
+ if (appInfo) {
70
+ const state = appInfoState(appInfo);
71
+ context.appInfo = { id: appInfo.id, state, editable: EDITABLE_STATES.has(state) };
72
+ const locs = list(await this.transport.call("appstore_list_app_info_localizations", { appInfoId: appInfo.id, limit: 200 }));
73
+ for (const loc of locs) {
74
+ const locale = loc.attributes?.locale;
75
+ if (!locale)
76
+ continue;
77
+ context.appInfoLocIds[locale] = loc.id;
78
+ listings[locale] = { ...clean(loc.attributes, ["name", "subtitle"]), ...listings[locale] };
79
+ }
80
+ }
81
+ const target = context.version
82
+ ? `version ${context.version.versionString ?? context.version.id} (${context.version.state.toLowerCase().replace(/_/g, " ")})`
83
+ : "no App Store version found";
84
+ return { store: this.store, listings, target, context };
85
+ }
86
+ /** Create the next App Store version so name, subtitle and keywords can change. */
87
+ async createVersion(versionString) {
88
+ const created = await this.transport.call("appstore_create_version", { appId: this.appId, platform: this.platform, versionString }, { write: true });
89
+ const id = list(created)[0]?.id;
90
+ if (!id)
91
+ throw new Error(`App Store Connect did not return the new version ${versionString}.`);
92
+ return { id, versionString };
93
+ }
94
+ annotate(changes, state, local) {
95
+ const ctx = state.context;
96
+ return changes.map((c) => {
97
+ if (APP_INFO_FIELDS.has(c.field)) {
98
+ if (!ctx.appInfo)
99
+ return { ...c, blocked: "This app has no app info in App Store Connect." };
100
+ if (!ctx.appInfo.editable) {
101
+ return { ...c, blocked: "Name and subtitle change only with a new version. Create the next version in App Store Connect, then push again." };
102
+ }
103
+ if (!ctx.appInfoLocIds[c.locale] && !local[c.locale]?.name) {
104
+ return { ...c, blocked: `Adding ${c.locale} needs a name in the spec.` };
105
+ }
106
+ return c;
107
+ }
108
+ if (!ctx.version)
109
+ return { ...c, blocked: "This app has no App Store version yet." };
110
+ if (!ctx.version.editable && !LIVE_EDITABLE["app-store"].has(c.field)) {
111
+ return {
112
+ ...c,
113
+ blocked: `${c.field} changes only with a new version (the current one is ${ctx.version.state.toLowerCase().replace(/_/g, " ")}). Create the next version, then push again.`,
114
+ };
115
+ }
116
+ return c;
117
+ });
118
+ }
119
+ async push(changes, state, local) {
120
+ const ctx = state.context;
121
+ const annotated = this.annotate(changes, state, local);
122
+ const ready = annotated.filter((c) => !c.blocked);
123
+ const skipped = annotated.filter((c) => c.blocked);
124
+ const applied = [];
125
+ for (const locale of [...new Set(ready.map((c) => c.locale))]) {
126
+ const mine = ready.filter((c) => c.locale === locale);
127
+ const infoFields = Object.fromEntries(mine.filter((c) => APP_INFO_FIELDS.has(c.field)).map((c) => [c.field, c.after]));
128
+ const versionFields = Object.fromEntries(mine.filter((c) => !APP_INFO_FIELDS.has(c.field)).map((c) => [c.field, c.after]));
129
+ if (Object.keys(infoFields).length > 0 && ctx.appInfo) {
130
+ const locId = ctx.appInfoLocIds[locale];
131
+ if (locId) {
132
+ await this.transport.call("appstore_update_app_info_localization", { localizationId: locId, ...infoFields }, { write: true });
133
+ }
134
+ else {
135
+ const created = await this.transport.call("appstore_create_app_info_localization", { appInfoId: ctx.appInfo.id, locale, name: local[locale]?.name, ...infoFields }, { write: true });
136
+ const id = list(created)[0]?.id;
137
+ if (id)
138
+ ctx.appInfoLocIds[locale] = id;
139
+ }
140
+ applied.push(...mine.filter((c) => APP_INFO_FIELDS.has(c.field)));
141
+ }
142
+ if (Object.keys(versionFields).length > 0 && ctx.version) {
143
+ const locId = ctx.versionLocIds[locale];
144
+ if (locId) {
145
+ await this.transport.call("appstore_update_version_localization", { localizationId: locId, ...versionFields }, { write: true });
146
+ }
147
+ else {
148
+ const created = await this.transport.call("appstore_create_version_localization", { versionId: ctx.version.id, locale, ...versionFields }, { write: true });
149
+ const id = list(created)[0]?.id;
150
+ if (id)
151
+ ctx.versionLocIds[locale] = id;
152
+ }
153
+ applied.push(...mine.filter((c) => !APP_INFO_FIELDS.has(c.field)));
154
+ }
155
+ }
156
+ return { applied, skipped };
157
+ }
158
+ }
159
+ //# sourceMappingURL=app-store.js.map
@@ -0,0 +1,13 @@
1
+ import type { GooglePlayListing, StoreListings } from "../schema.js";
2
+ import type { OpsTransport } from "../transport.js";
3
+ import type { FieldChange, PushResult, RemoteState, StoreAdapter } from "./types.js";
4
+ export declare class GooglePlayAdapter implements StoreAdapter<GooglePlayListing> {
5
+ private readonly transport;
6
+ private readonly packageName;
7
+ readonly store: "google-play";
8
+ constructor(transport: OpsTransport, packageName: string);
9
+ pull(): Promise<RemoteState<GooglePlayListing>>;
10
+ annotate(changes: FieldChange[], _state: RemoteState<GooglePlayListing>, local: StoreListings<GooglePlayListing>): FieldChange[];
11
+ /** Google Play commits each language in its own edit; changes go live without an app update. */
12
+ push(changes: FieldChange[], state: RemoteState<GooglePlayListing>, local: StoreListings<GooglePlayListing>): Promise<PushResult>;
13
+ }
@@ -0,0 +1,61 @@
1
+ const REQUIRED_FOR_NEW = ["title", "shortDescription", "fullDescription"];
2
+ function toListing(l) {
3
+ const out = {};
4
+ if (l.title)
5
+ out.title = l.title;
6
+ if (l.shortDescription)
7
+ out.shortDescription = l.shortDescription;
8
+ if (l.fullDescription)
9
+ out.fullDescription = l.fullDescription;
10
+ if (l.video)
11
+ out.video = l.video;
12
+ return out;
13
+ }
14
+ function rows(result) {
15
+ if (Array.isArray(result))
16
+ return result;
17
+ const wrapped = result;
18
+ return wrapped?.listings ?? wrapped?.data ?? [];
19
+ }
20
+ export class GooglePlayAdapter {
21
+ transport;
22
+ packageName;
23
+ store = "google-play";
24
+ constructor(transport, packageName) {
25
+ this.transport = transport;
26
+ this.packageName = packageName;
27
+ }
28
+ async pull() {
29
+ const listings = {};
30
+ for (const l of rows(await this.transport.call("gps_list_listings", { packageName: this.packageName }))) {
31
+ if (l.language)
32
+ listings[l.language] = toListing(l);
33
+ }
34
+ return { store: this.store, listings, target: `${this.packageName} (live listing)`, context: null };
35
+ }
36
+ annotate(changes, _state, local) {
37
+ return changes.map((c) => {
38
+ if (!c.newLocale)
39
+ return c;
40
+ const missing = REQUIRED_FOR_NEW.filter((f) => !local[c.locale]?.[f]);
41
+ return missing.length > 0 ? { ...c, blocked: `A new Google Play language needs ${missing.join(", ")} in the spec.` } : c;
42
+ });
43
+ }
44
+ /** Google Play commits each language in its own edit; changes go live without an app update. */
45
+ async push(changes, state, local) {
46
+ const annotated = this.annotate(changes, state, local);
47
+ const ready = annotated.filter((c) => !c.blocked);
48
+ const skipped = annotated.filter((c) => c.blocked);
49
+ const applied = [];
50
+ for (const language of [...new Set(ready.map((c) => c.locale))]) {
51
+ const mine = ready.filter((c) => c.locale === language);
52
+ const fields = mine[0].newLocale
53
+ ? { ...local[language] }
54
+ : Object.fromEntries(mine.map((c) => [c.field, c.after]));
55
+ await this.transport.call("gps_update_listing", { packageName: this.packageName, language, ...fields }, { write: true });
56
+ applied.push(...mine);
57
+ }
58
+ return { applied, skipped };
59
+ }
60
+ }
61
+ //# sourceMappingURL=google-play.js.map
@@ -0,0 +1,34 @@
1
+ import type { Listing, StoreId, StoreListings } from "../schema.js";
2
+ /** One field that differs between the spec and the store. */
3
+ export interface FieldChange {
4
+ store: StoreId;
5
+ locale: string;
6
+ field: string;
7
+ /** Live value; undefined when the store has no value or no such locale. */
8
+ before: string | undefined;
9
+ after: string;
10
+ /** The locale does not exist in the store yet. */
11
+ newLocale: boolean;
12
+ /** Why this change cannot be pushed right now, if it cannot. */
13
+ blocked?: string;
14
+ }
15
+ /** What the store looks like now, plus whatever the adapter needs to write back. */
16
+ export interface RemoteState<L extends Listing = Listing> {
17
+ store: StoreId;
18
+ listings: StoreListings<L>;
19
+ /** Human-readable context, e.g. which App Store version is being edited. */
20
+ target: string;
21
+ /** Adapter-private data (resource ids, editability). */
22
+ context: unknown;
23
+ }
24
+ export interface PushResult {
25
+ applied: FieldChange[];
26
+ skipped: FieldChange[];
27
+ }
28
+ export interface StoreAdapter<L extends Listing = Listing> {
29
+ readonly store: StoreId;
30
+ pull(): Promise<RemoteState<L>>;
31
+ /** Mark changes that cannot be written in the current store state. */
32
+ annotate(changes: FieldChange[], state: RemoteState<L>, local: StoreListings<L>): FieldChange[];
33
+ push(changes: FieldChange[], state: RemoteState<L>, local: StoreListings<L>): Promise<PushResult>;
34
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,73 @@
1
+ import type { Project, StoreId } from "./schema.js";
2
+ /** Add one word to an App Store keyword field the compact way (`a,b,c`), or return null when it does not fit. */
3
+ export declare function appendKeyword(field: string | undefined, word: string, maxChars?: number): string | null;
4
+ export interface KeywordSuggestion {
5
+ word: string;
6
+ /** Targets that this word would help cover. */
7
+ helps: string[];
8
+ popularity?: number;
9
+ /** True when the word still fits in the keyword field. */
10
+ fits: boolean;
11
+ /** True when one of your own targets needs it (ranked before API candidates). */
12
+ target: boolean;
13
+ }
14
+ export interface CandidateKeyword {
15
+ keyword: string;
16
+ popularity?: number;
17
+ /** "suggestion" for store autocomplete ideas, which must relate to the listing to count. */
18
+ source?: string;
19
+ }
20
+ /**
21
+ * Words to add to one App Store locale's keyword field: the missing words of
22
+ * targets in storefronts that index this locale, plus extra candidates (for
23
+ * example keyword opportunities from the Appeeky API). Words already indexed
24
+ * anywhere in the locale are left out.
25
+ *
26
+ * An autocomplete idea only counts when it shares a word with the listing or
27
+ * the targets: "screenshot stitching" is an idea for a screenshot app, "proton
28
+ * vpn" (another product the store's autocomplete returned) is not. Searches
29
+ * the app already ranks for always count.
30
+ */
31
+ export declare function keywordSuggestions(project: Project, locale: string, candidates?: CandidateKeyword[]): KeywordSuggestion[];
32
+ export interface CrossLocalizationSuggestion {
33
+ country: string;
34
+ /** The secondary locale whose keyword field gets the words. */
35
+ locale: string;
36
+ add: string[];
37
+ /** The keyword field after adding them. */
38
+ keywords: string;
39
+ /** The field was one long phrase without commas; `keywords` also splits it into words. */
40
+ splitPhrase?: boolean;
41
+ }
42
+ /**
43
+ * A keyword field written without commas reads as one long phrase. Split it
44
+ * into comma-separated words so each word can combine with others.
45
+ */
46
+ export declare function splitKeywordPhrase(field: string): string | null;
47
+ /**
48
+ * Storefronts that index more than one locale (the US reads Spanish (Mexico)
49
+ * too) get extra keyword space for free. Fill the spare characters of the secondary
50
+ * locale with words the storefront's targets still miss.
51
+ */
52
+ export declare function crossLocalizationSuggestions(project: Project): CrossLocalizationSuggestion[];
53
+ /** A competitor metadata change, as recorded by Appeeky's competitor timeline. */
54
+ export interface CompetitorChange {
55
+ competitor: string;
56
+ field: string;
57
+ before: string | null;
58
+ after: string | null;
59
+ at: string;
60
+ }
61
+ export interface CompetitorGap {
62
+ competitor: string;
63
+ field: string;
64
+ at: string;
65
+ /** Words the competitor stopped using that one of your targets needs. */
66
+ dropped: string[];
67
+ /** Words the competitor started using that one of your targets needs. */
68
+ added: string[];
69
+ /** Of the dropped words, those your listing does not index yet. */
70
+ openings: string[];
71
+ }
72
+ /** Turn competitor metadata changes into openings for one storefront's targets. */
73
+ export declare function competitorGaps(project: Project, country: string, changes: CompetitorChange[], store?: StoreId): CompetitorGap[];
@@ -0,0 +1,135 @@
1
+ import { appStoreCoverage, googlePlayCoverage, isCovered } from "./coverage.js";
2
+ import { indexedLocales } from "./storefronts.js";
3
+ import { charLength, keywordEntries, STOPWORDS, unique, words } from "./text.js";
4
+ /** Add one word to an App Store keyword field the compact way (`a,b,c`), or return null when it does not fit. */
5
+ export function appendKeyword(field, word, maxChars = 100) {
6
+ const entries = keywordEntries(field);
7
+ if (entries.some((e) => e.toLowerCase() === word.toLowerCase()))
8
+ return entries.join(",");
9
+ const next = [...entries, word].join(",");
10
+ return charLength(next) <= maxChars ? next : null;
11
+ }
12
+ /** Words of the locale's indexed fields (App Store: name, subtitle, keyword field). */
13
+ function indexedWords(project, locale) {
14
+ const l = project.listings["app-store"][locale] ?? {};
15
+ return new Set([...words(l.name), ...words(l.subtitle), ...keywordEntries(l.keywords).flatMap((e) => words(e))]);
16
+ }
17
+ /**
18
+ * Words to add to one App Store locale's keyword field: the missing words of
19
+ * targets in storefronts that index this locale, plus extra candidates (for
20
+ * example keyword opportunities from the Appeeky API). Words already indexed
21
+ * anywhere in the locale are left out.
22
+ *
23
+ * An autocomplete idea only counts when it shares a word with the listing or
24
+ * the targets: "screenshot stitching" is an idea for a screenshot app, "proton
25
+ * vpn" (another product the store's autocomplete returned) is not. Searches
26
+ * the app already ranks for always count.
27
+ */
28
+ export function keywordSuggestions(project, locale, candidates = []) {
29
+ const have = indexedWords(project, locale);
30
+ const field = project.listings["app-store"][locale]?.keywords;
31
+ const out = new Map();
32
+ const add = (word, helps, popularity, target) => {
33
+ if (have.has(word) || word.length < 2 || STOPWORDS.has(word))
34
+ return;
35
+ const s = out.get(word) ?? { word, helps: [], fits: appendKeyword(field, word) !== null, target };
36
+ s.target ||= target;
37
+ if (!s.helps.includes(helps))
38
+ s.helps.push(helps);
39
+ if (popularity !== undefined)
40
+ s.popularity = Math.max(s.popularity ?? 0, popularity);
41
+ out.set(word, s);
42
+ };
43
+ for (const country of Object.keys(project.spec.targets)) {
44
+ if (!indexedLocales(project, country).includes(locale))
45
+ continue;
46
+ for (const k of appStoreCoverage(project, country).keywords) {
47
+ if (!isCovered(k.level))
48
+ for (const w of k.missing)
49
+ add(w, k.keyword, k.popularity, true);
50
+ }
51
+ }
52
+ const related = new Set([...have, ...Object.values(project.spec.targets).flatMap((list) => list.flatMap((t) => words(t)))].filter((w) => !STOPWORDS.has(w)));
53
+ for (const c of candidates) {
54
+ const cw = words(c.keyword);
55
+ if (c.source === "suggestion" && !cw.some((w) => related.has(w)))
56
+ continue;
57
+ for (const w of cw)
58
+ add(w, c.keyword, c.popularity, false);
59
+ }
60
+ return [...out.values()].sort((a, b) => Number(b.fits) - Number(a.fits) ||
61
+ Number(b.target) - Number(a.target) ||
62
+ (b.popularity ?? 0) - (a.popularity ?? 0) ||
63
+ b.helps.length - a.helps.length);
64
+ }
65
+ /**
66
+ * A keyword field written without commas reads as one long phrase. Split it
67
+ * into comma-separated words so each word can combine with others.
68
+ */
69
+ export function splitKeywordPhrase(field) {
70
+ const entries = keywordEntries(field);
71
+ if (entries.length !== 1 || !/\s/.test(entries[0]))
72
+ return null;
73
+ return unique(words(entries[0])).join(",");
74
+ }
75
+ /**
76
+ * Storefronts that index more than one locale (the US reads Spanish (Mexico)
77
+ * too) get extra keyword space for free. Fill the spare characters of the secondary
78
+ * locale with words the storefront's targets still miss.
79
+ */
80
+ export function crossLocalizationSuggestions(project) {
81
+ const out = [];
82
+ for (const country of Object.keys(project.spec.targets)) {
83
+ const present = indexedLocales(project, country);
84
+ // The first locale is the storefront's own; the rest are extra keyword space.
85
+ const secondary = present.slice(1);
86
+ if (secondary.length === 0)
87
+ continue;
88
+ const covered = new Set(present.flatMap((l) => [...indexedWords(project, l)]));
89
+ const missing = unique(appStoreCoverage(project, country)
90
+ .keywords.filter((k) => !isCovered(k.level))
91
+ .flatMap((k) => words(k.keyword))
92
+ .filter((w) => !covered.has(w) && !STOPWORDS.has(w)));
93
+ for (const locale of secondary) {
94
+ const original = project.listings["app-store"][locale]?.keywords ?? "";
95
+ const split = splitKeywordPhrase(original);
96
+ let field = split ?? original;
97
+ const added = [];
98
+ for (const w of missing) {
99
+ if (out.some((s) => s.country === country && s.add.includes(w)))
100
+ continue;
101
+ const next = appendKeyword(field, w);
102
+ if (next === null)
103
+ continue;
104
+ field = next;
105
+ added.push(w);
106
+ }
107
+ if (added.length > 0)
108
+ out.push({ country, locale, add: added, keywords: field, ...(split ? { splitPhrase: true } : {}) });
109
+ }
110
+ }
111
+ return out;
112
+ }
113
+ /** Turn competitor metadata changes into openings for one storefront's targets. */
114
+ export function competitorGaps(project, country, changes, store = "app-store") {
115
+ const targetWords = new Set((project.spec.targets[country] ?? []).flatMap((k) => words(k)));
116
+ if (targetWords.size === 0)
117
+ return [];
118
+ const report = store === "app-store" ? appStoreCoverage(project, country) : googlePlayCoverage(project, country);
119
+ const missing = new Set(report.keywords.flatMap((k) => k.missing));
120
+ const gaps = [];
121
+ for (const ch of changes) {
122
+ // A change with one side missing is not a change we can read words from.
123
+ if (!ch.before || !ch.after)
124
+ continue;
125
+ const before = new Set(words(ch.before ?? ""));
126
+ const after = new Set(words(ch.after ?? ""));
127
+ const dropped = [...before].filter((w) => !after.has(w) && targetWords.has(w));
128
+ const added = [...after].filter((w) => !before.has(w) && targetWords.has(w));
129
+ if (dropped.length === 0 && added.length === 0)
130
+ continue;
131
+ gaps.push({ competitor: ch.competitor, field: ch.field, at: ch.at, dropped, added, openings: dropped.filter((w) => missing.has(w)) });
132
+ }
133
+ return gaps;
134
+ }
135
+ //# sourceMappingURL=suggest.js.map
package/dist/text.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ /** Text helpers shared by the lint rules and the coverage report. */
2
+ /** UTF-8 byte length: the unit Apple uses for the 100-byte keyword field. */
3
+ export declare function byteLength(value: string): number;
4
+ /** Character count as the stores count it (user-perceived characters, not UTF-16 units). */
5
+ export declare function charLength(value: string): number;
6
+ export declare function normalize(value: string): string;
7
+ /** Lowercased words; punctuation and symbols split words, apostrophes inside a word are kept. */
8
+ export declare function words(value: string | undefined): string[];
9
+ /** Entries of an App Store keyword field, as written (trimmed, empty ones dropped). */
10
+ export declare function keywordEntries(field: string | undefined): string[];
11
+ /** Naive English singular form, only used to flag likely plural pairs. */
12
+ export declare function singular(word: string): string;
13
+ export declare function isEnglish(locale: string): boolean;
14
+ export declare function unique<T>(items: Iterable<T>): T[];
15
+ /** Short English words that do not decide a search; ignored when a phrase has other words. */
16
+ export declare const STOPWORDS: Set<string>;
17
+ /** Words of a target that matter for coverage: stopwords drop out unless they are all there is. */
18
+ export declare function keyWords(value: string): string[];