storebound/cli/lib/aso-intel.ts

366 lines
24 KiB
TypeScript

// ASO keyword research from official and public data only, as pure parts. The commands are in cli/aso.ts
// (`aso competitors`, `aso suggest`); the audit report is cli/lib/aso-audit.ts.
//
// Data sources and what each number is (and is not):
// - iTunes Search API (public, no account; https://performance-partners.apple.com/search-api): the top N apps for a
// term in one storefront, with name (trackName), developer, rating count, first release date, last update, genre
// and the genre names localized to that storefront ("genres": "Produktivität" for country=de). Its order is close
// to, not equal to, App Store search. It has no subtitle field, so competitor subtitles are never shown.
// - Apple Ads Platform API, POST /v1/suggestions/keywords/query (only with the owner's Apple Ads credentials):
// keyword suggestions for the app (promotedObjectId = Apple ID, promotedObjectType APPSTORE_APP), seeded with
// `terms` and scoped with `countriesOrRegions`; each KeywordSuggestion is { text, popularity } where popularity is
// "a relative popularity score (not an absolute volume)" on a 0-100 scale. That is the only popularity signal
// storebound shows; without credentials popularity stays "unknown" (never estimated).
// https://developer.apple.com/documentation/apple-ads-platform-api/query-keyword-suggestions
// https://developer.apple.com/documentation/apple-ads-platform-api/keywordsuggestion
// https://developer.apple.com/documentation/apple-ads-platform-api/recommendationqueryrequest (pageSize 1..1000)
// (The Recommendations endpoints are budget / target CPA only; Insights has search term popularity 1-5 by genre,
// not used here.)
// - The app's own listing (store/appstore.md): name, subtitle, keyword field and description.
// Nothing here scrapes a store web page or calls a third-party ASO service.
import { FILLER, words } from './aso';
import type { SearchApp } from './aso-rank';
// ---------------------------------------------------------------------------------------------------------------
// Words
/** Function words that never make a keyword (a few languages; extend as needed). FILLER (app, game, free ...) too. */
const STOP: Record<string, string[]> = {
en: ['to', 'in', 'on', 'at', 'by', 'or', 'is', 'it', 'its', 'you', 'your', 'our', 'we', 'my', 'me', 'all', 'one', 'new', 'more', 'from', 'that', 'this', 'are', 'be', 'can', 'get', 'up', 'out', 'as', 'so', 'no', 'not', 'any', 'every', 'each', 'just', 'into', 'than', 'then', 'also', 'will', 'has', 'have', 'only', 'when', 'what', 'who', 'how', 'way', 'like', 'use', 'make', 'keep', 'see'],
de: ['der', 'die', 'das', 'und', 'mit', 'für', 'von', 'zu', 'ein', 'eine', 'ist', 'im', 'in', 'auf', 'den', 'dem', 'des', 'sie', 'du', 'dein', 'deine', 'alle', 'oder', 'auch'],
fr: ['le', 'la', 'les', 'et', 'de', 'des', 'du', 'un', 'une', 'pour', 'avec', 'en', 'au', 'aux', 'vos', 'votre', 'est', 'dans', 'sur', 'ou', 'tout', 'tous'],
es: ['el', 'la', 'los', 'las', 'y', 'de', 'del', 'un', 'una', 'para', 'con', 'en', 'tu', 'tus', 'es', 'por', 'o', 'que', 'todo', 'todos'],
pt: ['o', 'a', 'os', 'as', 'e', 'de', 'do', 'da', 'um', 'uma', 'para', 'com', 'em', 'seu', 'sua', 'no', 'na', 'ou', 'que', 'todo', 'todos'],
it: ['il', 'lo', 'la', 'le', 'gli', 'e', 'di', 'del', 'un', 'una', 'per', 'con', 'in', 'tuo', 'tua', 'che', 'tutti'],
tr: ['ve', 'ile', 'için', 'göre', 'kadar', 'sonra', 'önce', 'olan', 'olarak', 'bir', 'bu', 'şu', 'da', 'de', 'mi', 'en', 'çok', 'her', 'tüm', 'gibi', 'daha', 'sen', 'senin'],
};
/** Words of listing boilerplate (account, settings, privacy ...): they describe the store page, not the app's job. */
const BOILER = new Set(['account', 'accounts', 'settings', 'sign', 'signin', 'login', 'delete', 'privacy', 'policy', 'terms', 'support', 'contact', 'feature', 'features',
'update', 'updates', 'version', 'subscription', 'purchase', 'purchases', 'time', 'any', 'back', 'between', 'off', 'next', 'keeps', 'keep', 'item', 'items',
'change', 'changes', 'show', 'shows', 'work', 'works', 'people', 'phone', 'phones', 'here', 'there', 'about', 'want', 'need', 'help', 'day', 'days', 'even', 'much', 'many']);
const lang = (locale: string): string => locale.split('-')[0]!.toLowerCase();
export const isStop = (w: string, locale: string): boolean => FILLER.has(w) || BOILER.has(w) || (STOP[lang(locale)] ?? []).includes(w) || STOP.en!.includes(w) || /^\d+$/.test(w);
/** "Ayarlar'dan" -> "Ayarlar", "it's" -> "it": the suffix after an apostrophe is not a word of its own. */
const unApostrophe = (s: string): string => s.replace(/['’]\p{L}+/gu, '');
/** Content words of a text (lowercased for the locale, function words, boilerplate and numbers left out). Pure. */
export const tokens = (s: string, locale: string): string[] => words(unApostrophe(s), locale).filter(w => !isStop(w, locale));
/** Two-word phrases of content words that stand next to each other in the text. Pure. */
export function bigrams(s: string, locale: string): string[] {
const out: string[] = [];
for (const seg of unApostrophe(s).split(/[.!?\n:;,()"“”«»•·|/–—-]+/)) {
const ws = words(seg, locale);
for (let i = 0; i + 1 < ws.length; i++) if (!isStop(ws[i]!, locale) && !isStop(ws[i + 1]!, locale)) out.push(`${ws[i]} ${ws[i + 1]}`);
}
return out;
}
/** Plural-insensitive key: languages whose plural is mostly "-s" fold "lists" and "list" together. Pure. */
export function stem(w: string, locale: string): string {
const l = lang(locale);
if (l === 'en' && w.length > 4 && w.endsWith('ies')) return w.slice(0, -3) + 'y'; // groceries -> grocery
return ['en', 'fr', 'es', 'pt'].includes(l) && w.length > 3 && w.endsWith('s') && !w.endsWith('ss') ? w.slice(0, -1) : w;
}
/** Languages that add suffixes to a word ("listeler", "listelerini"): one word starts with the other. */
const SUFFIXING = new Set(['tr', 'fi', 'hu', 'et', 'az', 'kk', 'uz']);
/** The same keyword for search: plural-insensitive, and in suffixing languages a word that only adds a short suffix
* (up to 5 letters) to a word of at least 4. A heuristic: it errs on the side of not repeating a word. Pure. */
export function sameWord(a: string, b: string, locale: string): boolean {
const x = stem(a, locale), y = stem(b, locale);
if (x === y) return true;
if (!SUFFIXING.has(lang(locale))) return false;
const [s, l] = x.length <= y.length ? [x, y] : [y, x];
return s.length >= 4 && l.startsWith(s) && l.length - s.length <= 5;
}
const coveredBy = (w: string, set: Iterable<string>, locale: string): boolean => { for (const s of set) if (sameWord(w, s, locale)) return true; return false; };
// ---------------------------------------------------------------------------------------------------------------
// Competitors (iTunes Search API)
/** App Store Connect category id -> iTunes genre id (the "genreIds" of Search API results). Apple's genre ids. */
export const GENRE_IDS: Record<string, string> = {
BOOKS: '6018', BUSINESS: '6000', DEVELOPER_TOOLS: '6026', EDUCATION: '6017', ENTERTAINMENT: '6016', FINANCE: '6015', FOOD_AND_DRINK: '6023', GAMES: '6014',
GRAPHICS_AND_DESIGN: '6027', HEALTH_AND_FITNESS: '6013', LIFESTYLE: '6012', MAGAZINES_AND_NEWSPAPERS: '6021', MEDICAL: '6020', MUSIC: '6011', NAVIGATION: '6010',
NEWS: '6009', PHOTO_AND_VIDEO: '6008', PRODUCTIVITY: '6007', REFERENCE: '6006', SHOPPING: '6024', SOCIAL_NETWORKING: '6005', SPORTS: '6004', STICKERS: '6025',
TRAVEL: '6003', UTILITIES: '6002', WEATHER: '6001',
GAMES_ACTION: '7001', GAMES_ADVENTURE: '7002', GAMES_CASUAL: '7003', GAMES_BOARD: '7004', GAMES_CARD: '7005', GAMES_CASINO: '7006', GAMES_FAMILY: '7009',
GAMES_MUSIC: '7011', GAMES_PUZZLE: '7012', GAMES_RACING: '7013', GAMES_ROLE_PLAYING: '7014', GAMES_SIMULATION: '7015', GAMES_SPORTS: '7016',
GAMES_STRATEGY: '7017', GAMES_TRIVIA: '7018', GAMES_WORD: '7019',
};
export interface CompetitorApp {
rank: number; id: string; name: string; developer: string; ratings: number; rating: number | null;
/** First release (releaseDate) and the current version's release (currentVersionReleaseDate), YYYY-MM-DD. */
released: string | null; updated: string | null;
genre: string;
/** Genre ids with their names localized to the storefront. */
genres: { id: string; name: string }[];
}
export interface CompetitorRow { storefront: string; locale: string; term: string; results: number; ourRank: number | null; apps: CompetitorApp[] }
export interface CompetitorSnapshot { date: string; app: { appId: string | null; bundleId: string }; top: number; rows: CompetitorRow[] }
const isUs = (r: SearchApp, app: { appId?: string; bundleId?: string }) => !!((app.appId && String(r.trackId) === app.appId) || (app.bundleId && r.bundleId === app.bundleId));
/** One term's Search API results -> the top n other apps (and our own position among the results read). Pure. */
export function competitorRow(results: SearchApp[], app: { appId?: string; bundleId?: string }, q: { storefront: string; locale: string; term: string }, n: number): CompetitorRow {
const i = results.findIndex(r => isUs(r, app));
const apps = results.map((r, k) => ({ r, k })).filter(({ r }) => !isUs(r, app)).slice(0, n).map(({ r, k }): CompetitorApp => ({
rank: k + 1, id: String(r.trackId), name: r.trackName, developer: r.sellerName ?? '', ratings: r.userRatingCount ?? 0, rating: r.averageUserRating ?? null,
released: r.releaseDate?.slice(0, 10) ?? null, updated: r.currentVersionReleaseDate?.slice(0, 10) ?? null, genre: r.primaryGenreName ?? '',
genres: (r.genreIds ?? []).map((id, j) => ({ id: String(id), name: r.genres?.[j] ?? '' })).filter(g => g.name),
}));
return { ...q, results: results.length, ourRank: i >= 0 ? i + 1 : null, apps };
}
/** Distinct apps of the rows (one storefront / locale), first seen first. Pure. */
export function distinctApps(rows: CompetitorRow[]): CompetitorApp[] {
const seen = new Map<string, CompetitorApp>();
for (const r of rows) for (const a of r.apps) if (!seen.has(a.id)) seen.set(a.id, a);
return [...seen.values()];
}
export interface Gap { word: string; apps: number; developers: number; terms: string[]; examples: string[] }
/**
* Words in competitors' names that our name, subtitle and keyword field do not cover. A word only counts when apps of at
* least two different developers use it: a word one developer uses is most likely a brand (and other apps' names and
* trademarks must never go into a keyword field). Pure.
*/
export function coverageGaps(rows: CompetitorRow[], covered: Iterable<string>, locale: string, max = 15): Gap[] {
const have = [...covered];
const by = new Map<string, { apps: Set<string>; devs: Set<string>; terms: Set<string>; examples: string[] }>();
for (const r of rows) for (const a of r.apps) for (const w of new Set(tokens(a.name, locale))) {
if (w.length < 3 || coveredBy(w, have, locale)) continue;
const k = stem(w, locale);
const g = by.get(k) ?? { apps: new Set(), devs: new Set(), terms: new Set(), examples: [] };
if (!g.apps.has(a.id) && g.examples.length < 3) g.examples.push(a.name);
g.apps.add(a.id); g.devs.add(a.developer.toLowerCase()); g.terms.add(r.term);
by.set(k, g);
}
return [...by.entries()].filter(([, g]) => g.devs.size >= 2)
.map(([word, g]) => ({ word, apps: g.apps.size, developers: g.devs.size, terms: [...g.terms], examples: g.examples }))
.sort((a, b) => b.developers - a.developers || b.apps - a.apps || a.word.localeCompare(b.word)).slice(0, max);
}
/** Apps in the top N now that were not there in the previous snapshot, per storefront + term. Pure. */
export function newEntrants(now: CompetitorRow[], before: CompetitorRow[] | undefined): Map<string, string[]> {
const out = new Map<string, string[]>();
if (!before) return out;
const prev = new Map(before.map(r => [`${r.storefront}|${r.term.toLowerCase()}`, new Set(r.apps.map(a => a.id))] as const));
for (const r of now) {
const p = prev.get(`${r.storefront}|${r.term.toLowerCase()}`);
if (p) out.set(`${r.storefront}|${r.term.toLowerCase()}`, r.apps.filter(a => !p.has(a.id)).map(a => a.name));
}
return out;
}
/** The category names of the app in one storefront's language, read from the Search API results' localized genres. Pure. */
export function categoryNames(rows: CompetitorRow[], categoryIds: string[]): string[] {
const ids = new Set(categoryIds.map(c => GENRE_IDS[c]).filter(Boolean));
const names = new Set<string>();
for (const a of distinctApps(rows)) for (const g of a.genres) if (ids.has(g.id)) names.add(g.name);
return [...names];
}
// ---------------------------------------------------------------------------------------------------------------
// Apple Ads keyword suggestions
export const ADS_KEYWORD_SUGGESTIONS = '/suggestions/keywords/query';
/** RecommendationQueryRequest for keyword suggestions: the two required filters, seed terms, countries. Pure. */
export function keywordSuggestionBody(adamId: string, terms: string[], countries: string[], pageSize = 200): Record<string, unknown> {
const filters: Record<string, unknown>[] = [
{ field: 'promotedObjectId', operator: 'EQUALS', value: [adamId] },
{ field: 'promotedObjectType', operator: 'EQUALS', value: ['APPSTORE_APP'] },
];
if (terms.length) filters.push({ field: 'terms', operator: 'IN', value: terms });
if (countries.length) filters.push({ field: 'countriesOrRegions', operator: 'IN', value: countries.map(c => c.toUpperCase()) });
return { filters, sorting: [{ field: 'popularity', order: 'DESC' }], pagination: { offset: 0, pageSize: Math.min(1000, Math.max(1, pageSize)) } };
}
export interface AdsSuggestion { text: string; popularity: number }
/** `result` of the keyword suggestions answer -> { text, popularity } (0-100), invalid rows left out. Pure. */
export function parseKeywordSuggestions(res: unknown): AdsSuggestion[] {
const rows = (res as { result?: unknown })?.result;
if (!Array.isArray(rows)) return [];
return rows.filter((r): r is { text: string; popularity: number } => typeof r?.text === 'string' && typeof r?.popularity === 'number')
.map(r => ({ text: r.text.trim(), popularity: Math.max(0, Math.min(100, Math.round(r.popularity))) })).filter(r => r.text);
}
// ---------------------------------------------------------------------------------------------------------------
// Candidates and scoring
export type CandSource = 'name' | 'subtitle' | 'keywords' | 'description' | 'competitors' | 'category' | 'apple-ads';
export type CandStatus = 'candidate' | 'indexed' | 'category' | 'filler';
export interface Candidate {
term: string;
kind: 'word' | 'phrase';
sources: CandSource[];
/** Times in our own name / subtitle / description. */
ours: number;
/** relevance 0-2: +1 in our own text (name, subtitle, description, keywords), +1 in competitor names (the top apps). */
relevance: number;
/** Competition: how many of the distinct top apps already have it in their name, of how many read. */
inNames: number; ofApps: number;
/** Apple Ads relative popularity 0-100, null = unknown (no credentials, or Apple returned nothing for it). */
popularity: number | null;
/** exact: Apple Ads suggested this exact word; phrase: the highest score of a suggested phrase that contains it. */
popularityFrom: 'exact' | 'phrase' | null;
status: CandStatus;
why?: string;
}
export interface CandidateInput {
locale: string;
name: string; subtitle: string; keywords: string; description: string;
/** The competitor rows of this locale's storefront (latest `aso competitors` snapshot), may be empty. */
competitors: CompetitorRow[];
/** Category names in this language (categoryNames); matched by Apple anyway, so never put into the field. */
categories: string[];
ads: AdsSuggestion[];
}
/** Every keyword candidate of one locale with its signals. Sorted: candidates first, by relevance, popularity (known
* first), lower competition, then how often our own text uses it. Pure. */
export function candidates(i: CandidateInput): Candidate[] {
const L = i.locale;
const m = new Map<string, Candidate>();
const key = (t: string) => t.split(' ').map(w => stem(w, L)).join(' ');
const add = (term: string, kind: Candidate['kind'], src: CandSource, n = 1) => {
const k = key(term);
const c = m.get(k) ?? { term, kind, sources: [], ours: 0, relevance: 0, inNames: 0, ofApps: 0, popularity: null, popularityFrom: null, status: 'candidate' as CandStatus };
if (!c.sources.includes(src)) c.sources.push(src);
if (src === 'name' || src === 'subtitle' || src === 'description') c.ours += n;
m.set(k, c);
};
for (const [src, text] of [['name', i.name], ['subtitle', i.subtitle], ['description', i.description]] as const) for (const w of tokens(text, L)) if (w.length > 2) add(w, 'word', src);
for (const w of i.keywords.split(',').flatMap(t => tokens(t, L))) if (w.length > 1) add(w, 'word', 'keywords');
// phrases: only the ones our own text uses at least twice, or that stand in the name / subtitle
const phraseCount = new Map<string, number>();
for (const b of [...bigrams(i.name, L), ...bigrams(i.subtitle, L), ...bigrams(i.subtitle, L), ...bigrams(i.description, L)]) phraseCount.set(b, (phraseCount.get(b) ?? 0) + 1);
for (const [b, n] of phraseCount) if (n >= 2) add(b, 'phrase', 'description', n);
const apps = distinctApps(i.competitors);
const nameWords = apps.map(a => new Set(tokens(a.name, L).map(w => stem(w, L))));
for (const g of coverageGaps(i.competitors, [], L, 200)) add(g.word, 'word', 'competitors');
const catWords = [...new Set(i.categories.flatMap(c => words(c, L)))];
const cats = new Set(catWords.map(w => stem(w, L)));
for (const c of catWords) add(c, 'word', 'category');
for (const s of i.ads) {
const ws = tokens(s.text, L);
if (ws.length === 1) add(ws[0]!, 'word', 'apple-ads');
}
const indexed = [...tokens(i.name, L), ...tokens(i.subtitle, L)];
for (const c of m.values()) {
const ws = c.term.split(' ').map(w => stem(w, L));
const inOurs = c.ours > 0 || c.sources.includes('keywords');
c.inNames = nameWords.filter(n => ws.every(w => n.has(w))).length;
c.ofApps = apps.length;
c.relevance = (inOurs ? 1 : 0) + (c.inNames > 0 ? 1 : 0);
const exact = i.ads.find(s => key(s.text.toLocaleLowerCase(L)) === key(c.term));
const phrases = i.ads.filter(s => ws.every(w => tokens(s.text, L).map(x => stem(x, L)).includes(w)));
if (exact) { c.popularity = exact.popularity; c.popularityFrom = 'exact'; }
else if (phrases.length) { c.popularity = Math.max(...phrases.map(s => s.popularity)); c.popularityFrom = 'phrase'; }
if (ws.some(w => cats.has(w)) && ws.length === 1) { c.status = 'category'; c.why = 'category name: matched by Apple anyway'; }
else if (ws.every(w => coveredBy(w, indexed, L))) { c.status = 'indexed'; c.why = 'already in the name / subtitle (indexed)'; }
else if (ws.some(w => FILLER.has(w))) { c.status = 'filler'; c.why = 'filler word'; }
}
const order = (c: Candidate) => (c.status === 'candidate' ? 0 : 1);
// a word only the description uses once is noise, unless competitors or Apple Ads bring it up too
const weak = (c: Candidate) => c.sources.length === 1 && c.sources[0] === 'description' && c.ours < 2;
return [...m.values()].filter(c => !weak(c)).sort((a, b) => order(a) - order(b) || b.relevance - a.relevance || (b.popularity ?? -1) - (a.popularity ?? -1)
|| a.inNames - b.inNames || b.ours - a.ours || a.term.localeCompare(b.term));
}
// ---------------------------------------------------------------------------------------------------------------
// The 100-byte keyword field
export interface FieldProposal { locale: string; before: string; after: string; chars: number; kept: string[]; added: string[]; dropped: { term: string; why: string }[] }
const chars = (s: string) => [...s].length;
/**
* A keyword field for one locale that fits 100 characters (App Store Connect counts characters, not bytes) with the existing rules: commas without spaces, no
* duplicates (plural-insensitive), no words of the name / subtitle, no filler or category words, single words. Current
* terms that are still valid are kept first (change a few terms per release, not all), unless `fresh`; then the best
* candidates fill the room. Pure.
*/
export function fitKeywordField(o: { locale: string; current: string; name: string; subtitle: string; ranked: Candidate[]; fresh?: boolean; limit?: number }): FieldProposal {
const L = o.locale, limit = o.limit ?? 100;
const indexed = [...tokens(o.name, L), ...tokens(o.subtitle, L)];
const blocked = new Map(o.ranked.filter(c => c.status !== 'candidate' && c.kind === 'word').map(c => [stem(c.term, L), c.why ?? c.status] as const));
const out: string[] = [], kept: string[] = [], added: string[] = [], dropped: { term: string; why: string }[] = [];
const seen: string[] = [];
const fits = (t: string) => chars([...out, t].join(',')) <= limit;
const tryAdd = (t: string, into: string[]): string | null => {
const ws = words(t, L);
if (!ws.length) return 'empty';
if (ws.every(w => coveredBy(w, indexed, L))) return 'already in the name / subtitle (indexed)';
if (ws.some(w => FILLER.has(w))) return 'filler word';
if (ws.length === 1 ? coveredBy(ws[0]!, seen, L) : seen.includes(ws.join(' '))) return 'duplicate';
const b = ws.length === 1 ? blocked.get(stem(ws[0]!, L)) : undefined;
if (b) return b;
if (!fits(t)) return 'no room in 100 characters';
seen.push(ws.length === 1 ? ws[0]! : ws.join(' ')); out.push(t); into.push(t);
return null;
};
if (!o.fresh) for (const t of o.current.split(',').map(s => s.trim().toLocaleLowerCase(L)).filter(Boolean)) {
// keep a current phrase as the words it is made of: single words combine on their own and save characters
const parts = /\s/.test(t) ? tokens(t, L) : [t];
for (const p of parts) { const why = tryAdd(p, kept); if (why && why !== 'duplicate') dropped.push({ term: p, why }); }
}
for (const c of o.ranked) {
if (c.status !== 'candidate' || c.kind !== 'word') continue;
tryAdd(c.term, added);
}
const after = out.join(',');
return { locale: L, before: o.current, after, chars: chars(after), kept, added, dropped };
}
// ---------------------------------------------------------------------------------------------------------------
// Writing the proposal into store/appstore.md (only with --write; marked as a draft for review)
export const DRAFT_RE = /^<!--\s*storebound-aso:\s*draft\b[^>]*-->\s*$/m;
export const draftMark = (date: string): string => `<!-- storebound-aso: draft keywords from \`aso suggest\` ${date}; review them, then delete this line -->`;
/** Locales of store/appstore.md whose keywords carry the draft line. Pure. */
export function draftLocales(md: string): string[] {
const out: string[] = [];
for (const sec of md.split(/^(?=## )/m)) {
if (!sec.startsWith('## ')) continue;
const kw = sec.split(/^(?=### )/m).find(p => /^### keywords\s*$/m.test(p.split('\n')[0]!));
if (kw && DRAFT_RE.test(kw)) out.push(sec.slice(3).split(/[\s·]/)[0]!.trim());
}
return out;
}
/** store/appstore.md with the keywords block of `locale` set to `value` and the draft line above it. Pure. */
export function setKeywords(md: string, locale: string, value: string, mark: string): string {
const secs = md.split(/^(?=## )/m);
const i = secs.findIndex(s => s.startsWith('## ') && s.slice(3).split(/[\s·]/)[0]!.trim() === locale);
if (i < 0) throw new Error(`no "## ${locale}" section in the App Store listing`);
const parts = secs[i]!.split(/^(?=### )/m);
const block = `### keywords\n${mark}\n\`\`\`\n${value}\n\`\`\`\n`;
const k = parts.findIndex(p => /^### keywords\s*$/.test(p.split('\n')[0]!));
if (k >= 0) {
const old = parts[k]!;
const fence = /^```[^\n]*\n[\s\S]*?\n```[^\n]*\n?/m.exec(old);
const tail = fence ? old.slice(fence.index + fence[0].length) : old.split('\n').slice(1).join('\n');
parts[k] = block + tail.replace(DRAFT_RE, '').replace(/^\n+/, '\n');
if (!/\n$/.test(parts[k]!)) parts[k] += '\n';
} else {
const last = parts.length - 1;
parts[last] = parts[last]!.replace(/\n*$/, '\n\n') + block + (i < secs.length - 1 ? '\n' : '');
}
secs[i] = parts.join('');
return secs.join('');
}
/** "us" for en-US, "tr" for tr, "jp" for ja ... : the storefront a locale is most likely meant for. Pure. */
export function storefrontOf(locale: string): string {
const [l, r] = locale.split('-');
if (r && /^[A-Za-z]{2}$/.test(r)) return r.toLowerCase();
const map: Record<string, string> = { ja: 'jp', ko: 'kr', zh: locale === 'zh-Hant' ? 'tw' : 'cn', el: 'gr', he: 'il', hi: 'in', uk: 'ua', vi: 'vn', cs: 'cz', da: 'dk', sv: 'se', nb: 'no', ms: 'my', ca: 'es', en: 'us', ar: 'sa' };
return map[l!.toLowerCase()] ?? l!.toLowerCase();
}