One implementation of an App Store companion site, in eleven languages.
A small app on the store needs the same pages every time: what it is, support, a privacy policy, terms, and — for a German operator — an Impressum. In eleven languages that is 45 files. Writing them twice is how the second app ends up wearing the first app's navigation, and how a fix to the checker lands on one app and rots on the others.
This package holds the structure. Each app holds its own words.
| Here (shared) | In the app (owned) |
|---|---|
Navigation, language switcher, hreflang |
The app's name, brand markup, icon |
The document: <head>, assets, <main> |
Which pages exist, and any page the kit does not know about |
| Page blocks: hero, cards, showcase, note, lockup, stats | Which blocks, in what order, with what copy |
| Legal-page structure and the standard Apple links | Every sentence of the privacy policy and terms |
| The stylesheet | Its palette, as token overrides |
| The checker, and App Store Connect's field limits | What the listing is required to point at |
| The index of every app, at the site root | The card it contributes to it |
The split is not arbitrary. Structure is single-sourced because a bug in it is one bug. Prose is owned because a sentence that has to change for one app must not change for the others — a privacy policy is a promise about a particular program, and sharing the text would eventually make one of the promises false.
Add the kit as a submodule and write one config file:
git submodule add https://github.com/ikunin/appsite.git vendor/appsite
mkdir appstore && cp vendor/appsite/template/* appstore/vendor/appsite and appstore/, in every repository, exactly. Both
lowercase: Tools/ and tools/ are the same directory on a case-insensitive
Mac and two different ones on Linux CI, a trap worth stepping around once
rather than debugging per repo. And one name for the config everywhere means a
recipe written here runs in any of these repositories unedited — three names
for the same directory is a lookup table the docs have to carry forever.
site_config.py finds the kit by walking up to it, so nothing breaks if a
repository does put it elsewhere. It should not.
site_config.py is the whole interface:
SITE = Site(
chrome=Chrome(brand="Harbor Rush", icon="img/icon-512.png",
pages=PAGES, copyright="© Igor Kunin 2026"),
out="site",
metadata=os.path.join("fastlane", "metadata"),
palette={"accent": "#1ec8b6"},
)Then, in the app's Makefile:
site:
python3 appstore/install_site_assets.py
python3 appstore/make_site_translations.py
python3 appstore/make_site_legal.py
python3 appstore/make_site_impressum.py
python3 appstore/make_site_card.py
python3 appstore/check_site.pyOrder matters at both ends: install_site_assets.py writes the stylesheet the
pages reference, and check_site.py is the gate. An app with a page the kit does
not know about adds its own generator between them.
check_site.py also measures every listing field against what App Store Connect
accepts — name and subtitle at 30 characters, keywords at 100, promotional text
at 170, descriptions and release notes at 4000. Connect rejects rather than
truncates, and it does so at upload, so without this the first you hear of one
extra character is a failed submission of a build that has already been made.
Measured in every locale, because a translation is longer than its source more
often than not: the overrun that prompted this was French, at 31 against 30.
The landing page's headline and opening paragraph are read from
metadata/<locale>/subtitle.txt and description.txt — fastlane's layout,
which App Store Connect's own tools also expect. Those words are already
translated and already reviewed, and they are what a customer meets before they
ever reach the site. Translating the same pitch twice guarantees the two drift.
One Pages site serves every app, a directory each, and its root lists them:
ikunin.github.io/apps/. That page is not a list anybody keeps up to date.
make_site_card.py writes site/app.json — the app's name, the App Store
subtitle it already ships, its icon and its accent, every one of them read from
site_config.py or the listing. Publishing copies site/ wholesale, so the
card lands on the branch with the pages, and publish.py rebuilds the index
from every card it finds there.
An app is on that page because it published. A fourth app needs no edit in this repository at all, and an app whose card is missing has simply not been rebuilt since this existed.
A card is an icon, a name and one slogan. Nothing on that page describes the
apps as a group — they differ in what they collect, and a sentence true of
two of them is a false statement about the third. Its own wording lives in
portfolio_config.py.
python3 vendor/appsite/publish.py --index-only # rebuild just the rootlanguages.py carries what is the same for every app: language names,
navigation labels for the five standard pages, and the governing-language
clause. A page kind the kit does not know needs its own labels on the Page —
that is the seam that keeps "Songs" out of a site kit.
Every translated privacy and terms page ends with a line saying the English version governs, linking back to it. A promise that reads differently in two languages is a legal problem; this resolves it in the open rather than hiding it.
A starting point to copy into an app, not a dependency. It holds every script
the recipe above runs, so a new app's make site works before a word of its own
copy exists — and then refuses to finish, naming what is missing.
| File | What you do with it |
|---|---|
site_config.py |
Write it: brand, palette, Impressum, which pages exist |
site_text_privacy.py, _support.py, _terms.py |
Rewrite the app-specific keys. Each file's header lists them |
make_site_translations.py |
Write it: the landing page below the hero, in eleven languages |
install_site_assets.py, make_site_legal.py, make_site_impressum.py, make_site_card.py, check_site.py |
Nothing. Copy and leave alone |
The legal text tables come from a real shipped app, so most of the eleven-language work is already done — but each file's header lists the keys that describe that app and must be rewritten. Read them. A privacy policy inherited without reading is worse than no policy, and it is a promise about a program it was not written for.
make_site_translations.py carries English only, on purpose: there is nothing to
inherit for a landing page, and a build that stops with no landing copy for: de,
fr, … is a better instruction than ten paragraphs about another app.
python3 tests/test_appsite.pyThe kit's own gate, when it was extracted, was that it re-rendered a shipped
46-page site byte-identically. That is the check to repeat after changing
anything here: build the app's site, git diff, and expect nothing.