From 2c5a373ae32e566660081833352e2af73893b52b Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 5 Aug 2026 09:41:57 +0900 Subject: [PATCH 1/3] feat(docs): add "Who uses OpenIAP?" app showcase Turn the empty showcase placeholder into a real app list driven by a single JSON file, so submissions land as a one-entry pull request. - Add packages/docs/showcase-apps.json as the list SSOT, seeded with the four apps submitted through issue #280 and the announcement thread - Render app cards on the home page (top 5 plus an inline submit card) and add a /showcase route listing every app with the submission requirements - Order apps by combined App Store and Google Play review count, falling back to Play installs. Apple reports userRatingCount per storefront and publishes no global total, so refresh-showcase-metrics.mjs sums ~170 storefronts and keeps previous numbers rather than writing a partial sweep - Normalize app icons to 256px webp with a shared rounded mask so store artwork with and without built-in corners renders identically - Link the library names in the section subtitle to their GitHub folders and style anchors inside .section-subtitle so they read as links - Document the flow in SHOWCASE.md and add the add-showcase-app skill for Codex and Claude Code Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 11 + .claude/skills/add-showcase-app/SKILL.md | 26 +++ .codex/skills/add-showcase-app/SKILL.md | 148 ++++++++++++ bun.lock | 8 +- packages/docs/SHOWCASE.md | 78 +++++++ packages/docs/package.json | 1 + packages/docs/public/showcase/loader.webp | Bin 0 -> 3590 bytes packages/docs/public/showcase/martie.webp | Bin 0 -> 12194 bytes packages/docs/public/showcase/recallai.webp | Bin 0 -> 3450 bytes .../docs/public/showcase/sudoku-rabbit.webp | Bin 0 -> 5830 bytes .../docs/scripts/refresh-showcase-metrics.mjs | 221 ++++++++++++++++++ packages/docs/showcase-apps.json | 43 ++++ packages/docs/src/App.tsx | 2 + .../docs/src/components/ShowcaseCards.tsx | 180 ++++++++++++++ packages/docs/src/lib/showcase.ts | 69 ++++++ packages/docs/src/pages/home.tsx | 89 +++---- packages/docs/src/pages/showcase.tsx | 121 ++++++++++ packages/docs/src/styles/home.css | 9 + 18 files changed, 944 insertions(+), 62 deletions(-) create mode 100644 .claude/launch.json create mode 100644 .claude/skills/add-showcase-app/SKILL.md create mode 100644 .codex/skills/add-showcase-app/SKILL.md create mode 100644 packages/docs/SHOWCASE.md create mode 100644 packages/docs/public/showcase/loader.webp create mode 100644 packages/docs/public/showcase/martie.webp create mode 100644 packages/docs/public/showcase/recallai.webp create mode 100644 packages/docs/public/showcase/sudoku-rabbit.webp create mode 100644 packages/docs/scripts/refresh-showcase-metrics.mjs create mode 100644 packages/docs/showcase-apps.json create mode 100644 packages/docs/src/components/ShowcaseCards.tsx create mode 100644 packages/docs/src/lib/showcase.ts create mode 100644 packages/docs/src/pages/showcase.tsx diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..6c65ba6ae --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "docs", + "runtimeExecutable": "bun", + "runtimeArgs": ["run", "--cwd", "packages/docs", "dev"], + "port": 5173 + } + ] +} diff --git a/.claude/skills/add-showcase-app/SKILL.md b/.claude/skills/add-showcase-app/SKILL.md new file mode 100644 index 000000000..67668cd20 --- /dev/null +++ b/.claude/skills/add-showcase-app/SKILL.md @@ -0,0 +1,26 @@ +--- +name: add-showcase-app +description: Add an app to the OpenIAP "Who uses OpenIAP?" showcase — download and mask its icon, append the showcase-apps.json entry, refresh the review-count ordering metrics, and verify the docs build. Use when someone submits an app through issue #280, a showcase pull request, X, or email, or when the user asks to add or update an app on openiap.dev/showcase. +--- + +# Add Showcase App (Claude Code) + +The canonical procedure lives in `.codex/skills/add-showcase-app/SKILL.md`. +Read it and follow every section — collecting the submission, masking the icon, +appending the JSON entry, refreshing metrics, verifying, and closing the loop +are agent-agnostic and apply as written. + +## Claude Code Notes + +- Fetch submissions with the GitHub MCP tools or `gh` (for example + `gh api repos/hyodotdev/openiap/issues/280/comments`) instead of asking the + user to paste them. +- To verify rendering, start the docs dev server through `preview_start` + (`.claude/launch.json` defines the `docs` configuration) and check the + showcase section in the browser pane. The home page section sits far down the + page — scroll to the `Who uses OpenIAP?` heading, or open `/showcase` + directly, which renders the full list near the top. +- If browser screenshots come back blank, fall back to headless Chrome against + the dev server and crop the region with Pillow. +- Attach the rendered section back to the user with `SendUserFile` so they can + approve the card before anything is committed. diff --git a/.codex/skills/add-showcase-app/SKILL.md b/.codex/skills/add-showcase-app/SKILL.md new file mode 100644 index 000000000..189f0bada --- /dev/null +++ b/.codex/skills/add-showcase-app/SKILL.md @@ -0,0 +1,148 @@ +--- +name: add-showcase-app +description: Add an app to the OpenIAP "Who uses OpenIAP?" showcase — download and mask its icon, append the showcase-apps.json entry, refresh the review-count ordering metrics, and verify the docs build. Use when someone submits an app through issue #280, a showcase pull request, X, or email, or when the user asks to add or update an app on openiap.dev/showcase. +--- + +# Add Showcase App + +Turn an app submission into a rendered card on the home page and `/showcase`. + +Everything lives in `packages/docs`: + +| Path | Role | +| ------------------------------------------- | ---------------------------------------- | +| `showcase-apps.json` | The list (SSOT for what renders) | +| `public/showcase/.webp` | Masked 256×256 app icon | +| `scripts/refresh-showcase-metrics.mjs` | Fills `ratings` / `installs` for ordering | +| `src/lib/showcase.ts` | Sorting + featured slice | +| `src/components/ShowcaseCards.tsx` | Card markup | +| `SHOWCASE.md` | Public submission guide | + +## 1. Collect the submission + +Required from the submitter: + +- **App name** and a one-line description (keep the tagline under ~70 chars so + cards stay even) +- **App icon** — square, 512×512 PNG (a store icon URL works too) +- **Store links** — App Store and/or Google Play; a website link is optional +- **Library** — one of `expo-iap`, `react-native-iap`, `flutter_inapp_purchase`, + `kmp-iap`, `maui-iap`, `godot-iap` + +Only list an app when the submitter asked for it. A comment on +[issue #280](https://github.com/hyodotdev/openiap/issues/280), a showcase PR, an +email, or a public reply to the announcement all count as permission; a mention +of the library somewhere else does not. + +If the icon is missing, pull it from the stores rather than asking again: + +```bash +# App Store artwork + metadata +curl -s "https://itunes.apple.com/lookup?id=" | python3 -m json.tool | grep artworkUrl512 + +# Google Play icon +curl -s "https://play.google.com/store/apps/details?id=" \ + | grep -o 'https://play-lh.googleusercontent.com/[A-Za-z0-9_=-]\{20,\}' | head -1 +``` + +## 2. Add the icon + +Icons are stored pre-masked so store artwork with baked-in rounded corners and +plain square artwork render identically. Append `=s512` to a Play icon URL for +the full-size original. + +```bash +cd packages/docs && python3 - <<'PY' +import urllib.request, io +from PIL import Image, ImageDraw + +SLUG = "your-app" # kebab-case, matches the logo path in the JSON +URL = "https://..." # 512px source icon + +SIZE, SS, RATIO = 256, 4, 0.2237 # 0.2237 ≈ the Apple icon corner radius +mask = Image.new("L", (SIZE*SS, SIZE*SS), 0) +ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, SIZE*SS-1, SIZE*SS-1), radius=int(SIZE*SS*RATIO), fill=255 +) +mask = mask.resize((SIZE, SIZE), Image.LANCZOS) + +req = urllib.request.Request(URL, headers={"User-Agent": "Mozilla/5.0"}) +raw = urllib.request.urlopen(req, timeout=30).read() +img = Image.open(io.BytesIO(raw)).convert("RGBA").resize((SIZE, SIZE), Image.LANCZOS) +out = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) +out.paste(img, (0, 0), mask) +out.save(f"public/showcase/{SLUG}.webp", "WEBP", quality=90, method=6) +print("saved", SLUG) +PY +``` + +`sips` cannot write WebP on macOS — use the Pillow snippet above. + +## 3. Append the entry + +Add to the end of the `apps` array in `packages/docs/showcase-apps.json`. +Ordering is computed at render time, so position in the file does not matter. + +```json +{ + "name": "Your App", + "tagline": "One line about what the app does", + "logo": "/showcase/your-app.webp", + "library": "expo-iap", + "ios": "https://apps.apple.com/us/app/your-app/id0000000000", + "android": "https://play.google.com/store/apps/details?id=com.example.yourapp" +} +``` + +`ios`, `android`, and `web` are each optional, but an entry with none of them is +dropped at render time. Leave `ratings` and `installs` out — step 4 writes them. + +## 4. Refresh the ordering metrics + +```bash +cd packages/docs && bun run showcase:metrics +``` + +The script fills every entry: + +- `ratings` — App Store `userRatingCount` **summed across every storefront** + plus the Google Play review count. **Primary sort key, descending.** +- `installs` — the Play install floor (`"1K+"` → `1000`). **Fallback** when + review counts tie, which is common for new apps. + +Apple reports ratings per storefront and publishes no global total, so a US-only +lookup reads zero for an app reviewed mainly in Korea or Japan. The sweep covers +~170 storefronts and takes about a minute; Apple throttles bursts, so the script +retries failures in later rounds and **keeps the previous numbers rather than +writing a partial sweep**. If output says `kept existing ratings`, rerun it. + +Neither store publishes download totals: Apple exposes no install data in any +public API, and Play reports only a coarse bucket. Do not add a `downloads` +field or invent numbers — review count is the one verifiable signal both stores +share. If a submitter reports their own install figures, keep them out of the +JSON. + +The scraper depends on Play's HTML, whose class names are obfuscated and change. +If `ratings` comes back unexpectedly `0` for an app that clearly has reviews, +re-check the regexes in `scripts/refresh-showcase-metrics.mjs` rather than +hand-editing the JSON. + +## 5. Verify + +```bash +cd packages/docs && bun run typecheck && bun run build +``` + +Then confirm the card renders and the icon actually loads — a broken `logo` path +fails silently as a missing image, not a build error. The home page shows the +top `FEATURED_SHOWCASE_LIMIT` (5) apps plus the submit card and a "See all" +link; `/showcase` lists everything. + +## 6. Close the loop + +- Reply to the submission thread (issue #280 comment, PR, or email) confirming + the app is listed, and note that updates or removal are available anytime. +- Public GitHub replies must be in English — see + `knowledge/internal/06-git-deployment.md`. +- Commit with `docs: add to showcase`. Do not commit, push, or open a PR + unless the user already authorized it. diff --git a/bun.lock b/bun.lock index 3ae7b8629..c52daf15d 100644 --- a/bun.lock +++ b/bun.lock @@ -12,14 +12,14 @@ }, "packages/apple": { "name": "@hyodotdev/openiap-ios", - "version": "2.4.2", + "version": "3.0.1", "dependencies": { "@hyodotdev/openiap-gql": "workspace:*", }, }, "packages/docs": { "name": "@hyodotdev/openiap-docs", - "version": "2.5.0", + "version": "3.0.1", "dependencies": { "@preact/signals-react": "^3.2.1", "@types/prismjs": "^1.26.5", @@ -57,14 +57,14 @@ }, "packages/google": { "name": "@hyodotdev/openiap-android", - "version": "2.5.0", + "version": "3.0.1", "dependencies": { "@hyodotdev/openiap-gql": "workspace:*", }, }, "packages/gql": { "name": "@hyodotdev/openiap-gql", - "version": "2.5.0", + "version": "3.0.1", "devDependencies": { "@graphql-codegen/add": "^6.0.0", "@graphql-codegen/cli": "^6.0.0", diff --git a/packages/docs/SHOWCASE.md b/packages/docs/SHOWCASE.md new file mode 100644 index 000000000..fdd610045 --- /dev/null +++ b/packages/docs/SHOWCASE.md @@ -0,0 +1,78 @@ +# Submit your app to the OpenIAP showcase + +Shipped an app with `react-native-iap`, `expo-iap`, `flutter_inapp_purchase`, +`kmp-iap`, `maui-iap`, or `godot-iap`? Add it to the +**[Who uses OpenIAP?](https://www.openiap.dev)** section on the home page. + +It's one entry in [`showcase-apps.json`](./showcase-apps.json). + +## Open a pull request + +1. Fork [hyodotdev/openiap](https://github.com/hyodotdev/openiap) and create a branch. +2. Add your app to the end of the `apps` array in `packages/docs/showcase-apps.json`: + + ```json + { + "name": "Your App", + "tagline": "One line about what your app does", + "logo": "/showcase/your-app.webp", + "library": "expo-iap", + "ios": "https://apps.apple.com/us/app/your-app/id0000000000", + "android": "https://play.google.com/store/apps/details?id=com.example.yourapp" + } + ``` + +3. Add your icon to `packages/docs/public/showcase/` as a **square 512×512 PNG** + or a 256×256 `.webp`. Don't pre-round the corners — we apply the same rounded + mask to every icon so the row stays consistent. +4. Open the PR with the title `docs: add to showcase`. + +That's it. No build step or code change is needed — the pages render the JSON +directly. + +## Ordering + +Apps are ordered by **combined App Store + Google Play review count**, +descending, with the Google Play install count as a tiebreaker. Neither store +publishes download totals — Apple exposes no install data publicly and Play +reports only a bucket like "1K+" — so review count is the one verifiable signal +both stores share. + +App Store review counts are **summed across every storefront**, not just the US +one: Apple reports `userRatingCount` per country and publishes no global total, +so an app reviewed mainly in Korea or Japan would otherwise read as zero. + +Maintainers refresh the numbers with: + +```bash +cd packages/docs && bun run showcase:metrics +``` + +Leave `ratings` and `installs` out of your PR — the script fills them in. + +## Fields + +| Field | Required | Notes | +| --------- | -------- | -------------------------------------------------------------------------------------- | +| `name` | ✅ | App name as it appears on the stores. | +| `tagline` | ✅ | One short line. Keep it under ~70 characters so cards stay even. | +| `logo` | ✅ | Path under `packages/docs/public` (e.g. `/showcase/your-app.webp`) or a full https URL. | +| `ratings` / `installs` | — | Maintainer-managed ordering metrics. Leave these out. | +| `library` | ✅ | One of `expo-iap`, `react-native-iap`, `flutter_inapp_purchase`, `kmp-iap`, `maui-iap`, `godot-iap`. | +| `ios` | — | App Store URL. | +| `android` | — | Google Play URL. | +| `web` | — | Website or other store, shown as "Website". | + +At least one of `ios`, `android`, or `web` is required — entries without a link +are skipped at render time. + +## Don't want to send a PR? + +Comment on [issue #280](https://github.com/hyodotdev/openiap/issues/280) or +email **hyo@hyo.dev** with your app name, one-liner, logo, store links, and which +library you use — we'll add it for you. + +## Removal and updates + +Your app is listed only with your permission. To change or remove an entry, open +a PR, comment on issue #280, or email hyo@hyo.dev anytime. diff --git a/packages/docs/package.json b/packages/docs/package.json index 9ee8c8435..9afea00c1 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -4,6 +4,7 @@ "version": "3.0.1", "type": "module", "scripts": { + "showcase:metrics": "node scripts/refresh-showcase-metrics.mjs", "dev": "bunx vite", "build": "bun run typecheck && bunx vite build", "typecheck": "tsc --noEmit", diff --git a/packages/docs/public/showcase/loader.webp b/packages/docs/public/showcase/loader.webp new file mode 100644 index 0000000000000000000000000000000000000000..683ae4c1e8abfc5fc7f53d7e1972f16e6bb04c4b GIT binary patch literal 3590 zcmV+h4*Bs?Nk&Hg4FCXFMM6+kP&il$0000G000300093006|PpNMQp2009n4&i1d7 zoGDk&OqiLoWlomNw2WpM%@JCCk1#Xdfti_;3otVnrLv1$BY$CkKYxG!91(CJNpcib z_b_pT28)-|E^F7wENU>B8UKXJY{J(4jy~qNijJi{m%7Zn^RiGmDeV8dnT5 zGw-%cUcJ>_i@fB$@=Y`~w9HL`$19Pcvtpw`C}~~tR1g;!^}MPqoaS6xwD%VoaYA~CY+k={av`8{aZZcu)Wmb7 zn1_B8&F)W^9cwf8GwZ^#(7K=Dk?s7kGPF?LU^d+^Ej!*8rd!0vbHvyajH<^xJFm>} zj?Suj#E0_)F+mSZwS#}ka>ST@cW`yQuDF1#5H_kjuYj2}yj;1dtv~068O47bSh9Z{ zQ1hEEDmgk%QS-ajOOAQUjaU$x83R_ zyG9a8cXp+27<#L`Vt-0Q*lVJ3LrId%>V^PTP&goV3IG65KmeTqDgXfh0X{JpibJ9y zp%NNwydVPvvbS)zF910K*hL8X|9AXp{KV}F;m-B`NA+gY9!orD{)h7;`{!=o6tD0f zlfAzD$^NZ>$^R+t@#t^%KiC8LFZM38KkYuC->v`u{b|4Qe}4Ez{h$BC{#)F``>*{? zYEL_MiI1#$Cuc5=G4ogQB=!H;fB)lDK-bwuM#%T_Y+*mu!|c`yuEPr(LsdBK?$LA8 z3(PhoEdwHcX(3QH8Qj)p&r{zzD%H{jl|vD&?b*(0mEMbL7^RJ%9d$2(`)YG@=Y>(W z{PKXTl-t9X#JtlyX9x*=P@hW#vos?eS|>}Op8wu$xmg`m_=9k8gO<) zvZCBKt>ve=46}4=bo2n1Qz|XPd-ku6Q6E+9Uku5`rcCmG@<8@tut*EUxL%gd55bJo z)S&%d9?KEB2#)E}KcFy(JY}-*xY?-to*NIp*?ZSHG}lW(3xHt|m@`j2d1@&rY&^8% zk0#Ql_l(8F|BVh{o{3htJK)gsgKrBY~IncUb*hso}o`;WcA> za5-Hv*LXmqJtnPmIQB2FuF1cNXmyJR*{l={>d(|C(rbxhf^Y!-`uy!d?yi4|T)Ts6v3X=lSAIsKo^7VfCr`z$xoFn0XYES zZT&I1{&4+N;&!6{&D(B!FH>>B24_D<{HSsXczn_x_$;PI;dV04=E)qJbZYBe@axC= znj@u0I&3R{C;^3>>(#PSq3F&v@1-m=G>r8HgQ0^lIB09UdxR7 z+*sla(pF1sot@CcP^~orJ3F#M>}cje;{1l){LW(%Hj0ZiIvH1vlZEPgWT*YLg!qS0 z7LEYemHpW_B#p6TGON(e(aRGFztWVS^yfKr zviPc;?603%7jN{dYRO%hkRj4fO- zgIg+oVV{YmB$%fSD-oZ3p^`|=;QXrx+L4W}g(?ig_>x~4hVFXy8>�P9iSi$?ep8X;n0j)Px-wAc#{D zG^~2}>dh1_ z`udZ5B`=qWvYjZ{SYTVo&*@kiJ4bM~XXUlC;! z$rKPME(1(x>}B zPobs#;jfPYI=pB8!)tky_sQ6X?!2dlW0$YmBsB`tJBgh9_6+J8u|SV>)wdakEIXI*=e^qT0*ZSLab< z9DS-h*9DLebx7Gdi|4Ty+iQ2Jsi3A#JL;YziAJk+@eQ{EMN$w14>y zGtHc6Ja?9Kkn~2E=2&QebX?_q= zCBS-66OW*w-fg9In(48Wm$)mThN(G=uJ&E&E@)XjK(T{=QTT+=@cZa)u`TvHNHX0U z#$GX|)lp-LAGhXhMy}pM#P5}`jmwFXJ{Y|ta4py*^)>V1dh^>u(959{)3o2}b zb8!mq!PXJ`6dvUtdzpU*8`P&7A-K z3)|evb{GCdxdfHj>3!J91ul$UP9BLDM71J-qJZ}T+EE>Ef{)Y6XbI1VhyJ{TpYAc~c%+vwDBT?$@Kpw?9iN*L55h z^t>(X?{0Bg8w<2tIrSvJKIQRVq>t*k5{d;oS~uT!o>mta8n6EiDmIMoPts(z$7+cF z&ig@}0{dq9I&U|BtKM@l6?rvuM&?1zULnzi%xe6No4vx(x@PKw8;0t<0^E1q#PeHJ}2~ zW8$0pkP;eF+k>%%^82{(8R_gD;D(4?ZBG>NfOEOR^;=K-a)Pk&OMtX>e4liC=eY;2 z__Qq^6cjNPk$$S2ML;I1l3HaMxMO{NPh+x=vz#hHjP1%z(;B>p{& M^g?p4^PZ3Z0EM3{ivR!s literal 0 HcmV?d00001 diff --git a/packages/docs/public/showcase/martie.webp b/packages/docs/public/showcase/martie.webp new file mode 100644 index 0000000000000000000000000000000000000000..1fbd31882b7a279a5f0123db5652498d1f93d58c GIT binary patch literal 12194 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
wy-P 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 kr1_{|SFF4k{DdluHD)h3u1a?IfU<{9 literal 0 HcmV?d00001 diff --git a/packages/docs/public/showcase/recallai.webp b/packages/docs/public/showcase/recallai.webp new file mode 100644 index 0000000000000000000000000000000000000000..15256e7c76624991652d9c95f125cb6a7540bfee GIT binary patch literal 3450 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 literal 0 HcmV?d00001 diff --git a/packages/docs/public/showcase/sudoku-rabbit.webp b/packages/docs/public/showcase/sudoku-rabbit.webp new file mode 100644 index 0000000000000000000000000000000000000000..6a36e9a0fbf435bd6e814643fb4eb5e1a06186dc GIT binary patch literal 5830 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 QjD 1200, "3M" -> 3000000, "55" -> 55 */ +function parseCompact(value) { + const match = /^([\d.,]+)\s*([KMB])?/i.exec(value.trim()); + if (!match) return undefined; + const base = Number(match[1].replace(/,/g, '')); + if (!Number.isFinite(base)) return undefined; + const scale = { k: 1e3, m: 1e6, b: 1e9 }[match[2]?.toLowerCase()] ?? 1; + return Math.round(base * scale); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Apple throttles bursts of storefront lookups (HTTP 403), so retry with + * exponential backoff instead of silently recording a zero. + */ +async function fetchText(url, attempts = 4) { + let lastError; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await fetch(url, { + headers: { 'User-Agent': USER_AGENT }, + }); + if (response.ok) return response.text(); + lastError = new Error(`${response.status} ${url}`); + if (response.status !== 403 && response.status !== 429) throw lastError; + } catch (error) { + lastError = error; + } + await sleep(500 * 2 ** attempt); + } + throw lastError; +} + +/** Sums userRatingCount across every App Store storefront the app ships in. */ +async function appleRatings(iosUrl) { + const id = /\/id(\d+)/.exec(iosUrl)?.[1]; + if (!id) return { ratings: undefined, markets: 0 }; + + let ratings = 0; + let markets = 0; + + const lookup = async (country) => { + const body = await fetchText( + `https://itunes.apple.com/lookup?id=${id}&country=${country}` + ); + return JSON.parse(body).results?.[0]?.userRatingCount ?? 0; + }; + + const record = (count) => { + if (count > 0) markets += 1; + ratings += count; + }; + + let pending = APP_STORE_STOREFRONTS; + + for (let round = 0; round < 3 && pending.length > 0; round += 1) { + const failed = []; + + for ( + let offset = 0; + offset < pending.length; + offset += STOREFRONT_CONCURRENCY + ) { + const batch = pending.slice(offset, offset + STOREFRONT_CONCURRENCY); + const results = await Promise.all( + batch.map(async (country) => { + try { + return { count: await lookup(country) }; + } catch { + return { country }; + } + }) + ); + for (const result of results) { + if (result.country) failed.push(result.country); + else record(result.count); + } + await sleep(STOREFRONT_BATCH_PAUSE_MS); + } + + pending = failed; + // Throttled storefronts usually clear after a short cool-down. + if (pending.length > 0) await sleep(5000); + } + + if (pending.length > 0) { + // A partial sweep would silently under-count, so surface it loudly rather + // than writing a number that looks authoritative. + throw new Error( + `${pending.length}/${APP_STORE_STOREFRONTS.length} storefront lookups failed — ratings would be under-counted` + ); + } + return { ratings, markets }; +} + +async function playMetrics(androidUrl) { + const packageName = /[?&]id=([^&]+)/.exec(androidUrl)?.[1]; + if (!packageName) return {}; + const html = await fetchText( + `https://play.google.com/store/apps/details?id=${packageName}&hl=en&gl=US` + ); + const reviews = /">([\d.,]+\s*[KMB]?)\s*reviews([\d.,]+\s*[KMB]?\+)<\/div>
Downloads + (b.ratings ?? 0) - (a.ratings ?? 0) || + (b.installs ?? 0) - (a.installs ?? 0) + ) + .map((app, index) => ` ${index + 1}. ${app.name} (${app.ratings ?? 0})`) + .join('\n'); + +console.log(`\nUpdated ${changed} of ${data.apps.length} entries.`); +if (stale > 0) { + console.log(`${stale} kept previous numbers — rerun to refresh them.`); +} +console.log(`\nRanking\n${ranking}`); diff --git a/packages/docs/showcase-apps.json b/packages/docs/showcase-apps.json new file mode 100644 index 000000000..401c66760 --- /dev/null +++ b/packages/docs/showcase-apps.json @@ -0,0 +1,43 @@ +{ + "apps": [ + { + "name": "Sudoku Rabbit", + "tagline": "The competitive sudoku app built for speed and comfort", + "logo": "/showcase/sudoku-rabbit.webp", + "library": "expo-iap", + "ios": "https://apps.apple.com/us/app/sudoku-rabbit-daily-puzzles/id6742900571", + "android": "https://play.google.com/store/apps/details?id=com.bustedout.sudokurabbit", + "ratings": 146, + "installs": 1000 + }, + { + "name": "Loader", + "tagline": "Media player and downloader — stream music, movies, and TV shows", + "logo": "/showcase/loader.webp", + "library": "react-native-iap", + "ios": "https://apps.apple.com/us/app/documents-loader/id1442498151", + "web": "https://loaderapp.info/", + "ratings": 9 + }, + { + "name": "Martie", + "tagline": "Daily trivia quiz — 5 questions a day to build a knowledge habit", + "logo": "/showcase/martie.webp", + "library": "expo-iap", + "ios": "https://apps.apple.com/us/app/martie-daily-trivia-quiz/id6740057833", + "android": "https://play.google.com/store/apps/details?id=dev.hyo.martie", + "ratings": 12, + "installs": 500 + }, + { + "name": "RecallAI", + "tagline": "Private, local-first AI memo and knowledge base", + "logo": "/showcase/recallai.webp", + "library": "expo-iap", + "ios": "https://apps.apple.com/us/app/recallai-ai-memo-chat/id6784931826", + "android": "https://play.google.com/store/apps/details?id=dev.hyo.recallai", + "ratings": 0, + "installs": 1 + } + ] +} diff --git a/packages/docs/src/App.tsx b/packages/docs/src/App.tsx index 437de4537..94999ed26 100644 --- a/packages/docs/src/App.tsx +++ b/packages/docs/src/App.tsx @@ -10,6 +10,7 @@ import Docs from './pages/docs'; import Languages from './pages/languages'; import Tutorials from './pages/tutorials'; import Sponsors from './pages/sponsors'; +import Showcase from './pages/showcase'; import NotFound from './pages/404'; import { searchModalSignal, closeSearchModal } from './lib/signals'; import { effect } from '@preact/signals-react'; @@ -37,6 +38,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/packages/docs/src/components/ShowcaseCards.tsx b/packages/docs/src/components/ShowcaseCards.tsx new file mode 100644 index 000000000..3a0ee2047 --- /dev/null +++ b/packages/docs/src/components/ShowcaseCards.tsx @@ -0,0 +1,180 @@ +import type { CSSProperties } from 'react'; +import { SiApple, SiGoogleplay } from 'react-icons/si'; +import { Globe } from 'lucide-react'; +import type { ShowcaseApp } from '../lib/showcase'; + +export const SHOWCASE_ISSUE_URL = + 'https://github.com/hyodotdev/openiap/issues/280'; + +export const SHOWCASE_GUIDE_URL = + 'https://github.com/hyodotdev/openiap/blob/main/packages/docs/SHOWCASE.md'; + +export const showcaseGridStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', + gap: '1rem', +}; + +const cardStyle: CSSProperties = { + display: 'flex', + gap: '1rem', + alignItems: 'flex-start', + padding: '1.25rem', + border: '1px solid var(--border-color)', + borderRadius: '0.875rem', + textAlign: 'left', +}; + +const logoStyle: CSSProperties = { + width: '56px', + height: '56px', + // Matches the rounded mask baked into /showcase icons so store artwork with + // and without built-in corners renders identically. + borderRadius: '22.37%', + objectFit: 'cover', + flexShrink: 0, +}; + +const storeLinkStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '28px', + height: '28px', + borderRadius: '0.5rem', + border: '1px solid var(--border-color)', + color: 'var(--text-secondary)', + textDecoration: 'none', +}; + +export function ShowcaseAppCard({ app }: { app: ShowcaseApp }) { + return ( +
+ {`${app.name} +
+
+ {app.name} +
+
+ {app.tagline} +
+
+ {app.ios ? ( + + + + ) : null} + {app.android ? ( + + + + ) : null} + {app.web ? ( + + + + ) : null} + + {app.library} + +
+
+
+ ); +} + +/** Sits in the app grid as the last cell, inviting the next submission. */ +export function ShowcaseSubmitCard() { + return ( +
+
Ship with OpenIAP?
+
+ Send your app name, icon, and store links — we'll add it here. +
+ + Submit Your App + +
+ ); +} diff --git a/packages/docs/src/lib/showcase.ts b/packages/docs/src/lib/showcase.ts new file mode 100644 index 000000000..fdcd22129 --- /dev/null +++ b/packages/docs/src/lib/showcase.ts @@ -0,0 +1,69 @@ +// ============================================================================= +// Showcase Apps +// ============================================================================= +// Apps shipped with OpenIAP libraries, rendered in the "Who uses OpenIAP?" +// section on the home page and in full on /showcase. +// +// To add an app, edit `showcase-apps.json` at the root of packages/docs and +// open a pull request. See SHOWCASE.md for the submission guide. +// ============================================================================= + +import * as showcaseData from '../../showcase-apps.json'; +import type { FrameworkLibraryName } from './images'; + +export type ShowcaseApp = { + /** App name as it appears on the stores. */ + name: string; + /** One-line description shown under the app name. */ + tagline: string; + /** Path under packages/docs/public (e.g. `/showcase/app.webp`) or an https URL. */ + logo: string; + /** Which OpenIAP library the app ships with. */ + library: FrameworkLibraryName; + ios?: string; + android?: string; + web?: string; + /** + * App Store + Google Play review counts combined. Primary ordering key — + * neither store publishes download totals, so this is the one verifiable + * signal both platforms share. + */ + ratings?: number; + /** Google Play install floor ("1K+" → 1000). Tiebreaker when ratings match. */ + installs?: number; +}; + +/** How many apps the home page highlights before "See all". */ +export const FEATURED_SHOWCASE_LIMIT = 5; + +function hasLink(app: ShowcaseApp): boolean { + return Boolean(app.ios ?? app.android ?? app.web); +} + +/** + * Orders by combined review count (desc), falling back to Play installs, then + * submission order. Refresh the numbers with `bun run showcase:metrics`. + */ +function byReach(apps: ShowcaseApp[]): ShowcaseApp[] { + return apps + .map((app, index) => ({ app, index })) + .sort((a, b) => { + const ratings = (b.app.ratings ?? 0) - (a.app.ratings ?? 0); + if (ratings !== 0) return ratings; + const installs = (b.app.installs ?? 0) - (a.app.installs ?? 0); + if (installs !== 0) return installs; + return a.index - b.index; + }) + .map((entry) => entry.app); +} + +export const SHOWCASE_APPS: ShowcaseApp[] = byReach( + (showcaseData.apps as ShowcaseApp[]).filter( + (app) => Boolean(app.name && app.logo) && hasLink(app) + ) +); + +export const FEATURED_SHOWCASE_APPS: ShowcaseApp[] = SHOWCASE_APPS.slice( + 0, + FEATURED_SHOWCASE_LIMIT +); diff --git a/packages/docs/src/pages/home.tsx b/packages/docs/src/pages/home.tsx index 8617ebbba..c000880e0 100644 --- a/packages/docs/src/pages/home.tsx +++ b/packages/docs/src/pages/home.tsx @@ -3,6 +3,12 @@ import { Link } from 'react-router-dom'; import { OPENIAP_VERSIONS } from '../lib/versioning'; import { LOGO_PATH } from '../lib/config'; import { LIBRARIES } from '../lib/images'; +import { FEATURED_SHOWCASE_APPS, SHOWCASE_APPS } from '../lib/showcase'; +import { + ShowcaseAppCard, + ShowcaseSubmitCard, + showcaseGridStyle, +} from '../components/ShowcaseCards'; import SEO from '../components/SEO'; const frameworkLinkStyle: CSSProperties = { @@ -511,7 +517,7 @@ function Home() {
-
+

Who uses OpenIAP?

( - {library.displayName} + + {library.displayName} + {index < LIBRARIES.length - 1 ? ', ' : ''} ))} @@ -528,66 +541,26 @@ function Home() {
We'd love to showcase it here.

-
-

- Send us your app name, logo, platform links, and which library you - use — we'll add you to this section. -

- - Submit Your App - -

- Contact: Hyo — Lead Maintainer ( - + {FEATURED_SHOWCASE_APPS.map((app) => ( + + ))} + +

+ {SHOWCASE_APPS.length > FEATURED_SHOWCASE_APPS.length ? ( + + See all {SHOWCASE_APPS.length} apps → + +
+ ) : null}
diff --git a/packages/docs/src/pages/showcase.tsx b/packages/docs/src/pages/showcase.tsx new file mode 100644 index 000000000..35c7a74dd --- /dev/null +++ b/packages/docs/src/pages/showcase.tsx @@ -0,0 +1,121 @@ +import SEO from '../components/SEO'; +import { + ShowcaseAppCard, + ShowcaseSubmitCard, + SHOWCASE_GUIDE_URL, + SHOWCASE_ISSUE_URL, + showcaseGridStyle, +} from '../components/ShowcaseCards'; +import { SHOWCASE_APPS } from '../lib/showcase'; + +function Showcase() { + return ( +
+ +
+
+

Who uses OpenIAP?

+

+ {SHOWCASE_APPS.length} apps ship in-app purchases with OpenIAP + libraries. Ordered by App Store and Google Play review counts. +

+
+ {SHOWCASE_APPS.map((app) => ( + + ))} + +
+ +
+

Add your app

+

+ Comment on{' '} + + the showcase issue + {' '} + with the details below and we'll add your app. Prefer a pull + request? Add an entry to{' '} + + showcase-apps.json + + , or email{' '} + + hyo@hyo.dev + + . +

+
    +
  • + App name and a one-line description +
  • +
  • + App icon — square, 512×512 PNG (we round the + corners and convert it for you) +
  • +
  • + Store links — App Store and/or Google Play +
  • +
  • + Library you ship with (expo-iap, + react-native-iap, flutter_inapp_purchase, kmp-iap, maui-iap, + godot-iap) +
  • +
+

+ Apps are listed only with your permission. Ask for an update or + removal anytime. +

+
+
+
+
+ ); +} + +export default Showcase; diff --git a/packages/docs/src/styles/home.css b/packages/docs/src/styles/home.css index ccc4318c3..980179e14 100644 --- a/packages/docs/src/styles/home.css +++ b/packages/docs/src/styles/home.css @@ -357,6 +357,15 @@ text-align: center; } +.section-subtitle a { + color: var(--accent-color); + text-decoration: none; +} + +.section-subtitle a:hover { + text-decoration: underline; +} + /* Specification Grid */ .specification-grid { display: grid; From a76d7755236b62457d52b6bd86471a8a596d0545 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 5 Aug 2026 09:51:55 +0900 Subject: [PATCH 2/3] fix(docs): fail loudly when play markup breaks in showcase metrics Address CodeRabbit review on #282. - Treat the Play install block as the markup canary. It renders on every app page, so its absence means our selectors stopped matching and the script now throws instead of writing a zero over a real review count. - Keep a missing review element as a legitimate zero: Play omits it entirely for apps with few or no reviews, so throwing there would fail every newly released app. - Skip entries with no store links so web-only apps keep their recorded metrics. - Use a lowercase commit subject in the add-showcase-app example. Co-Authored-By: Claude Opus 5 --- .codex/skills/add-showcase-app/SKILL.md | 5 ++-- .../docs/scripts/refresh-showcase-metrics.mjs | 23 +++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.codex/skills/add-showcase-app/SKILL.md b/.codex/skills/add-showcase-app/SKILL.md index 189f0bada..712b82109 100644 --- a/.codex/skills/add-showcase-app/SKILL.md +++ b/.codex/skills/add-showcase-app/SKILL.md @@ -144,5 +144,6 @@ link; `/showcase` lists everything. the app is listed, and note that updates or removal are available anytime. - Public GitHub replies must be in English — see `knowledge/internal/06-git-deployment.md`. -- Commit with `docs: add to showcase`. Do not commit, push, or open a PR - unless the user already authorized it. +- Commit with a lowercase subject after the tag, e.g. + `docs: add recallai to showcase`. Do not commit, push, or open a PR unless the + user already authorized it. diff --git a/packages/docs/scripts/refresh-showcase-metrics.mjs b/packages/docs/scripts/refresh-showcase-metrics.mjs index d44a6a5de..d96d9a49a 100644 --- a/packages/docs/scripts/refresh-showcase-metrics.mjs +++ b/packages/docs/scripts/refresh-showcase-metrics.mjs @@ -143,9 +143,21 @@ async function playMetrics(androidUrl) { const installs = />([\d.,]+\s*[KMB]?\+)<\/div>
Downloads Date: Wed, 5 Aug 2026 10:01:32 +0900 Subject: [PATCH 3/3] fix(docs): keep play review parsing fail-closed in showcase metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to CodeRabbit review on #282. The install-block canary only proved that one selector still matched; review markup could drift on its own and write a zero over a real count. - Extract parsePlayMetrics as a pure function and distinguish an absent review element (a real zero, which Play renders for apps with few reviews) from a present-but-unreadable one (selector drift, now throws) - Refuse to drop an established positive rating to zero, whatever the readings looked like individually — losing a count is always a regression - Add fixtures covering populated, compact, absent, unreadable, chrome-only, and missing-install-block pages, wired into the docs pre-commit block - Guard the refresh flow behind a direct-invocation check so importing the module for tests performs no network calls and rewrites nothing Co-Authored-By: Claude Opus 5 --- .husky/pre-commit | 1 + packages/docs/package.json | 1 + .../docs/scripts/refresh-showcase-metrics.mjs | 207 +++++++++++------- .../scripts/refresh-showcase-metrics.test.mjs | 69 ++++++ 4 files changed, 199 insertions(+), 79 deletions(-) create mode 100644 packages/docs/scripts/refresh-showcase-metrics.test.mjs diff --git a/.husky/pre-commit b/.husky/pre-commit index bb259cb10..592ca18e7 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -176,6 +176,7 @@ if git diff --cached --name-only --diff-filter=ACMR \ bun install --frozen-lockfile bun run --filter @hyodotdev/openiap-docs typecheck bun test "$REPO_ROOT/scripts/audit-docs.test.ts" + bun run --filter @hyodotdev/openiap-docs showcase:metrics:test bun run audit:docs ( cd packages/docs && bunx prettier --check "src/**/*.{ts,tsx,css}" diff --git a/packages/docs/package.json b/packages/docs/package.json index 9afea00c1..3a6382dba 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "showcase:metrics": "node scripts/refresh-showcase-metrics.mjs", + "showcase:metrics:test": "node --test scripts/refresh-showcase-metrics.test.mjs", "dev": "bunx vite", "build": "bun run typecheck && bunx vite build", "typecheck": "tsc --noEmit", diff --git a/packages/docs/scripts/refresh-showcase-metrics.mjs b/packages/docs/scripts/refresh-showcase-metrics.mjs index d96d9a49a..b40abcd49 100644 --- a/packages/docs/scripts/refresh-showcase-metrics.mjs +++ b/packages/docs/scripts/refresh-showcase-metrics.mjs @@ -15,7 +15,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; const HERE = dirname(fileURLToPath(import.meta.url)); const DATA_PATH = join(HERE, '..', 'showcase-apps.json'); @@ -133,108 +133,157 @@ async function appleRatings(iosUrl) { return { ratings, markets }; } -async function playMetrics(androidUrl) { - const packageName = /[?&]id=([^&]+)/.exec(androidUrl)?.[1]; - if (!packageName) return {}; - const html = await fetchText( - `https://play.google.com/store/apps/details?id=${packageName}&hl=en&gl=US` - ); - const reviews = /">([\d.,]+\s*[KMB]?)\s*reviews([\d.,]+\s*[KMB]?\+)<\/div>
Downloads([\d.,]+\s*[KMB]?)\s*reviews([^<]{0,40}?reviews) match[1].trim()) + .filter((text) => !PLAY_REVIEW_CHROME.test(text)) + .filter((text) => /\d/.test(text)); + + if (orphaned.length > 0) { + throw new Error( + `found review element "${orphaned[0]}" but could not read its count for ${packageName}` + ); + } + + return { ratings: 0, installs: parseCompact(installs) }; } -const data = JSON.parse(await readFile(DATA_PATH, 'utf8')); -let changed = 0; +async function playMetrics(androidUrl) { + const packageName = /[?&]id=([^&]+)/.exec(androidUrl)?.[1]; + if (!packageName) return {}; + const html = await fetchText( + `https://play.google.com/store/apps/details?id=${packageName}&hl=en&gl=US` + ); + return parsePlayMetrics(html, packageName); +} -let stale = 0; +async function main() { + const data = JSON.parse(await readFile(DATA_PATH, 'utf8')); + let changed = 0; -for (const app of data.apps) { - // Web-only entries have no store to measure; leave whatever is on record - // instead of writing a zero that looks like a real reading. - if (!app.ios && !app.android) { - console.log(` ${app.name}: no store links — metrics left untouched`); - continue; - } + let stale = 0; - let ratings = 0; - let installs; - let appleMarkets = 0; - let incomplete = false; + for (const app of data.apps) { + // Web-only entries have no store to measure; leave whatever is on record + // instead of writing a zero that looks like a real reading. + if (!app.ios && !app.android) { + console.log(` ${app.name}: no store links — metrics left untouched`); + continue; + } - if (app.ios) { - try { - const apple = await appleRatings(app.ios); - ratings += apple.ratings ?? 0; - appleMarkets = apple.markets; - } catch (error) { - console.warn(` ! ${app.name}: App Store — ${error.message}`); - incomplete = true; + let ratings = 0; + let installs; + let appleMarkets = 0; + let incomplete = false; + + if (app.ios) { + try { + const apple = await appleRatings(app.ios); + ratings += apple.ratings ?? 0; + appleMarkets = apple.markets; + } catch (error) { + console.warn(` ! ${app.name}: App Store — ${error.message}`); + incomplete = true; + } } - } - if (app.android) { - try { - const play = await playMetrics(app.android); - ratings += play.ratings ?? 0; - installs = play.installs; - } catch (error) { - console.warn(` ! ${app.name}: Play — ${error.message}`); + if (app.android) { + try { + const play = await playMetrics(app.android); + ratings += play.ratings ?? 0; + installs = play.installs; + } catch (error) { + console.warn(` ! ${app.name}: Play — ${error.message}`); + incomplete = true; + } + } + + // Last line of defence: every reading can look individually valid and still + // collapse a real count to zero if a selector drifts silently. Losing an + // established count is always a regression, never a legitimate reading. + if (ratings === 0 && (app.ratings ?? 0) > 0) { incomplete = true; + console.warn( + ` ! ${app.name}: refusing to drop ratings ${app.ratings} → 0 — check the store selectors` + ); } - } - if (incomplete) { - // Keep the previous numbers rather than replacing them with a partial sweep. - stale += 1; - console.log(` ${app.name}: kept existing ratings=${app.ratings ?? 0}`); - continue; - } + if (incomplete) { + // Keep the previous numbers rather than replacing them with a partial sweep. + stale += 1; + console.log(` ${app.name}: kept existing ratings=${app.ratings ?? 0}`); + continue; + } - const nextInstalls = installs ?? app.installs; - if (app.ratings !== ratings || app.installs !== nextInstalls) changed += 1; + const nextInstalls = installs ?? app.installs; + if (app.ratings !== ratings || app.installs !== nextInstalls) changed += 1; - app.ratings = ratings; - if (nextInstalls === undefined) delete app.installs; - else app.installs = nextInstalls; + app.ratings = ratings; + if (nextInstalls === undefined) delete app.installs; + else app.installs = nextInstalls; - console.log( - ` ${app.name}: ratings=${ratings}` + - (appleMarkets ? ` (App Store in ${appleMarkets} markets)` : '') + - (nextInstalls === undefined ? '' : ` installs=${nextInstalls}`) - ); -} + console.log( + ` ${app.name}: ratings=${ratings}` + + (appleMarkets ? ` (App Store in ${appleMarkets} markets)` : '') + + (nextInstalls === undefined ? '' : ` installs=${nextInstalls}`) + ); + } -await writeFile(DATA_PATH, `${JSON.stringify(data, null, 2)}\n`); + await writeFile(DATA_PATH, `${JSON.stringify(data, null, 2)}\n`); -const ranking = [...data.apps] - .sort( - (a, b) => - (b.ratings ?? 0) - (a.ratings ?? 0) || - (b.installs ?? 0) - (a.installs ?? 0) - ) - .map((app, index) => ` ${index + 1}. ${app.name} (${app.ratings ?? 0})`) - .join('\n'); + const ranking = [...data.apps] + .sort( + (a, b) => + (b.ratings ?? 0) - (a.ratings ?? 0) || + (b.installs ?? 0) - (a.installs ?? 0) + ) + .map((app, index) => ` ${index + 1}. ${app.name} (${app.ratings ?? 0})`) + .join('\n'); + + console.log(`\nUpdated ${changed} of ${data.apps.length} entries.`); + if (stale > 0) { + console.log(`${stale} kept previous numbers — rerun to refresh them.`); + } + console.log(`\nRanking\n${ranking}`); +} -console.log(`\nUpdated ${changed} of ${data.apps.length} entries.`); -if (stale > 0) { - console.log(`${stale} kept previous numbers — rerun to refresh them.`); +// Only refresh when run directly; importing for tests must stay side-effect free. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); } -console.log(`\nRanking\n${ranking}`); diff --git a/packages/docs/scripts/refresh-showcase-metrics.test.mjs b/packages/docs/scripts/refresh-showcase-metrics.test.mjs new file mode 100644 index 000000000..729a36c46 --- /dev/null +++ b/packages/docs/scripts/refresh-showcase-metrics.test.mjs @@ -0,0 +1,69 @@ +// Fixtures for the Google Play scrape in refresh-showcase-metrics.mjs. +// +// The parser must never turn a selector drift into a zero: ordering metrics are +// written straight into showcase-apps.json, so a silent zero demotes a real app. +// Play does omit the review element for apps with few or no reviews, though, so +// "absent" and "broken" have to stay distinguishable. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { parsePlayMetrics } from './refresh-showcase-metrics.mjs'; + +const installBlock = (value = '1K+') => + `
${value}
Downloads
`; + +const reviewBlock = (value = '55') => + `
${value} reviews
`; + +const heading = '

Ratings and reviews

'; + +test('reads review count and install floor from a populated page', () => { + const html = `${heading}${reviewBlock('55')}${installBlock('1K+')}`; + assert.deepEqual(parsePlayMetrics(html, 'com.example.app'), { + ratings: 55, + installs: 1000, + }); +}); + +test('parses compact review counts', () => { + const html = `${reviewBlock('1.2K')}${installBlock('500K+')}`; + assert.deepEqual(parsePlayMetrics(html, 'com.example.app'), { + ratings: 1200, + installs: 500000, + }); +}); + +test('treats an absent review element as a real zero', () => { + // Newly released apps render the heading and install block but no count. + const html = `${heading}${installBlock('1+')}`; + assert.deepEqual(parsePlayMetrics(html, 'com.example.new'), { + ratings: 0, + installs: 1, + }); +}); + +test('throws when the install block is missing', () => { + const html = `${heading}${reviewBlock('55')}`; + assert.throws( + () => parsePlayMetrics(html, 'com.example.app'), + /install count not found/ + ); +}); + +test('throws when a review element exists but its count cannot be read', () => { + // Play kept the element and changed the number format underneath us. + const html = `${heading}
many reviews
1 234 reviews
${installBlock('1K+')}`; + assert.throws( + () => parsePlayMetrics(html, 'com.example.app'), + /could not read its count/ + ); +}); + +test('does not mistake review chrome for a count', () => { + const html = `${heading}Ratings and reviews${installBlock('10K+')}`; + assert.deepEqual(parsePlayMetrics(html, 'com.example.app'), { + ratings: 0, + installs: 10000, + }); +});