Generic Astro template that turns an appindex.json publication document
into a localized landing page (or a localized catalog of landing pages),
with platform-specific content (iOS / Android), screenshots, permissions,
and per-locale store badges.
appindex.json is expected to conform to the App Fair appindex
schema — JSON Schema at
https://appfair.org/schemas/appindex/v1.json.
The template lives in a vendored site/appland/ directory next to a
small site/siteinfo.yaml config and the site/appindex.json data file:
your-repo/
└── site/
├── siteinfo.yaml # template config (title, host, theme, …)
├── appindex.json # generated by your release pipeline
└── appland/ # this template, dropped in unmodified
├── src/
└── …
At build time the template reads ../siteinfo.yaml, follows the
appindex field to find appindex.json, and generates the static site
into site/appland/dist/.
For a CI example, netskip.app is built from
https://github.com/Net-Skip/Net-Skip/releases/latest/download/appindex.json
via this workflow.
The template auto-selects between two render modes based on the number of
entries in appindex.json's apps[] array:
The classic per-app landing page, e.g. https://netskip.app. Generated routes:
| Route | Renders |
|---|---|
/ |
Default-locale landing page, with browser-language auto-redirect to /<locale>/. |
/<locale>/ |
One per locale present in appindex.json. |
/sitemap-*.xml |
Generated by @astrojs/sitemap. |
/robots.txt |
Dynamic. |
Catalog mode, e.g. https://appfair.net aggregating every App Fair app into one site. Generated routes:
| Route | Renders |
|---|---|
/ |
Default-locale catalog index, with auto-redirect. |
/<locale>/ |
Catalog index in that locale (one card per app). |
/<locale>/apps/<slug>/ |
Per-app landing page, identical to single-app mode but with the language-picker hopping to the same app in other locales. |
In multi-app mode the locale list is the union of every app's locales
— each app falls back gracefully when rendered in a locale it doesn't
have content for (the existing xx-YY → xx → en-US → en → first
resolution applies). Slugs come from each app's name field, which by
convention matches the GitHub repo name.
appindex.jsonis the single source of truth for app content; the template contains no app-specific assumptions.- Locales are discovered dynamically by walking every localized field in
the document. The default locale is
en-USif present, thenen, then whatever appears first. - The platform picker hides itself when only one platform is published.
- Asset URLs are resolved against each app's
source.assets, so screenshots and icons load from wherever the publishing pipeline puts them. - Store badges are localized:
<locale>versions ship underpublic/badges/<locale>/{apple-app-store,google-play-store}.svg. The set of locales mirrors skipstone'snormalizeLocaleApple()andnormalizeLocaleGoogle()outputs (so an appindex written by skipstone always finds a matching badge); per-store fallback is exact → language-only →enso URLs never 404. - App descriptions and release notes can carry the small subset of HTML
Google Play permits (
<b>,<i>,<u>,<br>,<a>); a whitelist-based sanitizer renders those as real markup and escapes everything else as visible text. Plain-text Apple-style descriptions pass through unchanged.
cd site/appland
npm install
npm run dev # http://localhost:4321npm run build # outputs to site/appland/dist/
npm run preview # local preview of the built site
npm run check # astro check (TypeScript + Astro diagnostics)Edit site/siteinfo.yaml. Recognized fields:
| Field | Required | Description |
|---|---|---|
title † |
yes | Site name (used in <title>, OpenGraph, header, multi-app catalog hero). |
host |
yes | Canonical site URL (sitemap, OpenGraph, canonical). |
appindex |
yes | Path to the publication document, relative to siteinfo.yaml. |
tagline † |
Subtitle below the title in the multi-app catalog hero, and <meta description> fallback. |
|
accentColor |
Brand accent (any CSS colour). Defaults to #3B82F6. |
|
defaultTheme |
light | dark | system. Defaults to system. |
|
defaultPlatform |
ios | android. Defaults to ios. Hidden when only one platform is published. |
|
showSourceLink |
Show source-repo link in the footer. Default true. |
|
showStoreBadges |
Show App Store / Play Store badges. Default true. |
|
showPermissions |
Show the permissions section. Default true. |
|
showDependencyCount |
Show the SBOM dependency count. Default true. |
|
footer † |
Footer line; {year} is interpolated; the description sanitizer's HTML subset (notably <a>) is supported. |
|
analyticsScript |
Optional analytics script URL. | |
analyticsDomain |
data-domain attribute for the analytics script. |
|
socialImage |
OpenGraph card image. Falls back to feature-graphic, then app icon. | |
pagefind |
When true, runs Pagefind over the built site and adds a search bar to the header. Default false. |
† Localizable. Fields marked † accept either a plain string or a locale-keyed map, so e.g.
title:
en: 'The App Fair Project'
de: 'Das App-Fair-Projekt'
zh-Hans: 'App Fair 项目'renders the right text in each locale's page. Lookup uses the same
exact → language-only → en-US → en → first fallback as the appindex's
own localized fields, so partial coverage is fine.
-
scripts/download-badges.mjs— refreshes the localized App Store / Play Store SVG badges underpublic/badges/<locale>/. Run when the upstream badge artwork changes.node scripts/download-badges.mjs
-
scripts/release-bump.sh— see below.
Sites consume the template by checking it out at a tag in their build workflow, e.g.:
- uses: actions/checkout@v6
with:
repository: appfair/appland
ref: v1 # floating major-version tag
path: site/applandTo cut a new release of the template, use scripts/release-bump.sh from
the repo root. It bumps the latest v<major>.<minor>.<patch> tag,
creates a GitHub release with auto-generated notes, and force-updates
the floating v<major> tag so consumers pinning to v1 immediately
pick up the release.
scripts/release-bump.sh # patch (default): v1.2.3 → v1.2.4
scripts/release-bump.sh --bump=minor # v1.2.3 → v1.3.0
scripts/release-bump.sh --bump major # v1.2.3 → v2.0.0If no v*.*.* tag exists yet, the script treats the current version as
v1.0.0 (so the first patch becomes v1.0.1, first minor v1.1.0,
first major v2.0.0).
After computing the next version, the script shows the repo, branch, HEAD commit, and prompts:
Do you want to tag and release v1.2.4 and update the v1 tag? [y/N]
On y it runs:
git tag -a v1.2.4 -m "Release v1.2.4"git push origin v1.2.4gh release create v1.2.4 --generate-notes --title v1.2.4git tag -f v1 v1.2.4git push --force origin v1
Pre-flight checks: working tree must be clean, gh must be on PATH,
remote tags are fetched first, and the script aborts if the computed tag
already exists locally. Each step is echoed before running so the
operation is auditable from the terminal.